🤖 微信小程序 × 聚合数据 × 多厂商大模型:AI 生活助手实训

本页是「教学讲义 + 课堂沙盒」一体化单文件。包含完整 5 天主线内容、可交互大模型调用沙盒(Key 仅存本机 localStorage)、HTTP 调试工具、微信小程序界面原型交互演示,以及完整代码示例。

📱 微信小程序 🔗 聚合数据 juhe 🤖 DeepSeek / 千问 / Kimi / 豆包 🧩 OpenAI 兼容 API 💾 wx.storage 本地缓存 🚀 VibeCoding AI辅助开发

🎯 5天学习目标

Day1:项目启动 + 基础页面

开发工具 · 项目结构 · 首页静态布局

Day2:多页面 + tabBar + 布局

导航系统 · Flex布局 · rpx单位

Day3:网络请求 + 聚合数据

wx.request · juhe API · Mock兜底

Day4:多模型AI对话

OpenAI兼容 · 4厂商配置 · 对话展示

Day5:上线部署 + 答辩

合法域名 · 发布流程 · 演示答辩

📦 技术栈全景

层次技术
前端框架微信小程序原生 WXML/WXSS/JS
网络层wx.request + Promise封装
数据服务聚合数据 juhe.cn(新闻/天气/笑话/星座)
AI 对话DeepSeek / 阿里千问 / Kimi / 字节豆包
本地存储wx.setStorageSync(Key不写死在代码)
AI辅助开发VibeCoding / Cursor AI

📁 推荐目录结构

ai-assistant/
├── app.js          # 全局逻辑
├── app.json        # 页面注册/tabBar
├── app.wxss        # 全局样式
├── utils/
│   ├── request.js  # wx.request封装
│   └── aiChat.js   # AI 对话工具函数
├── pages/
│   ├── home/       # 首页宫格
│   ├── news/       # 新闻列表
│   ├── joke/       # 笑话
│   ├── weather/    # 天气
│   ├── chat/       # AI 对话
│   └── setting/    # 配置 Key
└── images/         # 图标资源

🔄 系统数据流总览

📱 小程序界面
WXML/WXSS/JS
🔧 utils/
request.js
🔗 juhe.cn
新闻/天气/笑话
🤖 AI 厂商
OpenAI兼容API
💾 localStorage
Key安全存储
💡 浏览器沙盒限制(课堂必讲)大模型厂商接口通常不允许浏览器跨域直连(CORS)。本页的沙盒工具会真实发起 fetch,若报 CORS,优先在小程序开发者工具 wx.request 中验证。这正好解释了小程序为何要配置「合法域名」。
🔐 API Key 安全规范Key 属于凭证:① 只存 wx.storage,不写死在源码里;② 提交前确认 .gitignore;③ 不推送到 GitHub/Gitee 公仓;④ 答辩结束后及时吊销演示用 Key。

📅 5天课程计划(每天约1小时)

每天包含教学目标、课堂流程建议、关键代码片段、验收标准。

Day 1:项目启动 + 小程序基础结构

开发环境 AppID配置 WXML/WXSS入门 首页静态布局

教学目标

让所有学生在第一天结束时,手机上能用微信扫码预览到自己修改过的页面。建立「能跑 → 能改 → 能解释」的信心。

课堂流程建议(60min)

安装 & 注册(10min)

微信开发者工具下载安装,注册小程序测试账号(无需企业认证),创建项目选「不使用云开发」。

认识 4 个核心文件(10min)

app.js 全局逻辑 · app.json 注册页面/配置窗口 · app.wxss 全局样式 · pages/index/index.wxml 首页模板

动手:修改首页 + 真机预览(20min)

改标题颜色、加一行文字、换一张 image。然后点「预览」用手机扫码验证效果。

讲解 data 绑定 + setData(15min)

在 .js 中定义 data,在 WXML 中用 {'{{'}变量{'}}'} 渲染,按钮触发 setData 更新视图。

VibeCoding 体验(5min)

用 Cursor 或 Copilot 输入「帮我写一个微信小程序首页,宫格布局,6个入口」,观察 AI 生成 WXML。

关键代码:app.json 页面注册

