页面和路由
路由配置(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
- 在
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": "我的"
}
]
}
}
- 新建自定义 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>
- 在每个页面引入自定义 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中对应页面项添加
- 下拉刷新 “enablePullDownRefresh”: true 是必须配置项否则不会触发回调
- 上拉触底 “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>
小程序登录
- 前端调用 wx.login() 获取 临时登录凭证code ,并回传到开发者服务器。
- 后端调用 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.share或uni.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 + ffmpeg | App/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 示例 |
|---|---|
| 系统适配 | getSystemInfo、getMenuButtonBoundingClientRect |
| 安全校验 | getSystemInfoSync().platform、扫码、NFC |
| 硬件交互 | 方向、加速度、陀螺仪、蓝牙、NFC |
| 媒体处理 | 录音、音频播放、拍照、摄像 |
| 通讯 & 分享 | 拨打电话、剪贴板、扫码、分享 |
| 位置信息 | getLocation、chooseLocation |
| 网络监控 | getNetworkType、onNetworkStatusChange |
| 权限控制(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.chooseFile | App/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 和功能说明
uni.createCanvasContext(canvasId, componentInstance)创建绘图上下文对象,用于调用绘图命令。
| canvasId | String | 画布标识,传入定义在 <canvas/> 的 canvas-id或id(支付宝小程序是id、其他平台是canvas-id) |
|---|---|---|
| componentInstance | Object | 自定义组件实例 this ,表示在这个自定义组件下查找拥有 canvas-id 的 <canvas/> ,如果省略,则不在任何自定义组件内查找 |
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>
视频加水印