uniapp的一些记录

354 阅读14分钟

页面和路由

路由配置(pages.json)

  • pages.json 是全局页面路由配置文件(类似微信小程序的 app.json)。
  • 配置格式:
{
  "pages": [
    {
      "path": "pages/index/index", 
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/about/about",
      "style": {
        "navigationBarTitleText": "关于我们"
      }
    }
  ],
  "subPackages": [ // 分包
    {
      "root": "packageA",
      "name": "packageA",
      "pages": [
        {
          "path": "packageA/test/test",
          "style": {
            "navigationBarTitleText": "分包1页面"
          }
        },
          {
          "path": "packageA/test2/test2",
          "style": {
            "navigationBarTitleText": "分包2页面"
          }
        }
      ]
    },
       {
      "root": "packageB",
      "name": "packageB",
      "pages": [
        {
          "path": "packageB/test/test",
          "style": {
            "navigationBarTitleText": "分包B页面"
          }
        }
      ]
    }
  ],
	"preloadRule": { // 进入pages/index/index加载分包
		"pages/index/index": {
			"network": "all",
			"packages": ["packageA", "packageB"]
		},
	},
}

页面生命周期

页面生命周期钩子函数:

  • onLoad(options):页面加载时触发(只执行一次,常用于获取路由参数
  • onShow():页面每次显示时触发(返回时也会执行,常用于刷新数据
  • onReady():页面首次渲染完成时触发(只执行一次,常用于操作DOM或第三方插件
  • onHide():页面被隐藏(切换到后台或跳转到其他页面
  • onUnload():页面卸载(关闭时触发

路由跳转方式 (uni.navigateTo(OBJECT) | uni-app官网)

跳转方式API 名称是否传参作用说明特点
页面跳转(保留栈)uni.navigateTo从 A → B,返回 A页面栈最多 10 层
页面跳转(关闭当前页)uni.redirectTo从 A → B,关闭 A用于不需要返回的场景
Tab 页面跳转uni.switchTab跳转到 tabBar 中配置的页面只能跳转到 tabBar 配置的页面
重启应用跳转uni.reLaunch清空页面栈,打开新页面场景如退出登录/登录成功
返回上一页uni.navigateBack从 B → 返回 A可指定 delta(返回层级)

A页面

const id = 123
const type = 'order'

// 不可跳 tabBar 页
uni.navigateTo({
  url: `/pages/detail/index?id=${id}&type=${type}`
})
uni.redirectTo({
  url: '/pages/login/index'
})

// 只能跳转到 tabBar 中配置的页面(`pages.json > tabBar`)
uni.switchTab({
  url: '/pages/home/index'
})

uni.reLaunch({
  url: '/pages/login/index'
})

uni.navigateBack({
  delta: 1 // 返回上一级,默认也是1
})

B页面

//在onLoad中获取
onLoad(options) {
  console.log('跳转参数:', options.id, options.type)
}

自定义tabbar

  1. pages.json 配置自定义 tabbar
{
  "pages": [
    {
      "path": "pages/index/index",
      "style": {
        "navigationBarTitleText": "首页"
      }
    },
    {
      "path": "pages/mine/mine",
      "style": {
        "navigationBarTitleText": "我的"
      }
    }
  ],
  "tabBar": {
    "custom": true,
    "list": [
      {
        "pagePath": "pages/index/index",
        "text": "首页"
      },
      {
        "pagePath": "pages/mine/mine",
        "text": "我的"
      }
    ]
  }
}

  1. 新建自定义 tabbar 组件(新建 components/custom-tabbar/custom-tabbar.vue
<template>
	<view class="tabbar">
		<view v-for="(item,index) in list" :key="index" class="tabbar-item" @click="switchTab(index)">
			<image :src="selected === index ? item.selectedIconPath : item.iconPath" class="tabbar-icon"
				mode="aspectFit"
				:style="{ width: item.width + 'rpx', height: item.height + 'rpx', marginTop: item.iconTop + 'rpx' }">
			</image>
			<text :class="['tabbar-text', selected === index ? 'active' : '']">
				{{ item.text }}
			</text>
		</view>
	</view>
</template>

<script>
	export default {
		props: {
			selected: {
				type: Number,
				default: 0
			},
		},
		data() {
			return {
				list: [{
						pagePath: "/pages/index/index",
						text: "首页",
						iconPath: "/static/tab_icons/home.png",
						selectedIconPath: "/static/tab_icons/home-active.png",
						width: 60,
						height: 60,
						iconTop: 0
					},
					{
						pagePath: "/pages/Health/Health",
						text: "我的",
						iconPath: "/static/tab_icons/word.png",
						selectedIconPath: "/static/tab_icons/word-active.png",
						width: 120,
						height: 120,
						iconTop: 30
					}
				]
			}
		},
		methods: {
			switchTab(index) {
				uni.switchTab({
					url: this.list[index].pagePath
				});
			}
		}
	}
</script>

<style scoped>
	.tabbar {
		position: fixed;
		bottom: 0;
		left: 0;
		right: 0;
		/* height: 160rpx; */
		/* tabbar总高度 */
		display: flex;
		background-color: #fff;
		border-top: 1px solid #eee;
		z-index: 999;
	}

	.tabbar-item {
		flex: 1;
		text-align: center;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: flex-end;
		/* ✅ 关键:让内容整体靠下 */
		padding-bottom: 30rpx;
		/* ✅ 控制文字距离底部的间距 */
	}

	.icon-box {
		height: 70rpx;
		/* 图标容器高度,保证图标上下居中 */
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.tabbar-icon {
		display: block;
	}

	.tabbar-text {
		font-size: 24rpx;
		color: #999;
		margin-top: 6rpx;
		/* 图标和文字的间距 */
	}

	.tabbar-text.active {
		color: #007AFF;
	}
</style>
  1. 在每个页面引入自定义 tabbar
<template>
  <view class="container">
    <text>这里是首页</text>
    <!-- 自定义tabbar -->
    <custom-tabbar :selected="0"></custom-tabbar>
  </view>
</template>

<script>
import customTabbar from "@/components/custom-tabbar/custom-tabbar.vue";

export default {
  components: { customTabbar }
}
</script>

<style>
.container {
  padding-bottom: 100rpx; /* 预留tabbar空间 */
}
</style>

小程序下拉刷新,下拉触底

场景方法 / 事件是否自动触发适用
下拉刷新(页面)onPullDownRefresh()页面滚动
上拉加载(页面)onReachBottom()页面滚动
scroll-view 下拉@refresherrefresh❌ 手动触发自定义组件内
scroll-view 上拉@scrolltolower❌ 手动触发自定义组件内
  • pages.json 中对应页面项添加
  1. 下拉刷新 “enablePullDownRefresh”: true 是必须配置项否则不会触发回调
  2. 上拉触底 “onReachBottomDistance”:150 设置距离,默认所有页面都支持触底事件
{
	"path": "pages/index/index",
	"style": {
            "enablePullDownRefresh": true,
            "onReachBottomDistance": 150
	}
},

具体页面

<template>
    <view class="OxygenCabinBox" v-for="(item,index) in List" :key="index">{{item.name</view>

    <u-loadmore :marginTop="20" :marginBottom="20" :load-text="loadText" bg-color="rgba(0, 0, 0, 0)" :status="loadStatus"></u-loadmore>
</template>

<script setup>
import {ref,reactive} from 'vue';
import {onShow,onLoad,onHide,onReachBottom,onPullDownRefresh} from '@dcloudio/uni-app'

onLoad(async () => {
	loadStatus.value = 'loading';
        await GetList()
})

let loadText = ref({
	loadmore: '轻轻上拉',
	loading: '努力加载中',
	nomore: '拉到底了'
})
// 下拉刷新的事件
onPullDownRefresh(async () => {
	// 1. 重置关键数据
	loadStatus.value = 'loadmore';
	PageIndex.value = 1
	total.value = 0
	List.value = []
	// 2. 重新发起请求
	await GetList(() => uni.stopPullDownRefresh())
})
// 上拉触底
onReachBottom(() => {
	if (PageIndex.value * PageSize.value > total.value) {
		loadStatus.value = 'nomore';
	} else {
		loadStatus.value = 'loadmore';
		PageIndex.value = ++PageIndex.value;
		GetList();
	}
	console.log("触底");
})

let List = ref([])
let PageIndex = ref(1)
let PageSize = ref(10)
let total = ref(0)
let loadStatus = ref('')
let query = reactive({})

const GetList = async (cb) => {
	loadStatus.value = 'loading';
	let parm = {
		PageIndex: PageIndex.value,
		PageSize: PageSize.value,
		...query
	}
	const res = await OxygenCabin(parm)
	console.log("获取列表", res);
	cb && cb()
	const StatusMap = {
		Connected: "在线",
		Disconnected: "离线",
	};
	res.data.map(item => { 
		item.DeviceStatus = DeviceStatusMap[item.DeviceStatus];
	})
	List.value = [
		...List.value,
		...res.data
	]
	total.value = res.dataCount
	loadStatus.value = 'nomore';
}
</script>

scroll-view 场景下的分页加载(支持下拉刷新、滚动触底加载、空列表、加载状态等)

  • 自动分页加载数据(onReachBottom
  • 支持下拉刷新(onPullDownRefresh
  • 提供 loading / finished / refreshing 状态
  • 提供 loadMore() / refresh() 方法

组合函数代码(useScrollViewList.js)

// composables/useScrollViewList.js
import { ref } from 'vue'

export function useScrollViewList(fetchFn, pageSize = 10) {
  const list = ref([])
  const page = ref(1)
  const loading = ref(false)
  const finished = ref(false)
  const refreshing = ref(false)

  // 加载更多数据
  const loadMore = async () => {
    if (loading.value || finished.value) return
    loading.value = true
    try {
      const res = await fetchFn(page.value, pageSize)
      if (res.length < pageSize) {
        finished.value = true
      }
      list.value = list.value.concat(res)
      page.value++
    } catch (err) {
      console.error('加载失败:', err)
    } finally {
      loading.value = false
    }
  }

  // 下拉刷新
  const refresh = async () => {
    if (refreshing.value) return
    refreshing.value = true
    page.value = 1
    finished.value = false
    try {
      const res = await fetchFn(page.value, pageSize)
      list.value = res
      page.value++
    } catch (err) {
      console.error('刷新失败:', err)
    } finally {
      refreshing.value = false
      uni.stopPullDownRefresh()
    }
  }

  return {
    list,
    loading,
    finished,
    refreshing,
    loadMore,
    refresh
  }
}

scroll-view页面使用

<template>
	<scroll-view scroll-y style="height: 80vh;background-color: red;" @scrolltolower="loadMore" refresher-enabled
		@refresherrefresh="refresh" :refresher-triggered="refreshing">
		<view v-for="item in list" :key="item.id" class="item">
			{{ item.title }}
		</view>
		<view class="loading-text" v-if="loading">加载中...</view>
		<view class="finished-text" v-if="finished">没有更多了</view>
		<view class="empty-text" v-if="!list.length && !loading">暂无数据</view>
	</scroll-view>
</template>



<script setup>
	import {
		onPullDownRefresh,
		onReachBottom
	} from '@dcloudio/uni-app'
	import {
		useScrollViewList
	} from '@composables/useScrollViewList.js' // 注意路径根据你的项目调整

	// 模拟请求函数(可替换为真实接口)
	const fetchList = async (page, pageSize) => {
		console.log(`加载第 ${page} 页`)
		await new Promise(resolve => setTimeout(resolve, 500)) // 模拟网络延迟

		const total = 35 // 总数据量
		const start = (page - 1) * pageSize
		const end = Math.min(start + pageSize, total)

		return Array.from({
			length: end - start
		}, (_, i) => ({
			id: start + i + 1,
			title: `Item ${start + i + 1}`
		}))
	}

	const {
		list,
		loading,
		finished,
		refreshing,
		loadMore,
		refresh
	} = useScrollViewList(fetchList, 20)
	 refresh()
	
	// 监听页面下拉刷新
	onPullDownRefresh(refresh)

	// 监听页面滚动到底部(如果用 scroll-view,请用 scrolltolower)
	onReachBottom(loadMore)
</script>

<style>
	.item {
		padding: 20rpx;
		border-bottom: 1px solid #eee;
	}

	.loading-text,
	.finished-text,
	.empty-text {
		text-align: center;
		padding: 30rpx;
		color: #999;
	}
</style>

小程序登录

  1. 前端调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
  2. 后端调用 auth.code2Session 接口,换取 用户唯一标识 OpenID 、 用户在微信开放平台账号下的唯一标识UnionID(若当前小程序已绑定到微信开放平台账号) 和 会话密钥 session_key

前端页面

<template>
	<view class="container">
		<button @click="login">登录</button>
	</view>
</template>

<script setup>
	function login() {
		// 获取临时登录凭证 code
		uni.login({
			provider: 'weixin',
			success: (res) => {
				const code = res.code;
				console.log('wx.login code:', res);

				// 发送 code 到后端换取 openid/session_key/token
				uni.request({
					url: 'http://localhost:3000/api/login',
					method: 'POST',
					data: {
						code
					},
					success: (res) => {
						console.log('登录成功:', res.data);
						uni.setStorageSync('token', res.data.token); // 缓存登录态
					},
					fail: () => {
						uni.showToast({
							title: '登录失败',
							icon: 'none'
						});
					}
				});
			},
			fail: () => {
				uni.showToast({
					title: '获取code失败',
					icon: 'none'
				});
			}
		});
	}
</script>

后端服务

const express = require('express');
const axios = require('axios');
const cors = require('cors');
app.post('/api/login', async (req, res) => {
  const { code } = req.body;

  if (!code) return res.status(400).json({ error: 'Missing code' });

  try {
    // 调用微信接口获取 openid 和 session_key
    const result = await axios.get(
      `https://api.weixin.qq.com/sns/jscode2session`,
      {
        params: {
          appid: '你的小程序appid'’,
          secret: '你的小程序secret',
          js_code: code, //前端获取的code
          grant_type: 'authorization_code' //授权类型,此处只需填写 authorization_code
        }
      }
    );

    const { openid, session_key } = result.data;


    if (!openid) {
      return res.status(400).json({ error: '获取openid失败', details: result.data });
    }

    // 模拟生成 token,可接入你自己的用户系统
    const token = Buffer.from(`${openid}.${Date.now()}`).toString('base64');

    res.json({ openid, token, phonenumber });
  } catch (err) {
    console.error('登录错误:', err);
    res.status(500).json({ error: '登录失败' });
  }
});

app.listen(3000, () => {
  console.log('Server running at http://localhost:3000');
});

推广分享

  • 小程序 AppID(已发布版本)
  • 小程序后台配置:需要开通「URL Scheme」能力(登录微信公众平台开通)
  • 获取小程序的 access_token

vue3

<template>
	<view class="container">
		<button @click="generate">获取分享连接</button>
	</view>
</template>

<script setup>
    const path = route.query.path || 'pages/index/index'
    const query = route.query.query || ''
    const generate =()=>{
         const res = await axios.get(`http://localhost:3001/generate-link`, {
              params: { path, query }
            })
         onsole.log(res.data.link);
    }
</script>

server

const express = require('express');
const axios = require('axios');
const cors = require('cors');

const app = express();
app.use(cors());

const APPID = '你的AppID';
const APPSECRET = '你的AppSecret';

let access_token = '';
let token_expires_at = 0;

async function getAccessToken() {
  const now = Date.now();
  if (access_token && now < token_expires_at) return access_token;

  const res = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
    params: {
      grant_type: 'client_credential',
      appid: APPID,
      secret: APPSECRET,
    }
  });

  access_token = res.data.access_token;
  token_expires_at = now + res.data.expires_in * 1000;
  return access_token;
}

// GET /generate-link?path=pages/index/index&query=orderId=123
app.get('/generate-link', async (req, res) => {
  const { path, query } = req.query;

  try {
    const token = await getAccessToken();
    const schemeRes = await axios.post(
      `https://api.weixin.qq.com/wxa/generatescheme?access_token=${token}`,
      {
        jump_wxa: {
          path: path || 'pages/index/index',
          query: query || '',
          env_version: 'release',
        },
        is_expire: true,
        expire_time: Math.floor(Date.now() / 1000) + 3600, // 有效 1 小时
      }
    );

    res.json({ link: schemeRes.data.openlink });
  } catch (err) {
    console.error(err.response?.data || err);
    res.status(500).json({ error: '生成链接失败' });
  }
});

app.listen(3001, () => {
  console.log('🚀 后端服务运行在 http://localhost:3001');
});

小程序分享功能

<template>
	<view>
		<!-- 图片预览 -->
		<image :src="imageUrl" class="full-img" mode="aspectFit" />

		<!-- 分享弹窗 -->
		<button class="share-trigger" @click="showShare = true">点击分享</button>

		<view class="share-popup" v-if="showShare">
			<view class="share-panel">
				<view class="share-title">分享到</view>
				<view class="share-icons">
					<button open-type="share" class="icon-item" v-for="item in platforms" :key="item.name" @click="shareTo(item)">
						<image :src="item.icon" class="icon" />
						<text>{{ item.name }}</text>
					</button>
				</view>

				<button class="cancel-btn" @click="showShare = false">取消</button>
			</view>
		</view>
	</view>
</template>

<script setup>
	import {
		ref
	} from 'vue'
	import {
		onLoad,
		onShareAppMessage,
		onShareTimeline
	} from '@dcloudio/uni-app'

	onShareAppMessage(() => {
		if (res.from === 'button') { // 来自页面内分享按钮
			console.log(111111,res.target)
		}
		return {
			title: '自定义分享标题',
			path: '/pages/test/test?id=123',
			imageUrl: '/static/logo.png'
		}
	})
	onShareTimeline(() => {
		if (res.from === 'button') { // 来自页面内分享按钮
			console.log(res.target)
		}
		return {
			title: '这是分享朋友圈标题',
		}
	})

	const imageUrl = ref('/static/logo.png') // 要预览/保存的图片
	const showShare = ref(false)

	const platforms = [{
			name: '微信好友',
			icon: '/static/logo.png',
			appId: 'weixin'
		},
		{
			name: '朋友圈',
			icon: '/static/logo.png',
			appId: 'wechatmoments'
		},
		{
			name: '保存图片',
			icon: '/static/logo.png',
			appId: 'save'
		}
	]

	const shareTo = (platform) => {
		switch (platform.appId) {
			case 'save':
				uni.showLoading({
					mask: true
				})
				uni.saveImageToPhotosAlbum({
					filePath: imageUrl.value,
					success: () => uni.showToast({
						title: '已保存',
						icon: 'none'
					}),
					complete: () => uni.hideLoading()
				})
				break

			default:
				// 提示用户点击右上角菜单分享
				uni.showToast({
					title: '请点击右上角菜单进行分享',
					icon: 'none',
					duration: 2000
				})
				break
		}
		showShare.value = false
	}
</script>

<style scoped>
	.full-img {
		width: 100%;
		height: 100vh;
		object-fit: contain;
	}

	.share-trigger {
		position: absolute;
		bottom: 100rpx;
		left: 50%;
		transform: translateX(-50%);
		font-size: 28rpx;
	}

	.share-popup {
		position: fixed;
		bottom: 0;
		width: 100%;
		background: #fff;
		z-index: 9999;
		border-top-left-radius: 32rpx;
		border-top-right-radius: 32rpx;
	}

	.share-panel {
		padding: 20rpx;
	}

	.share-title {
		text-align: center;
		font-size: 28rpx;
		margin-bottom: 20rpx;
	}

	.share-icons {
		display: flex;
		justify-content: space-around;
		margin-bottom: 30rpx;
	}

	.icon-item {
		display: flex;
		flex-direction: column;
		align-items: center;
		font-size: 24rpx;
	}

	.icon {
		width: 60rpx;
		height: 60rpx;
		margin-bottom: 10rpx;
	}

	.cancel-btn {
		text-align: center;
		font-size: 28rpx;
		color: #888;
	}
</style>

点击复制 uni.setClipboardData()

uni.setClipboardData({
	  data: '13273*****',// 复制的内容
	` success: function () {
		console.log('success');
	  }
});

小程序中想跳转到其他小程序

uni.navigateToMiniProgram({
  appId: '目标小程序AppID',
  path: 'pages/index/index?id=123',
  extraData: {
    foo: 'bar'
  },
  envVersion: 'release',
  success(res) {
    console.log('跳转成功')
  }
})

数据缓存(uni.setStorage(OBJECT) @setstorage | uni-app官网)

  • 微信小程序限制缓存总容量:10MB
  • 同步适用于初始化配置或立即使用的场景
  • 异步适用于大数据存取或非关键性内容
API 名称类型是否同步说明
uni.setStorage异步存储数据到本地
uni.setStorageSync同步同步存储数据
uni.getStorage异步获取本地缓存数据
uni.getStorageSync同步同步获取数据
uni.removeStorage异步删除本地缓存中的指定 key
uni.removeStorageSync同步同步删除
uni.clearStorage异步清空所有本地缓存
uni.clearStorageSync同步同步清空
uni.getStorageInfo异步查看当前 storage 信息(keys/大小)
场景推荐方式存储时长
--------------------------------------
登录 token / 会话信息setStorageSync永久直到清除
临时缓存(页面之间传参)setStorage永久直到清除
用户偏好(主题、语言)setStorageSync永久直到清除
页面状态(如下拉刷新时间)setStorage可设置定时清除
//设置缓存
// 异步
uni.setStorage({
  key: 'token',
  data: 'abc123',
  success() {
    console.log('保存成功')
  }
})

// 同步
uni.setStorageSync('token', 'abc123')

// 获取缓存
// 异步
uni.getStorage({
  key: 'token',
  success: function (res) {
    console.log('缓存值为:', res.data)
  }
})

// 同步
const token = uni.getStorageSync('token')


//删除缓存
// 异步
uni.removeStorage({
  key: 'token',
  success() {
    console.log('删除成功')
  }
})

// 同步
uni.removeStorageSync('token')

//清空所有缓存
// 异步
uni.clearStorage()

// 同步
uni.clearStorageSync()

使用pinia

main.js 配置

import { createSSRApp } from "vue"
import App from "./App.vue"
import { createPinia } from "pinia"

export function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()
  app.use(pinia)
  return { app }
}

创建 Store 文件夹

示例:用户 Store(store/user.js)

// store/user.js
import { defineStore } from 'pinia'

// defineStore(唯一id, 配置对象)
export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),

  getters: {
    isLogin: (state) => !!state.token
  },

  actions: {
    setToken(token) {
      this.token = token
    },
    setUserInfo(info) {
      this.userInfo = info
    },
    logout() {
      this.token = ''
      this.userInfo = null
    }
  }
})

基础使用

// page/index/index
<script setup>
import { useUserStore } from '@/store/user'

const userStore = useUserStore()

// 读取
console.log(userStore.token)

// 修改(推荐)
userStore.setToken('123456')

// 直接赋值(也支持)
userStore.token = 'abc'

// 使用 getter
console.log(userStore.isLogin)  // true/false
</script>

状态响应式

Pinia 的 state 本身就是响应式的,但如果需要在 template 里解构,建议用 storeToRefs

<script setup>
import { useUserStore } from '@/store/user'
import { storeToRefs } from 'pinia'

const userStore = useUserStore()

// 解构为 ref
const { token, userInfo } = storeToRefs(userStore)
</script>

<template>
  <view>Token: {{ token }}</view>
  <view v-if="userInfo">用户: {{ userInfo.name }}</view>
</template>

封装缓存工具

📁 utils/storage.js

export const storage = {
  set(key, value, ttl = 3600) {
    const expireTime = Date.now() + ttl * 1000
    uni.setStorageSync(key, { value, expireTime })
  }
  get(key) {
    const data = uni.getStorageSync(key)
    if (!data) return null
    if (Date.now() > data.expireTime) {
      uni.removeStorageSync(key)
      return null
    }
    return data.value
  }
  remove(key) {
    uni.removeStorageSync(key)
  },
  clear() {
    uni.clearStorageSync()
  },
  has(key) {
    return uni.getStorageInfoSync().keys.includes(key)
  }
}

使用示例

<script setup>
    import { storage } from '@/utils/storage'

    storage.set('token', 'abc123')
    const token = storage.get('token')
    storage.remove('token')
</script>

位置(uni.getLocation(OBJECT) | uni-app官网

功能API 方法支持端
获取当前位置(经纬度)uni.getLocation✅ 微信小程序 / App / H5
监听位置变化(持续更新)uni.onLocationChange✅ 小程序 / App
停止监听位置变化uni.offLocationChange✅ 小程序 / App
调起原生地图导航uni.openLocation✅ 小程序 / App / H5
选择地图上的位置(手动)uni.chooseLocation✅ 小程序 / App
设置是否自动监听位置变化uni.startLocationUpdate✅ 小程序(需高权限)
问题解决方法
iOS 定位失败检查是否配置了 NSLocationWhenInUseUsageDescription
H5 定位失败必须在 HTTPS 环境,且需用户手动授权
App 不弹出权限框确保 manifest.json 中配置了定位权限
持续监听不触发(小程序)需调用 startLocationUpdateBackground + 配置后台定位权限
// 获取当前位置(一次性)
uni.getLocation({
  type: 'wgs84', // wgs84 返回 GPS 坐标;gcj02 返回可用于 openLocation 的坐标
  success(res) {
    console.log('当前位置:', res.latitude, res.longitude)
  },
  fail(err) {
    console.error('定位失败:', err)
  }
})

// 监听位置变化(持续定位)
// 开始监听(小程序需授权后台定位)
uni.startLocationUpdate({
  success: () => {
    uni.onLocationChange((res) => {
      console.log('实时位置:', res.latitude, res.longitude)
    })
  },
  fail: err => {
    console.error('无法启动持续定位:', err)
  }
})

// 停止监听
uni.offLocationChange()


//打开地图查看某位置(导航)
uni.openLocation({
  latitude: 22.543096,
  longitude: 114.057865,
  name: '腾讯大厦',
  address: '深圳市南山区深南大道10000号',
  scale: 18
})

//判断和请求定位权限(仅 App + 小程序)
// 微信小程序
uni.getSetting({
  success(res) {
    if (!res.authSetting['scope.userLocation']) {
      uni.authorize({
        scope: 'scope.userLocation',
        success() {
          console.log('用户授权成功')
        },
        fail() {
          uni.showModal({
            title: '提示',
            content: '需要授权定位权限,是否前往设置?',
            success: (res) => {
              if (res.confirm) {
                uni.openSetting()
              }
            }
          })
        }
      })
    }
  }
})

图片处理(uni-app官网

功能API 名称支持平台
选择图片uni.chooseImage全端 ✅
预览图片uni.previewImage全端 ✅
保存图片到相册uni.saveImageToPhotosAlbum全端 ✅
获取图片信息uni.getImageInfo全端 ✅
压缩图片uni.compressImage小程序 / App ✅
  • 使用 uni.chooseImage 选择图片
  • 使用 uni.previewImage 预览并长按分享、保存
  • 使用 uni.saveImageToPhotosAlbum 保存到相册
  • 使用 uni.shareuni.showShareMenu(微信小程序支持)分享
  • 使用 plugin://剪裁插件 可扩展裁剪功能(本例使用一个模拟裁剪方式跳转)
/index/index.vue
<template>
	<view class="feedback-page">
		<view v-for="(item,index) in urls" :key="index" class="image-item">
			<image :src="item" class="preview-img" mode="aspectFill" @click="preview(item)" />
			<view class="delete-btn" @click="remove(index)">×</view>
			<view class="action-row">
				<text @click="crop(item)">裁剪</text>
				<text @click="save(item)">保存</text>
				<text @click="share(item)">分享</text>
			</view>
		</view>
		<image :src="result" class="preview-img" mode="aspectFill" />
		<CropImage v-if="showCropper" :src="tempImg" @done="onCropDone" @cancel="showCropper = false" />
		<view class="upload-btn" @click="selectImage">+</view>
	</view>
</template>

<script setup>
	import {
		ref
	} from 'vue'
	import {
		onShow
	} from '@dcloudio/uni-app'
	const urls = ref([])

	// 选择图片
	const selectImage = () => {
		uni.chooseImage({
			count: 6,
			sizeType: ['original', 'compressed'], //original 原图,compressed 压缩图,默认二者都有
			sourceType: ['album', 'camera'], //album 从相册选图,camera 使用相机,默认二者都有。如需直接开相机或直接选相册,请只使用一个选项
			success: res => {
				urls.value = [...urls.value, ...res.tempFilePaths]
			}
		})
	}

	// 预览图片
	const preview = (currentUrl) => {
		uni.previewImage({
			current: currentUrl, //current 为当前显示图片的链接/索引值,不填或填写的值无效则为 urls 的第一张。
			urls: urls.value, //需要预览的图片链接列表
			longPressActions: { //长按图片显示操作菜单,如不填默认为保存相册
				itemList: ['发送给朋友', '保存图片', '收藏'], //按钮的文字数组
				success: data => {
					console.log(`选中了第${data.tapIndex + 1}个按钮`);
					if (data.tapIndex === 1) {
						save(currentUrl)
					}
				}
			}
		})
	}

	// 删除图片
	const remove = (index) => {
		urls.value.splice(index, 1)
	}

	// 保存图片
	const save = (url) => {
		uni.saveImageToPhotosAlbum({
			filePath: url,
			success: () => {
				uni.showToast({
					title: '保存成功',
					icon: 'success'
				})
			},
			fail: () => {
				uni.showToast({
					title: '保存失败',
					icon: 'none'
				})
			}
		})
	}


	// 裁剪图片
	const crop = (url) => {
		console.log(url);
		const image = encodeURIComponent(url)
		uni.navigateTo({
			url: `/pages/crop/crop?image=${image}&shape=circle` // 或 shape=rect
		})
	}



	// 分享图片
	const share = (url) => {
		wx.showShareImageMenu ?
			wx.showShareImageMenu({
				path: url, //要分享的图片地址,必须为本地路径或临时路径
			}) :
			uni.showToast({
				title: '当前平台不支持分享图片',
				icon: 'none'
			})
	}

	onShow(() => {
		const cropped = uni.getStorageSync('cropped-image')
		if (cropped) {
			this.result = cropped
			uni.removeStorageSync('cropped-image') // 只用一次就清除
		}
	})
</script>

<style scoped>
	.feedback-page {
		padding: 30rpx;
		background: #111;
		color: #fff;
		min-height: 100vh;
		display: flex;
		flex-wrap: wrap;
		gap: 20rpx;
	}

	.image-item {
		width: 200rpx;
		position: relative;
	}

	.preview-img {
		width: 200rpx;
		height: 200rpx;
		border-radius: 16rpx;
	}

	.delete-btn {
		position: absolute;
		top: -10rpx;
		right: -10rpx;
		background: red;
		color: #fff;
		font-size: 24rpx;
		width: 36rpx;
		height: 36rpx;
		text-align: center;
		line-height: 36rpx;
		border-radius: 50%;
		z-index: 10;
	}

	.upload-btn {
		width: 200rpx;
		height: 200rpx;
		background: #333;
		color: #fff;
		font-size: 60rpx;
		display: flex;
		justify-content: center;
		align-items: center;
		border-radius: 16rpx;
	}

	.action-row {
		display: flex;
		justify-content: space-between;
		font-size: 24rpx;
		color: #ccc;
		margin-top: 10rpx;
	}

	.action-row text {
		padding: 4rpx 8rpx;
		background: #222;
		border-radius: 8rpx;
	}
</style>
/crop/crop.vue
<template>
  <view class="crop-page">
    <canvas canvas-id="cropCanvas" style="width: 100%; height: 600rpx;" @touchstart="startDrag" @touchmove="onDrag" @touchend="endDrag" />
    <button @click="doCrop">裁剪并返回</button>
  </view>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'

const image = ref('')
let ctx, canvasW = 750, canvasH = 600
let startX = 0, startY = 0, dragging = false
let cropX = 100, cropY = 100, cropW = 300, cropH = 300

onLoad((options) => {
  image.value = decodeURIComponent(options.image || '')
})

onMounted(() => {
  const query = uni.createSelectorQuery()
  query.select('#cropCanvas').fields({ node: true, size: true }).exec(res => {
    const canvas = uni.createCanvasContext('cropCanvas')
    ctx = canvas
    draw()
  })
})

const draw = () => {
  ctx.clearRect(0, 0, canvasW, canvasH)
  ctx.drawImage(image.value, 0, 0, canvasW, canvasH)
  ctx.setStrokeStyle('red')
  ctx.strokeRect(cropX, cropY, cropW, cropH)
  ctx.draw()
}

const startDrag = (e) => {
  dragging = true
  const touch = e.touches[0]
  startX = touch.x
  startY = touch.y
}

const onDrag = (e) => {
  if (!dragging) return
  const touch = e.touches[0]
  let dx = touch.x - startX
  let dy = touch.y - startY
  cropX += dx
  cropY += dy
  startX = touch.x
  startY = touch.y
  draw()
}

const endDrag = () => {
  dragging = false
}

const doCrop = () => {
  uni.canvasToTempFilePath({
    canvasId: 'cropCanvas',
    x: cropX,
    y: cropY,
    width: cropW,
    height: cropH,
    success: (res) => {
      const croppedImg = res.tempFilePath
      uni.setStorageSync('cropped-image', croppedImg)
      uni.navigateBack()
    },
    fail: err => {
      uni.showToast({ title: '裁剪失败', icon: 'none' })
      console.error(err)
    }
  }, this)
}
</script>

<style scoped>
.crop-page {
  padding: 20rpx;
}
</style>

音频相关(uni.getRecorderManager() | uni-app官网)

功能API/组件支持平台
录音uni.startRecord(旧)全端 ✅
高级录音(推荐)RecorderManager小程序 ✅
播放音频uni.createInnerAudioContext()小程序 / App ✅
播放背景音乐BackgroundAudioManager小程序 ✅
上传音频uni.uploadFile全端 ✅
// 录音
<template>
	<view>
		<button @tap="startRecord">开始录音</button>
		<button @tap="endRecord">停止录音</button>
		<button @tap="playVoice">播放录音</button>
	</view>
</template>
<script setup>
	import {
		ref,
		computed
	} from 'vue'
	import {
		onLoad
	} from '@dcloudio/uni-app'
	const recorderManager = uni.getRecorderManager();
	const innerAudioContext = uni.createInnerAudioContext();

	innerAudioContext.autoplay = true;

	const voicePath = ref('')
	onLoad(() => {
		innerAudioContext.onPlay(() => {
		  console.log('开始播放');
		});
		recorderManager.onStop(function(res) {
			console.log('recorder stop' + JSON.stringify(res));
			self.voicePath = res.tempFilePath;
		})
	})

	const startRecord = () => {
		console.log('开始录音');

		recorderManager.start();
	}
	const endRecord = () => {
		console.log('录音结束');
		recorderManager.stop();
	}
	const playVoice = () => {
		console.log('播放录音');

		if (this.voicePath) {
			innerAudioContext.src = voicePath.value;
			innerAudioContext.play();
		}
	}

// 上传录音
const submit = async () => {
		uni.showLoading({
			title: '上传中...'
		})
		const formData = {
			Name: 'test1'
		}
		uni.uploadFile({
			url: 'http://localhost:3000/upload',
			filePath: videoPath.value,
			name: 'file',
			formData,
			success: (res) => {
				const data = JSON.parse(res.data)
				console.log(data);
				if (data.success) {
					uni.showToast({
						title: '提交成功',
						icon: 'success'
					})
					console.log(data);
				} else {
					uni.showToast({
						title: '提交失败',
						icon: 'none'
					})
				}
			},
			fail: (err) => {
				console.error('上传失败:', err)
				uni.showToast({
					title: '网络错误',
					icon: 'error'
				})
			},
			complete: () => {
				uni.hideLoading()
			}
		})
	}
</script>

视频相关(uni-app官网

功能API/组件支持平台
选择视频uni.chooseVideo全端 ✅
拍摄视频uni.chooseVideo全端 ✅
视频播放组件<video /> 组件全端 ✅
创建视频上下文uni.createVideoContext全端 ✅
导出合成视频(FFmpeg)后端需配合 Node.js + ffmpegApp/H5 ✅(非小程序)
<video id="myVideo" src="https://example.com/video.mp4" controls></video>

<script setup>
const videoCtx = uni.createVideoContext('myVideo')
videoCtx.play()
// 选择视频
uni.chooseVideo({
  sourceType: ['camera', 'album'],
  maxDuration: 60,
  success: (res) => {
    const path = res.tempFilePath
  }
})
</script>

设备(系统信息的概念 | uni-app官网

场景常用 API 示例
系统适配getSystemInfogetMenuButtonBoundingClientRect
安全校验getSystemInfoSync().platform、扫码、NFC
硬件交互方向、加速度、陀螺仪、蓝牙、NFC
媒体处理录音、音频播放、拍照、摄像
通讯 & 分享拨打电话、剪贴板、扫码、分享
位置信息getLocationchooseLocation
网络监控getNetworkTypeonNetworkStatusChange
权限控制(App)plus.android.requestPermissions
// utils/device.js
export const useDevice = () => {
  // 获取系统信息(同步)
  const getSystemInfo = () => {
    return uni.getSystemInfoSync()
  }

  // 获取电池信息
  const getBatteryInfo = () => {
    return new Promise((resolve, reject) => {
      uni.getBatteryInfo({
        success: resolve,
        fail: reject
      })
    })
  }

  // 获取屏幕亮度
  const getScreenBrightness = () => {
    return new Promise((resolve, reject) => {
      uni.getScreenBrightness({
        success: (res) => resolve(res.value),
        fail: reject
      })
    })
  }

  // 设置屏幕常亮
  const keepScreenOn = (on = true) => {
    uni.setKeepScreenOn({ keepScreenOn: on })
  }

  // 获取网络类型
  const getNetworkType = () => {
    return new Promise((resolve, reject) => {
      uni.getNetworkType({
        success: (res) => resolve(res.networkType),
        fail: reject
      })
    })
  }

  // 获取定位
  const getLocation = () => {
    return new Promise((resolve, reject) => {
      uni.getLocation({
        type: 'wgs84',
        success: resolve,
        fail: reject
      })
    })
  }

  // 扫码
  const scanCode = () => {
    return new Promise((resolve, reject) => {
      uni.scanCode({
        onlyFromCamera: true,
        success: resolve,
        fail: reject
      })
    })
  }

  // 设置剪贴板
  const setClipboard = (text) => {
    return new Promise((resolve, reject) => {
      uni.setClipboardData({
        data: text,
        success: resolve,
        fail: reject
      })
    })
  }

  // 读取剪贴板
  const getClipboard = () => {
    return new Promise((resolve, reject) => {
      uni.getClipboardData({
        success: (res) => resolve(res.data),
        fail: reject
      })
    })
  }

  // 拨打电话
  const callPhone = (phoneNumber) => {
    uni.makePhoneCall({ phoneNumber })
  }

  // 获取菜单按钮信息(用于自定义导航栏)
  const getMenuButtonInfo = () => {
    return uni.getMenuButtonBoundingClientRect()
  }

  // 获取指南针方向
  const watchCompass = (callback) => {
    uni.startCompass()
    uni.onCompassChange(callback)
  }

  const stopCompass = () => {
    uni.stopCompass()
  }

  // 监听加速度
  const watchAccelerometer = (callback) => {
    uni.startAccelerometer()
    uni.onAccelerometerChange(callback)
  }

  const stopAccelerometer = () => {
    uni.stopAccelerometer()
  }

  // 震动
  const vibrateShort = () => uni.vibrateShort()
  const vibrateLong = () => uni.vibrateLong()

  return {
    getSystemInfo, // 获取系统信息(同步)
    getBatteryInfo, // 获取电池信息(异步)
    getScreenBrightness, // 获取屏幕亮度(异步)
    keepScreenOn, // 设置屏幕常亮(同步)
    getNetworkType, // 获取网络类型(异步)
    getLocation, // 获取定位(异步)
    scanCode, // 扫码(异步)
    setClipboard, // 设置剪贴板(异步)
    getClipboard, // 读取剪贴板(异步)
    callPhone, // 拨打电话(同步)
    getMenuButtonInfo, // 获取菜单按钮信息(同步)
    watchCompass, // 监听指南针方向(异步)
    stopCompass, // 停止监听指南针方向(同步)
    watchAccelerometer, // 监听加速度(异步)
    stopAccelerometer, // 停止监听加速度(同步)
    vibrateShort, // 短震动(同步)
    vibrateLong, // 长震动(同步)
  }
}

键盘(uni.hideKeyboard() | uni-app官网

功能方法适用平台场景
显示键盘uni.showKeyboard()小程序、App主动唤起
隐藏键盘uni.hideKeyboard()所有平台主动收起
监听键盘高度uni.onKeyboardHeightChange()小程序、App避免遮挡
输入失焦@blur所有平台收起键盘或处理输入逻辑
// composables/useKeyboard.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useKeyboard() {
  const keyboardHeight = ref(0)       // 当前键盘高度
  const isKeyboardVisible = ref(false) // 键盘是否可见

  // 键盘高度监听(小程序 + App)
  const handleKeyboardHeightChange = (res) => {
    keyboardHeight.value = res.height
    isKeyboardVisible.value = res.height > 0
  }

  // 主动隐藏键盘(适用于所有平台)
  const hideKeyboard = () => {
    uni.hideKeyboard()
    keyboardHeight.value = 0
    isKeyboardVisible.value = false
  }

  // 手动设置键盘为隐藏(适用于 blur 场景)
  const handleBlur = () => {
    hideKeyboard()
  }

  // 初始化监听
  onMounted(() => {
    if (uni.onKeyboardHeightChange) {
      uni.onKeyboardHeightChange(handleKeyboardHeightChange)
    }
  })

  // 卸载监听
  onUnmounted(() => {
    if (uni.offKeyboardHeightChange) {
      uni.offKeyboardHeightChange(handleKeyboardHeightChange)
    }
  })

  return {
    keyboardHeight,
    isKeyboardVisible,
    hideKeyboard,
    handleBlur
  }
}

使用

<template>
  <view class="page">
    <scroll-view class="content" :style="{ paddingBottom: keyboardHeight + 'px' }">
      <!-- 页面内容 -->
    </scroll-view>

    <view class="input-bar" :style="{ marginBottom: keyboardHeight + 'px' }">
      <input placeholder="请输入内容" @blur="handleBlur" />
    </view>
  </view>
</template>

<script setup>
import { useKeyboard } from '@/composables/useKeyboard'

const {
  keyboardHeight,
  isKeyboardVisible,
  hideKeyboard,
  handleBlur
} = useKeyboard()
</script>

<style scoped>
.page {
  height: 100vh;
  display: flex;
  flex-direction: column;
}
.content {
  flex: 1;
}
.input-bar {
  padding: 10rpx 20rpx;
  background-color: #fff;
  border-top: 1px solid #eee;
}
</style>

提示类 API(uni.showToast(OBJECT) | uni-app官网

// 显示提示
uni.showToast({
  title: '操作成功',
  icon: 'success', // success / none / loading
  duration: 2000
})
//显示模态弹窗(Modal)
uni.showModal({
  title: '提示',
  content: '确定要删除吗?',
  success: (res) => {
    if (res.confirm) {
      console.log('用户点击确定')
    }
  }
})
//显示加载中(Loading)
uni.showLoading({
  title: '加载中...'
})

// 隐藏加载提示
uni.hideLoading()

文件(uni.saveFile(OBJECT) @savefile | uni-app官网

操作方法名支持平台
选择文件uni.chooseFileApp/H5 ✅
上传文件uni.uploadFile全平台 ✅
下载文件uni.downloadFile全平台 ✅
获取文件信息uni.getFileInfo全平台 ✅
新开页面打开文档uni.openDocument全平台 ✅
// 选择文件
uni.chooseFile({
  count: 1,
  success: (res) => {
    const file = res.tempFiles[0]
  }
})
// 生产文件
uni.uploadFile({
  url: 'https://example.com/upload',
  filePath: tempFilePath,
  name: 'file',
  formData: {
    user: 'test'
  },
  success: (uploadRes) => {
    console.log('上传成功', uploadRes)
  }
})
// 下载文件
uni.downloadFile({
  url: 'https://example.com/file.pdf',
  success: (res) => {
    if (res.statusCode === 200) {
      const filePath = res.tempFilePath
    }
  }
})


//获取文件信息
uni.getFileInfo({
  filePath: tempFilePath,
  success: (res) => {
    console.log(res.size, res.digest)
  }
})

// 新开页面打开文档
uni.downloadFile({
  url: 'https://example.com/somefile.pdf',
  success: function (res) {
    var filePath = res.tempFilePath;
    uni.openDocument({
      filePath: filePath,
      showMenu: true,
      success: function (res) {
        console.log('打开文档成功');
      }
    });
  }
});

画布(CanvasContext | uni-app官网

Canvas 的两种类型

类型描述使用方式
Canvas 2D普通画布,适用于大多数绘图场景<canvas> + createCanvasContext()
CanvasToTempFile将画布导出为临时文件(图片)canvasToTempFilePath()

常用 API 和功能说明

  1. uni.createCanvasContext(canvasId, componentInstance)创建绘图上下文对象,用于调用绘图命令。
canvasIdString画布标识,传入定义在 <canvas/> 的 canvas-id或id(支付宝小程序是id、其他平台是canvas-id)
componentInstanceObject自定义组件实例 this ,表示在这个自定义组件下查找拥有 canvas-id 的 <canvas/> ,如果省略,则不在任何自定义组件内查找
  1. uni.canvasToTempFilePath(object, componentInstance)把当前画布指定区域的内容导出生成指定大小的图片,并返回文件路径。在自定义组件下,第二个参数传入自定义组件实例,以操作组件内 <canvas> 组件。
<template>
	<view class="container">
		<canvas canvas-id="posterCanvas" class="canvas" style="width: 300px; height: 500px;"></canvas>
		<button @click="drawPoster">生成海报</button>
		<button @click="savePoster">保存到相册</button>
	</view>
</template>

<script setup>
import { ref, onMounted } from 'vue'

const canvasId = 'posterCanvas'
const posterPath = ref('')  // 最终生成的海报地址

// 图片路径(可以换成动态的)
const bgImage = '/static/bg.jpg'
const avatarImage = '/static/avatar.png'

// 绘制海报函数
const drawPoster = async () => {
	const ctx = uni.createCanvasContext(canvasId)

	// 背景图
	ctx.drawImage(bgImage, 0, 0, 300, 500)

	// 头像
	ctx.save()
	ctx.beginPath()
	ctx.arc(50, 50, 30, 0, Math.PI * 2)
	ctx.clip()
	ctx.drawImage(avatarImage, 20, 20, 60, 60)
	ctx.restore()

	// 标题文字
	ctx.setFillStyle('#333')
	ctx.setFontSize(18)
	ctx.fillText('欢迎来到海报世界', 90, 50)

	// 副标题
	ctx.setFontSize(14)
	ctx.setFillStyle('#666')
	ctx.fillText('这是你的专属邀请', 90, 80)

	ctx.draw(false, () => {
		// 导出图片
		setTimeout(() => {
			uni.canvasToTempFilePath({
				canvasId,
				success: res => {
					posterPath.value = res.tempFilePath
					uni.showToast({ title: '海报生成成功' })
				},
				fail: err => {
					console.error(err)
					uni.showToast({ title: '生成失败', icon: 'error' })
				}
			}, this)
		}, 300)
	})
}

// 保存海报
const savePoster = () => {
	if (!posterPath.value) {
		uni.showToast({ title: '请先生成海报', icon: 'none' })
		return
	}
	uni.saveImageToPhotosAlbum({
		filePath: posterPath.value,
		success: () => {
			uni.showToast({ title: '已保存到相册' })
		},
		fail: err => {
			console.error(err)
			uni.showToast({ title: '保存失败', icon: 'error' })
		}
	})
}
</script>

<style scoped>
.container {
	display: flex;
	flex-direction: column;
	align-items: center;
	justify-content: center;
	padding: 20rpx;
}
.canvas {
	border: 1px solid #eee;
	margin-bottom: 20rpx;
}
button {
	margin-top: 20rpx;
}
</style>

电子签名

<template>
	<view>
		<view class="wrapper">
			<view class="handBtn">
				<image @click="selectColorEvent('black','#1A1A1A')" :src="selectColor === 'black' ? '../static/other/color_black_selected.png' : '../static/other/color_black.png'"
				 :class="[selectColor === 'black' ? 'color_select' : '', 'black-select']"></image>
				<image @click="selectColorEvent('red','#ca262a')" :src="selectColor === 'red' ? '../static/other/color_red_selected.png' : '../static/other/color_red.png'"
				 :class="[selectColor === 'red' ? 'color_select' : '', 'black-select']"></image>
				<button @click="retDraw" class="delBtn">重写</button>
				<button @click="saveCanvasAsImg" class="saveBtn">保存</button>
				<button @click="previewCanvasImg" class="previewBtn">预览</button>
				<button @click="uploadCanvasImg" class="uploadBtn">上传</button>
				<button @click="subCanvas" class="subBtn">完成</button>
			</view>
			<view class="handCenter">
				<canvas class="handWriting" :disable-scroll="true" @touchstart="uploadScaleStart" @touchmove="uploadScaleMove"
				 @touchend="uploadScaleEnd" canvas-id="handWriting"></canvas>
			</view>
			<view class="handRight">
				<view class="handTitle">请签名</view>
			</view>
		</view>
	</view>
</template>

<script>
	export default {
		data() {
			return {
				canvasName: 'handWriting',
				ctx: '',
				canvasWidth: 0,
				canvasHeight: 0,
				transparent: 1, // 透明度
				selectColor: 'black',
				lineColor: '#1A1A1A', // 颜色
				lineSize: 1.5, // 笔记倍数
				lineMin: 0.5, // 最小笔画半径
				lineMax: 4, // 最大笔画半径
				pressure: 1, // 默认压力
				smoothness: 60, //顺滑度,用60的距离来计算速度
				currentPoint: {},
				currentLine: [], // 当前线条
				firstTouch: true, // 第一次触发
				radius: 1, //画圆的半径
				cutArea: {
					top: 0,
					right: 0,
					bottom: 0,
					left: 0
				}, //裁剪区域
				bethelPoint: [], //保存所有线条 生成的贝塞尔点;
				lastPoint: 0,
				chirography: [], //笔迹
				currentChirography: {}, //当前笔迹
				linePrack: [] //划线轨迹 , 生成线条的实际点
			};
		},
		onLoad() {
			let canvasName = this.canvasName;
			let ctx = wx.createCanvasContext(canvasName);

			this.ctx = ctx;
			var query = wx.createSelectorQuery();
			query
				.select('.handCenter')
				.boundingClientRect(rect => {
					this.canvasWidth = rect.width;
					this.canvasHeight = rect.height;

					/* 将canvas背景设置为 白底,不设置  导出的canvas的背景为透明 */
					this.setCanvasBg('#fff');
				})
				.exec();
		},
		methods: {
			// 笔迹开始
			uploadScaleStart(e) {
				if (e.type != 'touchstart') return false;
				let ctx = this.ctx;
				ctx.setFillStyle(this.lineColor); // 初始线条设置颜色
				ctx.setGlobalAlpha(this.transparent); // 设置半透明
				let currentPoint = {
					x: e.touches[0].x,
					y: e.touches[0].y
				};
				let currentLine = this.currentLine;
				currentLine.unshift({
					time: new Date().getTime(),
					dis: 0,
					x: currentPoint.x,
					y: currentPoint.y
				});
				this.currentPoint = currentPoint;
				// currentLine
				if (this.firstTouch) {
					this.cutArea = {
						top: currentPoint.y,
						right: currentPoint.x,
						bottom: currentPoint.y,
						left: currentPoint.x
					};
					this.firstTouch = false;
				}
				this.pointToLine(currentLine);
			},
			// 笔迹移动
			uploadScaleMove(e) {
				if (e.type != 'touchmove') return false;
				if (e.cancelable) {
					// 判断默认行为是否已经被禁用
					if (!e.defaultPrevented) {
						e.preventDefault();
					}
				}
				let point = {
					x: e.touches[0].x,
					y: e.touches[0].y
				};

				//测试裁剪
				if (point.y < this.cutArea.top) {
					this.cutArea.top = point.y;
				}
				if (point.y < 0) this.cutArea.top = 0;

				if (point.x > this.cutArea.right) {
					this.cutArea.right = point.x;
				}
				if (this.canvasWidth - point.x <= 0) {
					this.cutArea.right = this.canvasWidth;
				}
				if (point.y > this.cutArea.bottom) {
					this.cutArea.bottom = point.y;
				}
				if (this.canvasHeight - point.y <= 0) {
					this.cutArea.bottom = this.canvasHeight;
				}
				if (point.x < this.cutArea.left) {
					this.cutArea.left = point.x;
				}
				if (point.x < 0) this.cutArea.left = 0;

				this.lastPoint = this.currentPoint;
				this.currentPoint = point;

				let currentLine = this.currentLine;
				currentLine.unshift({
					time: new Date().getTime(),
					dis: this.distance(this.currentPoint, this.lastPoint),
					x: point.x,
					y: point.y
				});

				this.pointToLine(currentLine);
			},
			// 笔迹结束
			uploadScaleEnd(e) {
				if (e.type != 'touchend') return 0;
				let point = {
					x: e.changedTouches[0].x,
					y: e.changedTouches[0].y
				};
				this.lastPoint = this.currentPoint;
				this.currentPoint = point;

				let currentLine = this.currentLine;
				currentLine.unshift({
					time: new Date().getTime(),
					dis: this.distance(this.currentPoint, this.lastPoint),
					x: point.x,
					y: point.y
				});

				if (currentLine.length > 2) {
					var info = (currentLine[0].time - currentLine[currentLine.length - 1].time) / currentLine.length;
					//$("#info").text(info.toFixed(2));
				}
				//一笔结束,保存笔迹的坐标点,清空,当前笔迹
				//增加判断是否在手写区域;
				this.pointToLine(currentLine);
				var currentChirography = {
					lineSize: this.lineSize,
					lineColor: this.lineColor
				};
				var chirography = this.chirography;
				chirography.unshift(currentChirography);
				this.chirography = chirography;

				var linePrack = this.linePrack;
				linePrack.unshift(this.currentLine);
				this.linePrack = linePrack;
				this.currentLine = [];
			},
			retDraw() {
				this.ctx.clearRect(0, 0, 700, 730);
				this.ctx.draw();

				//设置canvas背景
				this.setCanvasBg('#fff');
			},
			//画两点之间的线条;参数为:line,会绘制最近的开始的两个点;
			pointToLine(line) {
				this.calcBethelLine(line);
				return;
			},
			//计算插值的方式;
			calcBethelLine(line) {
				if (line.length <= 1) {
					line[0].r = this.radius;
					return;
				}
				let x0,
					x1,
					x2,
					y0,
					y1,
					y2,
					r0,
					r1,
					r2,
					len,
					lastRadius,
					dis = 0,
					time = 0,
					curveValue = 0.5;
				if (line.length <= 2) {
					x0 = line[1].x;
					y0 = line[1].y;
					x2 = line[1].x + (line[0].x - line[1].x) * curveValue;
					y2 = line[1].y + (line[0].y - line[1].y) * curveValue;
					//x2 = line[1].x;
					//y2 = line[1].y;
					x1 = x0 + (x2 - x0) * curveValue;
					y1 = y0 + (y2 - y0) * curveValue;
				} else {
					x0 = line[2].x + (line[1].x - line[2].x) * curveValue;
					y0 = line[2].y + (line[1].y - line[2].y) * curveValue;
					x1 = line[1].x;
					y1 = line[1].y;
					x2 = x1 + (line[0].x - x1) * curveValue;
					y2 = y1 + (line[0].y - y1) * curveValue;
				}
				//从计算公式看,三个点分别是(x0,y0),(x1,y1),(x2,y2) ;(x1,y1)这个是控制点,控制点不会落在曲线上;实际上,这个点还会手写获取的实际点,却落在曲线上
				len = this.distance({
					x: x2,
					y: y2
				}, {
					x: x0,
					y: y0
				});
				lastRadius = this.radius;
				for (let n = 0; n < line.length - 1; n++) {
					dis += line[n].dis;
					time += line[n].time - line[n + 1].time;
					if (dis > this.smoothness) break;
				}

				this.radius = Math.min((time / len) * this.pressure + this.lineMin, this.lineMax) * this.lineSize;
				line[0].r = this.radius;
				//计算笔迹半径;
				if (line.length <= 2) {
					r0 = (lastRadius + this.radius) / 2;
					r1 = r0;
					r2 = r1;
					//return;
				} else {
					r0 = (line[2].r + line[1].r) / 2;
					r1 = line[1].r;
					r2 = (line[1].r + line[0].r) / 2;
				}
				let n = 5;
				let point = [];
				for (let i = 0; i < n; i++) {
					let t = i / (n - 1);
					let x = (1 - t) * (1 - t) * x0 + 2 * t * (1 - t) * x1 + t * t * x2;
					let y = (1 - t) * (1 - t) * y0 + 2 * t * (1 - t) * y1 + t * t * y2;
					let r = lastRadius + ((this.radius - lastRadius) / n) * i;
					point.push({
						x: x,
						y: y,
						r: r
					});
					if (point.length == 3) {
						let a = this.ctaCalc(point[0].x, point[0].y, point[0].r, point[1].x, point[1].y, point[1].r, point[2].x, point[2]
							.y, point[2].r);
						a[0].color = this.lineColor;
						// let bethelPoint = this.bethelPoint;
						// bethelPoint = bethelPoint.push(a);
						this.bethelDraw(a, 1);
						point = [{
							x: x,
							y: y,
							r: r
						}];
					}
				}
				this.currentLine = line;
			},
			//求两点之间距离
			distance(a, b) {
				let x = b.x - a.x;
				let y = b.y - a.y;
				return Math.sqrt(x * x + y * y);
			},
			ctaCalc(x0, y0, r0, x1, y1, r1, x2, y2, r2) {
				let a = [],
					vx01,
					vy01,
					norm,
					n_x0,
					n_y0,
					vx21,
					vy21,
					n_x2,
					n_y2;
				vx01 = x1 - x0;
				vy01 = y1 - y0;
				norm = Math.sqrt(vx01 * vx01 + vy01 * vy01 + 0.0001) * 2;
				vx01 = (vx01 / norm) * r0;
				vy01 = (vy01 / norm) * r0;
				n_x0 = vy01;
				n_y0 = -vx01;
				vx21 = x1 - x2;
				vy21 = y1 - y2;
				norm = Math.sqrt(vx21 * vx21 + vy21 * vy21 + 0.0001) * 2;
				vx21 = (vx21 / norm) * r2;
				vy21 = (vy21 / norm) * r2;
				n_x2 = -vy21;
				n_y2 = vx21;
				a.push({
					mx: x0 + n_x0,
					my: y0 + n_y0,
					color: '#1A1A1A'
				});
				a.push({
					c1x: x1 + n_x0,
					c1y: y1 + n_y0,
					c2x: x1 + n_x2,
					c2y: y1 + n_y2,
					ex: x2 + n_x2,
					ey: y2 + n_y2
				});
				a.push({
					c1x: x2 + n_x2 - vx21,
					c1y: y2 + n_y2 - vy21,
					c2x: x2 - n_x2 - vx21,
					c2y: y2 - n_y2 - vy21,
					ex: x2 - n_x2,
					ey: y2 - n_y2
				});
				a.push({
					c1x: x1 - n_x2,
					c1y: y1 - n_y2,
					c2x: x1 - n_x0,
					c2y: y1 - n_y0,
					ex: x0 - n_x0,
					ey: y0 - n_y0
				});
				a.push({
					c1x: x0 - n_x0 - vx01,
					c1y: y0 - n_y0 - vy01,
					c2x: x0 + n_x0 - vx01,
					c2y: y0 + n_y0 - vy01,
					ex: x0 + n_x0,
					ey: y0 + n_y0
				});
				a[0].mx = a[0].mx.toFixed(1);
				a[0].mx = parseFloat(a[0].mx);
				a[0].my = a[0].my.toFixed(1);
				a[0].my = parseFloat(a[0].my);
				for (let i = 1; i < a.length; i++) {
					a[i].c1x = a[i].c1x.toFixed(1);
					a[i].c1x = parseFloat(a[i].c1x);
					a[i].c1y = a[i].c1y.toFixed(1);
					a[i].c1y = parseFloat(a[i].c1y);
					a[i].c2x = a[i].c2x.toFixed(1);
					a[i].c2x = parseFloat(a[i].c2x);
					a[i].c2y = a[i].c2y.toFixed(1);
					a[i].c2y = parseFloat(a[i].c2y);
					a[i].ex = a[i].ex.toFixed(1);
					a[i].ex = parseFloat(a[i].ex);
					a[i].ey = a[i].ey.toFixed(1);
					a[i].ey = parseFloat(a[i].ey);
				}
				return a;
			},
			bethelDraw(point, is_fill, color) {
				let ctx = this.ctx;
				ctx.beginPath();
				ctx.moveTo(point[0].mx, point[0].my);
				if (undefined != color) {
					ctx.setFillStyle(color);
					ctx.setStrokeStyle(color);
				} else {
					ctx.setFillStyle(point[0].color);
					ctx.setStrokeStyle(point[0].color);
				}
				for (let i = 1; i < point.length; i++) {
					ctx.bezierCurveTo(point[i].c1x, point[i].c1y, point[i].c2x, point[i].c2y, point[i].ex, point[i].ey);
				}
				ctx.stroke();
				if (undefined != is_fill) {
					ctx.fill(); //填充图形 ( 后绘制的图形会覆盖前面的图形, 绘制时注意先后顺序 )
				}
				ctx.draw(true);
			},
			selectColorEvent(str, color) {
				this.selectColor = str;
				this.lineColor = color;
			},
			//将Canvas内容转成 临时图片 --> cb 为回调函数 形参 tempImgPath 为 生成的图片临时路径
			canvasToImg(cb) {
				//这种写法移动端 出不来

				this.ctx.draw(true, () => {
					wx.canvasToTempFilePath({
						canvasId: 'handWriting',
						fileType: 'png',
						quality: 1, //图片质量
						success(res) {
							// console.log(res.tempFilePath, 'canvas生成图片地址');

							wx.showToast({
								title: '执行了吗?'
							});

							cb(res.tempFilePath);
						}
					});
				});
			},
			//完成
			subCanvas() {
				this.ctx.draw(true, () => {
					wx.canvasToTempFilePath({
						canvasId: 'handWriting',
						fileType: 'png',
						quality: 1, //图片质量
						success(res) {
							// console.log(res.tempFilePath, 'canvas生成图片地址');
							wx.showToast({
								title: '以保存'
							});
							//保存到系统相册
							wx.saveImageToPhotosAlbum({
								filePath: res.tempFilePath,
								success(res) {
									wx.showToast({
										title: '已成功保存到相册',
										duration: 2000
									});
								}
							});
						}
					});
				});
			},
			//保存到相册
			saveCanvasAsImg() {

				/*
				this.canvasToImg( tempImgPath=>{
					// console.log(tempImgPath, '临时路径');
					wx.saveImageToPhotosAlbum({
						filePath: tempImgPath,
						success(res) {
							wx.showToast({
								title: '已保存到相册',
								duration: 2000
							});
						}
					})
				} );
		*/

				wx.canvasToTempFilePath({
					canvasId: 'handWriting',
					fileType: 'png',
					quality: 1, //图片质量
					success(res) {
						// console.log(res.tempFilePath, 'canvas生成图片地址');
						wx.saveImageToPhotosAlbum({
							filePath: res.tempFilePath,
							success(res) {
								wx.showToast({
									title: '已保存到相册',
									duration: 2000
								});
							}
						});
					}
				});
			},
			//预览
			previewCanvasImg() {
				wx.canvasToTempFilePath({
					canvasId: 'handWriting',
					fileType: 'jpg',
					quality: 1, //图片质量
					success(res) {
						// console.log(res.tempFilePath, 'canvas生成图片地址');

						wx.previewImage({
							urls: [res.tempFilePath] //预览图片 数组
						});
					}
				});

				/*	//移动端出不来  ^~^!!
						this.canvasToImg( tempImgPath=>{
							wx.previewImage({
								urls: [tempImgPath], //预览图片 数组
							})
						} );
				*/
			},
			//上传
			uploadCanvasImg() {

				wx.canvasToTempFilePath({
					canvasId: 'handWriting',
					fileType: 'png',
					quality: 1, //图片质量
					success(res) {
						// console.log(res.tempFilePath, 'canvas生成图片地址');

						//上传
						wx.uploadFile({
							url: 'https://example.weixin.qq.com/upload', // 仅为示例,非真实的接口地址
							filePath: res.tempFilePath,
							name: 'file_signature',
							formData: {
								user: 'test'
							},
							success(res) {
								const data = res.data;
								// do something
							}
						});
					}
				});
			},
			//设置canvas背景色  不设置  导出的canvas的背景为透明
			//@params:字符串  color
			setCanvasBg(color) {

				/* 将canvas背景设置为 白底,不设置  导出的canvas的背景为透明 */
				//rect() 参数说明  矩形路径左上角的横坐标,左上角的纵坐标, 矩形路径的宽度, 矩形路径的高度
				//这里是 canvasHeight - 4 是因为下边盖住边框了,所以手动减了写
				this.ctx.rect(0, 0, this.canvasWidth, this.canvasHeight - 4);
				// ctx.setFillStyle('red')
				this.ctx.setFillStyle(color);
				this.ctx.fill(); //设置填充
				this.ctx.draw(); //开画
			}
		}
	};
</script>

<style>
	page {
		background: #fbfbfb;
		height: auto;
		overflow: hidden;
	}

	.wrapper {
		width: 100%;
		height: 95vh;
		margin: 30rpx 0;
		overflow: hidden;
		display: flex;
		align-content: center;
		flex-direction: row;
		justify-content: center;
		font-size: 28rpx;
	}

	.handWriting {
		background: #fff;
		width: 100%;
		height: 95vh;
	}

	.handRight {
		display: inline-flex;
		align-items: center;
	}

	.handCenter {
		border: 4rpx dashed #e9e9e9;
		flex: 5;
		overflow: hidden;
		box-sizing: border-box;
	}

	.handTitle {
		transform: rotate(90deg);
		flex: 1;
		color: #666;
	}

	.handBtn button {
		font-size: 28rpx;
	}

	.handBtn {
		height: 95vh;
		display: inline-flex;
		flex-direction: column;
		justify-content: space-between;
		align-content: space-between;
		flex: 1;
	}

	.delBtn {
		position: absolute;
		top: 250rpx;
		left: 0rpx;
		transform: rotate(90deg);
		color: #666;
	}

	.delBtn image {
		position: absolute;
		top: 13rpx;
		left: 25rpx;
	}

	.subBtn {
		position: absolute;
		bottom: 52rpx;
		left: -3rpx;
		display: inline-flex;
		transform: rotate(90deg);
		background: #008ef6;
		color: #fff;
		margin-bottom: 30rpx;
		text-align: center;
		justify-content: center;
	}

	/*Peach - 新增 - 保存*/

	.saveBtn {
		position: absolute;
		top: 375rpx;
		left: 0rpx;
		transform: rotate(90deg);
		color: #666;
	}

	.previewBtn {
		position: absolute;
		top: 500rpx;
		left: 0rpx;
		transform: rotate(90deg);
		color: #666;
	}

	.uploadBtn {
		position: absolute;
		top: 625rpx;
		left: 0rpx;
		transform: rotate(90deg);
		color: #666;
	}

	/*Peach - 新增 - 保存*/

	.black-select {
		width: 60rpx;
		height: 60rpx;
		position: absolute;
		top: 30rpx;
		left: 25rpx;
	}

	.black-select.color_select {
		width: 90rpx;
		height: 90rpx;
		top: 100rpx;
		left: 10rpx;
	}

	.red-select {
		width: 60rpx;
		height: 60rpx;
		position: absolute;
		top: 140rpx;
		left: 25rpx;
	}

	.red-select.color_select {
		width: 90rpx;
		height: 90rpx;
		top: 120rpx;
		left: 10rpx;
	}
</style>

图片加水印

<template>
	<view>
		<button @click="chooseImg">选择图片并加水印</button>
		<image v-if="WaterSrc" :src="WaterSrc" style="width:300px;height:300px;" />
		<canvas canvas-id="myCanvas" style="position:absolute;left:-9999px;top:-9999px;width:300px;height:300px;" />
	</view>
</template>

<script>
	export default {
		data() {
			return {
				WaterSrc: ''
			}
		},
		methods: {
			chooseImg() {
				new Promise((resolve, reject) => {
					uni.chooseImage({
						count: 1,
						success: (res) => resolve(res),
						fail: (err) => reject(err)
					})
				}).then(async (res) => {
					const filePath = res.tempFilePaths[0] // 这里就不会报错
					const watermarked = await this.drawWatermark(filePath, {
						text: '水印文字',
						img: '/static/780.jpg',
						pos: 'right-bottom'
					})
					this.WaterSrc = watermarked
				}).catch(err => {
					console.error('选择图片失败', err)
				})
			},

			drawWatermark(imgPath, {
				text = '',
				img = '',
				pos = 'right-bottom'
			}) {
				return new Promise((resolve, reject) => {
					const ctx = uni.createCanvasContext('myCanvas', this)

					// 绘制原图
					ctx.drawImage(imgPath, 0, 0, 300, 300)

					const padding = 10
					ctx.setFontSize(20)
					ctx.setFillStyle('rgba(255,255,255,0.8)')

					// 绘制文字
					const metrics = ctx.measureText(text)
					const textWidth = metrics.width
					const textHeight = 20
					const imgW = 40,
						imgH = 40

					let x = padding,
						y = padding
					switch (pos) {
						case 'left-top':
							x = padding
							y = padding + imgH + textHeight
							break
						case 'right-top':
							x = 300 - textWidth - padding
							y = padding + imgH + textHeight
							break
						case 'left-bottom':
							x = padding
							y = 300 - padding
							break
						case 'right-bottom':
							x = 300 - textWidth - padding
							y = 300 - padding
							break
					}

					const imgX = x + (textWidth - imgW) / 2
					const imgY = y - textHeight - imgH - 5

					if (img) {
						ctx.drawImage(img, imgX, imgY, imgW, imgH)
					}

					ctx.fillText(text, x, y)

					ctx.draw(false, () => {
						setTimeout(() => {
							uni.canvasToTempFilePath({
								canvasId: 'myCanvas',
								success: (res) => resolve(res.tempFilePath),
								fail: (err) => reject(err)
							}, this)
						}, 200)
					})
				})
			}
		}
	}
</script>

视频加水印