{
  "pages": [
    "pages/home/home",
    "pages/news/news",
    "pages/joke/joke",
    "pages/weather/weather",
    "pages/chat/chat",
    "pages/setting/setting"
  ],
  "window": {
    "navigationBarBackgroundColor": "#4527A0",
    "navigationBarTitleText": "AI 生活助手",
    "navigationBarTextStyle": "white",
    "backgroundColor": "#F5F7FA"
  },
  "tabBar": {
    "color": "#78909C",
    "selectedColor": "#4527A0",
    "list": [
      { "pagePath": "pages/home/home",    "text": "首页",   "iconPath": "images/home.png",    "selectedIconPath": "images/home-s.png" },
      { "pagePath": "pages/chat/chat",    "text": "AI",     "iconPath": "images/ai.png",      "selectedIconPath": "images/ai-s.png" },
      { "pagePath": "pages/setting/setting", "text": "我的", "iconPath": "images/me.png",   "selectedIconPath": "images/me-s.png" }
    ]
  }
}

关键代码:首页宫格 WXML

<!-- pages/home/home.wxml -->
<view class="header">
  <text class="title">AI 生活助手</text>
  <text class="sub">你的智能生活工具箱</text>
</view>

<view class="grid">
  <view class="item" bindtap="goNews">
    <text class="icon">📰</text>
    <text class="label">今日新闻</text>
  </view>
  <view class="item" bindtap="goJoke">
    <text class="icon">😄</text>
    <text class="label">每日笑话</text>
  </view>
  <view class="item" bindtap="goWeather">
    <text class="icon">🌤️</text>
    <text class="label">天气查询</text>
  </view>
  <view class="item" bindtap="goConstellation">
    <text class="icon">⭐</text>
    <text class="label">星座运势</text>
  </view>
  <view class="item" bindtap="goChat">
    <text class="icon">🤖</text>
    <text class="label">AI 助手</text>
  </view>
  <view class="item" bindtap="goSetting">
    <text class="icon">⚙️</text>
    <text class="label">配置中心</text>
  </view>
</view>

验收标准

✅ 能用手机扫码预览

项目能在开发者工具模拟器正常运行,且真机预览通过

✅ 能解释文件职责

能说出 app.js / app.json / .wxml / .wxss / .js 各自的作用

✅ 能修改并看到效果

改了标题/颜色/文字后,模拟器立即反映变化

Day 2:多页面导航 + tabBar + 响应式布局

页面栈 tabBar Flex布局 rpx单位

教学目标

把「一个页面」扩展成「一个有结构的产品骨架」——能导航,能切 Tab,布局自适应屏幕宽度。

课堂流程建议(60min)

页面导航两种方式(15min)

wx.navigateTo(有返回按钮,入栈)vs wx.switchTab(tabBar 页,不入栈)。讲清楚哪些页面用哪种。

配置 tabBar(15min)

在 app.json 配置 3 个 Tab,准备 icon 图标(建议 AI 生成 40×40 PNG),讲解常见报错:路径不存在、图标尺寸过大。

Flex + rpx 布局(20min)

rpx 是小程序专用单位:750rpx = 屏幕宽度。用 display:flex 做宫格,保证在不同机型自适应。

页面间传参(10min)

wx.navigateTo({'{'}url:'/pages/detail/detail?id=123&name=xx'{'}'}),目标页在 onLoad(options) 里拿参数。

关键代码:导航 + 传参

// pages/home/home.js
Page({
  goNews() {
    wx.navigateTo({ url: '/pages/news/news?type=top' })
  },
  goChat() {
    wx.switchTab({ url: '/pages/chat/chat' })  // tabBar 页必须用 switchTab
  }
})

// pages/news/news.js  - 接收参数
Page({
  data: { type: 'top', list: [] },
  onLoad(options) {
    const type = options.type || 'top'
    this.setData({ type })
    this.loadNews(type)
  },
  loadNews(type) {
    // Day3 填充网络请求
    wx.showLoading({ title: '加载中' })
    // ...
  }
})

关键代码:宫格布局 WXSS

/* pages/home/home.wxss */
.grid {
  display: flex;
  flex-wrap: wrap;
  padding: 20rpx;
  gap: 20rpx;
}
.item {
  width: calc(33.33% - 14rpx);
  background: #fff;
  border-radius: 16rpx;
  padding: 30rpx 20rpx;
  display: flex;
  flex-direction: column;
  align-items: center;
  box-shadow: 0 2rpx 12rpx rgba(0,0,0,.08);
}
.icon { font-size: 52rpx; margin-bottom: 12rpx; }
.label { font-size: 26rpx; color: #455A64; font-weight: bold; }
📐 rpx 换算记忆法设计稿 750px 等比 = 750rpx。你手机实际像素宽 × (750/设计稿宽) = rpx值。简单记:Figma/UI图按750px设计,量到多少px就写多少rpx。
⚠️ tabBar 图标常见错误① 图片不是 PNG → 改格式 ② 图片 > 40KB → 压缩 ③ pagePath 路径写了 .html 后缀 → 删掉 ④ tabBar 页用了 navigateTo → 改 switchTab

Day 3:网络请求 + 聚合数据服务 + Mock兜底

wx.request juhe API Promise封装 Mock数据

教学目标

让学生理解:接口是产品的心脏,但课堂要有「不断电」的备份方案(mock 数据)。掌握封装 request 的工程思维。

课堂主线(60min)

封装 wx.request 为 Promise(15min)

抽出 utils/request.js,统一处理 loading、错误提示、超时,让业务代码只关心数据。

申请 juhe 接口 Key,接入新闻列表(20min)

juhe.cn 注册 → 申请「今日头条新闻」免费接口 → 在设置页保存 Key → 新闻页调用展示。

Mock 数据备份方案(10min)

当 Key 申请未通过或网络异常时,从本地 JSON 文件读取 mock 数据,保证课堂演示不卡壳。

星座/笑话页面(15min)

picker 选择星座 → 请求运势接口 → 展示多字段。笑话列表 → 每日随机一条。

关键代码:utils/request.js

// utils/request.js
const BASE_JUHE = 'https://v.juhe.cn'

function request(url, method = 'GET', data = {}) {
  return new Promise((resolve, reject) => {
    wx.showNavigationBarLoading()
    wx.request({
      url,
      method,
      data,
      timeout: 10000,
      success(res) {
        wx.hideNavigationBarLoading()
        if (res.statusCode === 200) {
          resolve(res.data)
        } else {
          reject(new Error('HTTP ' + res.statusCode))
        }
      },
      fail(err) {
        wx.hideNavigationBarLoading()
        wx.showToast({ title: '网络异常', icon: 'error' })
        reject(err)
      }
    })
  })
}

// 新闻接口封装
function getNews(type = 'top', key) {
  if (!key) return Promise.resolve(MOCK_NEWS)   // 无 Key 走 mock
  return request(`${BASE_JUHE}/toutiao/index?type=${type}&key=${key}`)
    .catch(() => MOCK_NEWS)                      // 接口失败也走 mock
}

// 笑话接口封装
function getJoke(key) {
  if (!key) return Promise.resolve(MOCK_JOKE)
  return request(`${BASE_JUHE}/joke/content/list.php?sort=rand&page=1&pagesize=1&key=${key}`)
    .catch(() => MOCK_JOKE)
}

// Mock 数据
const MOCK_NEWS = {
  reason: 'mock',
  result: {
    data: [
      { title: '【演示】今日科技头条:AI大模型持续演进', category: '科技', date: '2025-05-14', url: 'https://example.com' },
      { title: '【演示】全球气候峰会:各国承诺碳中和目标', category: '国际', date: '2025-05-14', url: 'https://example.com' },
      { title: '【演示】本地餐饮业恢复强劲,夜经济持续升温', category: '本地', date: '2025-05-14', url: 'https://example.com' }
    ]
  }
}

const MOCK_JOKE = {
  reason: 'mock',
  result: {
    data: [{ content: '演示笑话:程序员最喜欢的运动是什么?—— 跑(bug)步!' }]
  }
}

module.exports = { request, getNews, getJoke }

关键代码:新闻页调用

// pages/news/news.js
const { getNews } = require('../../utils/request')

Page({
  data: { list: [], loading: true },
  onLoad() {
    const key = wx.getStorageSync('juhe_key') || ''
    getNews('top', key).then(res => {
      const list = (res.result && res.result.data) || []
      this.setData({ list, loading: false })
    })
  },
  goDetail(e) {
    const url = encodeURIComponent(e.currentTarget.dataset.url)
    wx.navigateTo({ url: `/pages/webview/webview?url=${url}` })
  }
})
🚨 教师演示建议课堂先演示「无 Key → mock 数据」,再演示「填入 Key → 真实接口」。避免一半学生卡在「juhe 审核中」无法继续。
🌐 合法域名提前配置小程序发布前需在「微信公众平台 → 开发设置 → 服务器域名」添加:v.juhe.cn。开发阶段可勾选「不校验合法域名」跳过。

Day 4:多厂商大模型 AI 对话集成

OpenAI兼容 DeepSeek / 千问 / Kimi / 豆包 对话历史 Token展示

教学目标

理解 OpenAI 兼容接口的统一格式,实现可复用的 AI 调用工具函数,在小程序中完整展示「配置 Key → 发送消息 → 查看模型信息」全流程。

课堂主线(60min)

OpenAI 兼容格式讲解(10min)

POST /v1/chat/completions,body:{ model, messages:[{role,content}], temperature },response:choices[0].message.content + usage。

4厂商 Endpoint 对比 + 豆包特殊说明(10min)

豆包 model 填 ep-xxxx(Endpoint ID,非模型名),其余3个填模型名字符串即可。

封装 utils/aiChat.js(15min)

通用 chat() 函数,传入 provider / messages → 返回 { text, usage, duration }。

对话页实现(20min)

消息列表渲染 + 输入框 + 发送 + loading 气泡 + 本次调用信息展开卡片。

配置页实现(5min)

picker 选厂商 → 输入 Key → 输入 model → wx.setStorageSync 保存。

4大厂商 API 对比

厂商Endpointmodel 示例官网
DeepSeekhttps://api.deepseek.com/v1/chat/completionsdeepseek-chatplatform.deepseek.com
阿里千问https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completionsqwen-turbo
qwen-plus
dashscope.console.aliyun.com
Kimihttps://api.moonshot.cn/v1/chat/completionsmoonshot-v1-8kplatform.moonshot.cn
字节豆包https://ark.cn-beijing.volces.com/api/v3/chat/completionsep-xxxxxxxx(Endpoint ID)console.volcengine.com/ark

关键代码:utils/aiChat.js

// utils/aiChat.js
const PROVIDERS = {
  deepseek: { url: 'https://api.deepseek.com/v1/chat/completions', defaultModel: 'deepseek-chat' },
  qwen:     { url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', defaultModel: 'qwen-turbo' },
  moonshot: { url: 'https://api.moonshot.cn/v1/chat/completions', defaultModel: 'moonshot-v1-8k' },
  doubao:   { url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', defaultModel: '' }
}

function chat(provider, apiKey, model, messages, systemPrompt = '你是一个智能助手,回答简洁清晰。') {
  const cfg = PROVIDERS[provider]
  if (!cfg) return Promise.reject(new Error('未知厂商'))

  const t0 = Date.now()
  const allMessages = [
    { role: 'system', content: systemPrompt },
    ...messages
  ]

  return new Promise((resolve, reject) => {
    wx.request({
      url: cfg.url,
      method: 'POST',
      timeout: 30000,
      header: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + apiKey
      },
      data: {
        model: model || cfg.defaultModel,
        messages: allMessages,
        temperature: 0.7
      },
      success(res) {
        const duration = Date.now() - t0
        if (res.statusCode === 200 && res.data.choices) {
          resolve({
            text: res.data.choices[0].message.content,
            usage: res.data.usage || {},
            duration,
            model: res.data.model || model,
            status: res.statusCode
          })
        } else {
          reject({
            status: res.statusCode,
            msg: (res.data && res.data.error && res.data.error.message) || JSON.stringify(res.data)
          })
        }
      },
      fail(err) {
        reject({ status: -1, msg: err.errMsg || '网络异常' })
      }
    })
  })
}

function loadConfig() {
  return {
    provider: wx.getStorageSync('ai_provider') || 'deepseek',
    key: wx.getStorageSync('ai_key') || '',
    model: wx.getStorageSync('ai_model') || ''
  }
}

function saveConfig(provider, key, model) {
  wx.setStorageSync('ai_provider', provider)
  wx.setStorageSync('ai_key', key)
  wx.setStorageSync('ai_model', model)
}

module.exports = { chat, loadConfig, saveConfig, PROVIDERS }

关键代码:对话页 JS

// pages/chat/chat.js
const { chat, loadConfig } = require('../../utils/aiChat')

Page({
  data: {
    messages: [],       // 对话历史 [{role:'user'|'assistant', content}]
    inputText: '',
    sending: false,
    lastMeta: null      // 最后一次调用信息
  },
  onLoad() {
    const cfg = loadConfig()
    this.setData({ cfg })
  },
  onInput(e) {
    this.setData({ inputText: e.detail.value })
  },
  async send() {
    const text = this.data.inputText.trim()
    if (!text || this.data.sending) return

    const cfg = loadConfig()
    if (!cfg.key) {
      wx.showModal({ title: '提示', content: '请先在「我的」页面配置 API Key', showCancel: false })
      return
    }

    // 加入用户消息
    const messages = [...this.data.messages, { role: 'user', content: text }]
    this.setData({ messages, inputText: '', sending: true })

    try {
      const res = await chat(cfg.provider, cfg.key, cfg.model, messages)
      // 加入 AI 回复
      const updated = [...messages, { role: 'assistant', content: res.text }]
      this.setData({
        messages: updated,
        sending: false,
        lastMeta: {
          provider: cfg.provider,
          model: res.model,
          duration: res.duration + 'ms',
          promptTokens: res.usage.prompt_tokens || '-',
          completionTokens: res.usage.completion_tokens || '-',
          totalTokens: res.usage.total_tokens || '-'
        }
      })
    } catch(err) {
      const updated = [...messages, { role: 'assistant', content: `[错误 ${err.status}] ${err.msg}` }]
      this.setData({ messages: updated, sending: false })
    }
  },
  clearHistory() {
    wx.showModal({
      title: '确认', content: '清空对话历史?',
      success: (res) => { if(res.confirm) this.setData({ messages: [], lastMeta: null }) }
    })
  }
})
📊 课堂演示脚本(5分钟)我的 → 选厂商(DeepSeek)→ 粘贴 Key → 保存 → AI页输入「用3句话介绍你自己」→ 发送 → 展开「本次调用信息」看 token 用量和耗时。

Day 5:上线部署 + 答辩演示

合法域名配置 代码审查 上传发布 答辩演示

上午:最终检查清单

检查项方法说明
✅ tabBar 可正常切换模拟器点击3个Tab都能到达
✅ 所有页面路径存在app.json核对无404页面
✅ 网络请求有loading提示真机测试避免空白等待
✅ 无Key时有友好提示清空storage测试提示去配置页填Key
✅ Key不写死在代码里全局搜索sk-/ep-用wx.getStorageSync读取
✅ API Key已清理(演示用)查看storage提交代码前必做

下午:合法域名配置 + 上传

微信公众平台配置

登录 mp.weixin.qq.com → 开发 → 开发管理 → 服务器域名,添加:v.juhe.cn api.deepseek.com api.moonshot.cn dashscope.aliyuncs.com ark.cn-beijing.volces.com

开发者工具上传

点「上传」→ 填写版本号(如 1.0.0)和版本说明 → 提交。登录公众平台 → 版本管理 → 将版本设为体验版。

体验版扫码测试

管理员扫体验版二维码,验证合法域名模式下所有接口正常(不勾选「不校验合法域名」)。

答辩演示路径(建议3-5分钟)

① 展示首页

宫格布局,说明每个入口的功能

② 演示新闻列表

进入新闻页,展示真实juhe数据

③ 演示AI对话

发送一条消息,展示模型信息卡片

④ 讲解一段核心代码

推荐:utils/aiChat.js 的chat()函数

✨ 加分展示① 切换不同 AI 厂商对比回复 ② 展示 token 用量与费用估算 ③ 演示断网时 mock 数据兜底 ④ 展示 Key 安全存储方案

🧠 大模型沙盒(Key 仅存浏览器 localStorage,不上传服务器)

选择厂商 → 填写 Key 和模型名 → 保存到本机 → 在对话框测试。支持多轮对话,展示模型信息和 token 用量。

⚠️ CORS 说明若浏览器报跨域错误,是正常的(AI厂商限制了非小程序直连)。请改在微信开发者工具的 wx.request 里验证,或开启「不校验合法域名」模式。这本身就是课堂讲解点。

🌐 HTTP 调试工具(GET / POST + JSON格式化)

课堂演示 juhe 接口、公开测试接口,或调试自建后端转发服务。浏览器可能遇到 CORS,这正好解释「小程序为何要配合法域名」。

{}

📱 微信小程序界面原型(可交互演示)

用于课堂讲解信息架构:底部 Tab 导航、首页宫格入口、AI 对话、配置中心。点击底部 Tab 切换页面,AI 对话框可模拟发送消息。

AI 生活助手
你好 👋
今天是
📰
今日新闻
😄
每日笑话
🌤️
天气查询
星座运势
🤖
AI 助手
⚙️
配置中心
📌 今日一句
「把需求拆成页面,把接口拆成函数,把问题拆成步骤。」
🤖 AI 助手
你好!我是 AI 助手,有什么可以帮你?
⚙️ 配置中心
聚合数据 Key
juhe_key = ••••••••
大模型配置
当前厂商DeepSeek
API Keysk-••••
模型deepseek-chat
已保存
✅ Key 仅存本地 wx.storage,不发送到第三方服务器
今日新闻
AI大模型持续演进,国产模型竞争白热化
科技 · 2025-05-14
全球气候峰会:各国承诺新一轮碳中和目标
国际 · 2025-05-14
本地夜经济持续升温,餐饮收入创新高
本地 · 2025-05-14
以上为 mock 数据演示
🏠 首页
🤖 AI
⚙️ 我的

📐 页面结构说明

页面路径核心技术
首页宫格pages/homeFlex Grid + navigateTo
新闻列表pages/newswx.request + scroll-view
AI 对话pages/chataiChat.js + 消息列表
配置中心pages/settingwx.setStorageSync
WebViewpages/webviewweb-view 组件

🧭 导航规则

wx.switchTab 只能跳转 tabBar 页(首页/AI/我的)。tabBar 不入栈,没有「返回」按钮。
wx.navigateTo 跳转非 tabBar 页(新闻/详情/笑话)。会入栈,左上角有返回箭头。

💻 完整代码参考

// utils/aiChat.js  — 完整版
const PROVIDERS = {
  deepseek: {
    label: 'DeepSeek',
    url: 'https://api.deepseek.com/v1/chat/completions',
    defaultModel: 'deepseek-chat'
  },
  qwen: {
    label: '阿里千问',
    url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
    defaultModel: 'qwen-turbo'
  },
  moonshot: {
    label: 'Kimi',
    url: 'https://api.moonshot.cn/v1/chat/completions',
    defaultModel: 'moonshot-v1-8k'
  },
  doubao: {
    label: '字节豆包',
    url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
    defaultModel: ''  // 必须填 ep-xxxx,无默认值
  }
}

/**
 * 发送 AI 对话请求
 * @param {string} provider  - 厂商 key(deepseek/qwen/moonshot/doubao)
 * @param {string} apiKey    - API Key
 * @param {string} model     - 模型名或 Endpoint ID
 * @param {Array}  messages  - 历史消息 [{role:'user'|'assistant', content:'...'}]
 * @param {string} systemPrompt - 系统提示词
 * @returns Promise<{text, usage, duration, model, status}>
 */
function chat(provider, apiKey, model, messages, systemPrompt) {
  const cfg = PROVIDERS[provider]
  if (!cfg) return Promise.reject({ status: -1, msg: '未知厂商: ' + provider })
  if (!apiKey) return Promise.reject({ status: -1, msg: '未配置 API Key' })

  systemPrompt = systemPrompt || '你是一个智能生活助手,回答简洁清晰,使用中文。'
  const t0 = Date.now()
  const allMessages = [
    { role: 'system', content: systemPrompt },
    ...messages.slice(-20)    // 最多保留最近20条,避免超 token
  ]

  return new Promise((resolve, reject) => {
    wx.request({
      url: cfg.url,
      method: 'POST',
      timeout: 30000,
      header: {
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + apiKey
      },
      data: {
        model: model || cfg.defaultModel,
        messages: allMessages,
        temperature: 0.7,
        max_tokens: 1000
      },
      success(res) {
        const duration = Date.now() - t0
        if (res.statusCode === 200 && res.data && res.data.choices) {
          resolve({
            text: res.data.choices[0].message.content || '',
            usage: res.data.usage || {},
            duration,
            model: res.data.model || (model || cfg.defaultModel),
            status: 200
          })
        } else {
          const errMsg = res.data && res.data.error
            ? res.data.error.message
            : JSON.stringify(res.data).slice(0, 200)
          reject({ status: res.statusCode, msg: errMsg })
        }
      },
      fail(err) {
        reject({ status: -1, msg: err.errMsg || '网络请求失败' })
      }
    })
  })
}

function loadConfig() {
  return {
    provider: wx.getStorageSync('ai_provider') || 'deepseek',
    key: wx.getStorageSync('ai_key') || '',
    model: wx.getStorageSync('ai_model') || ''
  }
}

function saveConfig(provider, key, model) {
  wx.setStorageSync('ai_provider', provider)
  wx.setStorageSync('ai_key', key)
  wx.setStorageSync('ai_model', model)
}

module.exports = { chat, loadConfig, saveConfig, PROVIDERS }
// utils/request.js  — Promise 封装 wx.request
const JUHE_BASE = 'https://v.juhe.cn'

function request(url, method, data, options) {
  method = method || 'GET'
  data = data || {}
  options = options || {}

  return new Promise((resolve, reject) => {
    if (options.showLoading !== false) {
      wx.showNavigationBarLoading()
    }
    wx.request({
      url, method, data,
      timeout: options.timeout || 10000,
      success(res) {
        wx.hideNavigationBarLoading()
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data)
        } else {
          reject(new Error('HTTP ' + res.statusCode))
        }
      },
      fail(err) {
        wx.hideNavigationBarLoading()
        if (options.silent !== true) {
          wx.showToast({ title: '网络异常,请重试', icon: 'error', duration: 2000 })
        }
        reject(err)
      }
    })
  })
}

// --- juhe 接口封装 ---

const MOCK = {
  news: {
    error_code: 0, result: {
      data: [
        { title: 'AI大模型竞争白热化', category: '科技', date: '2025-05-14', url: 'https://example.com' },
        { title: '全球气候峰会新进展', category: '国际', date: '2025-05-14', url: 'https://example.com' }
      ]
    }
  },
  joke: {
    error_code: 0, result: {
      data: [{ content: '程序员最喜欢的运动是什么?跑(bug)步!' }]
    }
  }
}

function getNews(type, key) {
  if (!key) return Promise.resolve(MOCK.news)
  return request(`${JUHE_BASE}/toutiao/index?type=${type || 'top'}&key=${key}`)
    .catch(() => MOCK.news)
}

function getJoke(key) {
  if (!key) return Promise.resolve(MOCK.joke)
  return request(`${JUHE_BASE}/joke/content/list.php?sort=rand&page=1&pagesize=3&key=${key}`)
    .catch(() => MOCK.joke)
}

function getWeather(city, key) {
  if (!key || !city) return Promise.reject(new Error('缺少city或key'))
  return request(`${JUHE_BASE}/weather/index?city=${encodeURIComponent(city)}&dtype=json&format=2&key=${key}`)
}

module.exports = { request, getNews, getJoke, getWeather }
<!-- pages/chat/chat.wxml -->
<view class="page">

  <!-- 消息列表 -->
  <scroll-view class="msg-list" scroll-y scroll-into-view="{{scrollTo}}" scroll-with-animation>
    <view wx:for="{{messages}}" wx:key="index" class="msg-wrap">
      <view class="bubble {{item.role}}">{{item.content}}</view>
    </view>
    <!-- loading 气泡 -->
    <view wx:if="{{sending}}" class="msg-wrap">
      <view class="bubble assistant loading">
        <text>思考中 </text>
        <view class="dots"><view/><view/><view/></view>
      </view>
    </view>
    <view id="bottom"></view>
  </scroll-view>

  <!-- 调用信息(可展开) -->
  <view wx:if="{{lastMeta}}" class="meta-bar" bindtap="toggleMeta">
    <text>{{metaOpen ? '▲' : '▼'}} 本次调用:{{lastMeta.model}} · {{lastMeta.duration}} · {{lastMeta.totalTokens}} tokens</text>
  </view>
  <view wx:if="{{lastMeta && metaOpen}}" class="meta-detail">
    <view>厂商:{{lastMeta.provider}}</view>
    <view>模型:{{lastMeta.model}}</view>
    <view>耗时:{{lastMeta.duration}}</view>
    <view>提示词 tokens:{{lastMeta.promptTokens}}</view>
    <view>回复 tokens:{{lastMeta.completionTokens}}</view>
  </view>

  <!-- 输入框 -->
  <view class="input-bar">
    <textarea
      class="input"
      value="{{inputText}}"
      bindinput="onInput"
      placeholder="输入消息,Enter 换行,点发送…"
      auto-height
      maxlength="2000"
    />
    <button class="send-btn" bindtap="send" disabled="{{sending}}">发送</button>
    <button class="clear-btn" bindtap="clearHistory">清空</button>
  </view>
</view>
// pages/chat/chat.js
const { chat, loadConfig } = require('../../utils/aiChat')

Page({
  data: {
    messages: [],
    inputText: '',
    sending: false,
    lastMeta: null,
    metaOpen: false,
    scrollTo: ''
  },

  onLoad() {
    const cfg = loadConfig()
    if (!cfg.key) {
      wx.showModal({
        title: '还没配置 Key',
        content: '请先去「我的」页面配置 AI 厂商和 API Key',
        showCancel: false,
        success: () => wx.switchTab({ url: '/pages/setting/setting' })
      })
    }
  },

  onInput(e) {
    this.setData({ inputText: e.detail.value })
  },

  toggleMeta() {
    this.setData({ metaOpen: !this.data.metaOpen })
  },

  async send() {
    const text = this.data.inputText.trim()
    if (!text || this.data.sending) return

    const cfg = loadConfig()
    if (!cfg.key) {
      wx.showToast({ title: '请先配置 API Key', icon: 'none' })
      return
    }

    const messages = [
      ...this.data.messages,
      { role: 'user', content: text }
    ]
    this.setData({ messages, inputText: '', sending: true, scrollTo: 'bottom' })

    try {
      const res = await chat(cfg.provider, cfg.key, cfg.model, messages)
      const updated = [...messages, { role: 'assistant', content: res.text }]
      this.setData({
        messages: updated,
        sending: false,
        scrollTo: 'bottom',
        lastMeta: {
          provider: cfg.provider,
          model: res.model,
          duration: res.duration + 'ms',
          promptTokens: res.usage.prompt_tokens ?? '-',
          completionTokens: res.usage.completion_tokens ?? '-',
          totalTokens: res.usage.total_tokens ?? '-'
        }
      })
    } catch(err) {
      const errText = `[错误 ${err.status}] ${err.msg}`
      const updated = [...messages, { role: 'assistant', content: errText }]
      this.setData({ messages: updated, sending: false })
      wx.showToast({ title: err.msg.slice(0, 20), icon: 'none' })
    }
  },

  clearHistory() {
    wx.showModal({
      title: '确认清空', content: '清空后无法恢复',
      success: res => {
        if (res.confirm) this.setData({ messages: [], lastMeta: null, metaOpen: false })
      }
    })
  }
})
// pages/setting/setting.js
const { saveConfig, loadConfig, PROVIDERS } = require('../../utils/aiChat')

Page({
  data: {
    providerList: [],
    providerIndex: 0,
    apiKey: '',
    model: '',
    juheKey: '',
    saved: false
  },

  onLoad() {
    const providerList = Object.entries(PROVIDERS).map(([k, v]) => v.label)
    const cfg = loadConfig()
    const providerIndex = Object.keys(PROVIDERS).indexOf(cfg.provider)

    this.setData({
      providerList,
      providerIndex: providerIndex >= 0 ? providerIndex : 0,
      apiKey: cfg.key,
      model: cfg.model,
      juheKey: wx.getStorageSync('juhe_key') || ''
    })
    this._providerKeys = Object.keys(PROVIDERS)
  },

  onProviderChange(e) {
    const idx = e.detail.value
    const key = this._providerKeys[idx]
    const defaultModel = PROVIDERS[key].defaultModel
    this.setData({ providerIndex: idx, model: defaultModel })
  },

  onKeyInput(e)   { this.setData({ apiKey: e.detail.value }) },
  onModelInput(e) { this.setData({ model: e.detail.value }) },
  onJuheInput(e)  { this.setData({ juheKey: e.detail.value }) },

  save() {
    const provider = this._providerKeys[this.data.providerIndex]
    saveConfig(provider, this.data.apiKey, this.data.model)
    wx.setStorageSync('juhe_key', this.data.juheKey)
    this.setData({ saved: true })
    wx.showToast({ title: '已保存到本机', icon: 'success' })
    setTimeout(() => this.setData({ saved: false }), 3000)
  },

  clearAll() {
    wx.showModal({
      title: '确认清除', content: '清除所有已保存的 Key?',
      success: res => {
        if (!res.confirm) return
        wx.clearStorageSync()
        this.setData({ apiKey: '', model: '', juheKey: '' })
        wx.showToast({ title: '已清除', icon: 'success' })
      }
    })
  }
})

🔗 接口速查 & 合法域名列表

需要在微信公众平台配置的合法域名

用途域名(加入 request 合法域名)备注
聚合数据新闻/笑话/天气v.juhe.cn需 juhe.cn 账号申请接口 Key
DeepSeekapi.deepseek.comOpenAI 兼容,/v1/chat/completions
阿里千问 DashScopedashscope.aliyuncs.com兼容模式路径:/compatible-mode/v1/chat/completions
Kimi Moonshotapi.moonshot.cnOpenAI 兼容,/v1/chat/completions
字节豆包火山方舟ark.cn-beijing.volces.com/api/v3/chat/completions,model 填 ep-xxxx

juhe 常用接口

接口URL参数
今日头条新闻https://v.juhe.cn/toutiao/indextype=top&key=YOUR_KEY
笑话大全https://v.juhe.cn/joke/content/list.phpsort=rand&page=1&pagesize=5&key=YOUR_KEY
天气查询https://v.juhe.cn/weather/indexcity=北京&dtype=json&format=2&key=YOUR_KEY
星座运势https://web.juhe.cn/constellation/getAllconsName=白羊座&key=YOUR_KEY

公共测试接口(无需 Key)

用途URL
文章列表(GET)https://jsonplaceholder.typicode.com/posts
单条记录(GET)https://jsonplaceholder.typicode.com/posts/1
创建记录(POST)https://jsonplaceholder.typicode.com/posts
📋 juhe Key 申请流程 注册 juhe.cn → 进入控制台 → 数据中心 → 申请所需接口(大部分每天500次免费)→ 复制 AppKey → 在小程序配置页粘贴保存。
💰 AI 接口费用参考(2025年) DeepSeek chat:¥1/百万tokens(极低成本,推荐课堂使用);千问 turbo:¥0.3/百万tokens;Kimi moonshot-v1-8k:¥12/百万tokens;豆包:按 Endpoint 计费。