87d7b854 by 华明祺

feat(personal): 完善个人会员申请及支付流程

- 协议勾选区域固定在页面底部,优化用户体验
- 联系方式改为非必填,但填写时验证手机号格式
- 支付逻辑使用 await-to-js 重构,统一错误处理
- 支付流程添加 loading 状态,防止重复提交
- 支付成功后传递 orderId,跳转时获取并展示订单详情
- 支付成功页面优化:标签不换行、值支持自动换行
- 新增获取订单详情接口 /common/order/{orderId}
1 parent 7d54d38d
......@@ -195,11 +195,11 @@ export function regionsList(params) {
export function carUrl(data, type) {
return uni.uploadFile({
url: `${config.baseUrl_api}/person/info/getPersonInfoFromCert/${type}`,
header: {
'Authorization': uni.getStorageSync('token'),
'Content-Language': 'zh_CN',
'Accept-Language': 'zh-CN,zh',
},
// header: {
// 'Authorization': uni.getStorageSync('token'),
// 'Content-Language': 'zh_CN',
// 'Accept-Language': 'zh-CN,zh',
// },
name: 'pic',
filePath: data
}).then(res => {
......@@ -237,10 +237,10 @@ export function addPersonCommit(data) {
}
export function getZtxFeeConfig() {
return request({
url: '/system/config/getZtxFeeConfig',
method: 'get'
})
return request({
url: '/system/config/getZtxFeeConfig',
method: 'get'
})
}
// 图片上传
export function uploadImg(e) {
......@@ -747,10 +747,10 @@ export function personalCommit(id) {
})
}
export function getNewCountByRangeId(rangeId) {
return request({
url: `/person/paymentNew/getNewCountByRangeId/${rangeId}`,
method: 'get',
})
return request({
url: `/person/paymentNew/getNewCountByRangeId/${rangeId}`,
method: 'get',
})
}
export function delPayment(payIds) {
......@@ -774,11 +774,11 @@ export function delcertified(ids) {
}
export function editYear(data) {
return request({
url: `/person/paymentNew/editYear/${data.payId}?payId=${data.payId}&year=${data.year}`,
method: 'post',
params: data
})
return request({
url: `/person/paymentNew/editYear/${data.payId}?payId=${data.payId}&year=${data.year}`,
method: 'post',
params: data
})
}
export function editGroupYear(data) {
......@@ -1283,135 +1283,143 @@ export function checkPersonByPersonId(perId) {
}
// 获取团体会员优惠政策
export function canUseDiscount(params) {
return request({
url: `/system/certifiedNew/canUseDiscount`,
method: 'get',
params
})
return request({
url: `/system/certifiedNew/canUseDiscount`,
method: 'get',
params
})
}
// 获取团体会员一年缴费价格
export function getMyMemberCertUnitFee(params) {
return request({
url: `/system/certifiedNew/getMyMemberCertUnitFee`,
method: 'get',
params
})
return request({
url: `/system/certifiedNew/getMyMemberCertUnitFee`,
method: 'get',
params
})
}
export function checkBusinessLicense(data) {
return request({
url: `/member/info/checkBusinessLicense`,
method: 'post',
params: data
})
return request({
url: `/member/info/checkBusinessLicense`,
method: 'post',
params: data
})
}
// 生成团体订单renewYear
export function certifiedNew(params) {
return request({
url: `/system/certifiedNew/commit`,
method: 'post',
params
})
return request({
url: `/system/certifiedNew/commit`,
method: 'post',
params
})
}
// 模拟回调
export function callBack2(orderId) {
return request({
url: `/system/certifiedNew/callBack2/${orderId}`,
method: 'get',
})
return request({
url: `/system/certifiedNew/callBack2/${orderId}`,
method: 'get',
})
}
export function pcallBack2(orderId) {
return request({
url: `/person/paymentRangeNew/callBack2/${orderId}`,
method: 'get',
})
return request({
url: `/person/paymentRangeNew/callBack2/${orderId}`,
method: 'get',
})
}
// 优惠政策回显
export function getZtxDiscountPolicy(params) {
return request({
url: '/system/config/getZtxDiscountPolicy',
method: 'get',
params
})
return request({
url: '/system/config/getZtxDiscountPolicy',
method: 'get',
params
})
}
// 考官列表
export function listApi(params) {
return request({
url: `/member/examiner/list`,
method: 'get',
params
})
return request({
url: `/member/examiner/list`,
method: 'get',
params
})
}
// 考官列表
export function examinerDel(id) {
return request({
url: `/member/examiner/${id}`,
method: 'delete'
})
return request({
url: `/member/examiner/${id}`,
method: 'delete'
})
}
// 添加考官
export function otherAdd(memId, ids) {
return request({
url: `/member/examiner/otherAdd/${memId}/${ids}`,
method: 'post'
})
return request({
url: `/member/examiner/otherAdd/${memId}/${ids}`,
method: 'post'
})
}
export function commitExamPointApply(params) {
return request({
url: `/member/examPointApply/commit?selfSelect=${params.selfSelect}`,
method: 'post',
params
})
return request({
url: `/member/examPointApply/commit?selfSelect=${params.selfSelect}`,
method: 'post',
params
})
}
export function getMyStatus() {
return request({
url: `/member/examPointApply/getMyStatus`
})
return request({
url: `/member/examPointApply/getMyStatus`
})
}
// 个人会员缴费支付
export function goPay(id) {
return request({
url: `/person/paymentRangeNew/pay/${id}`,
method: 'post'
})
return request({
url: `/person/paymentRangeNew/pay/${id}`,
method: 'post'
})
}
// 缴费单列表学员
export function listAPI(params) {
return request({
url: `/person/paymentNew/list`,
method: 'get',
params
})
return request({
url: `/person/paymentNew/list`,
method: 'get',
params
})
}
// 删除学员
export function paymentNewDel(id) {
return request({
url: `/person/paymentNew/${id}`,
method: 'delete'
})
return request({
url: `/person/paymentNew/${id}`,
method: 'delete'
})
}
// 缴费单列表
export function memberInsertPersons(data) {
return request({
url: `/person/paymentNew/memberInsertPersons/${data.rangeId}/${data.year}/${data.idcCode}`,
method: 'post',
data
})
return request({
url: `/person/paymentNew/memberInsertPersons/${data.rangeId}/${data.year}/${data.idcCode}`,
method: 'post',
data
})
}
export function createMemberPayRange(data) {
return request({
url: `/person/paymentRangeNew/createMemberPayRange`,
method: 'post',
data
})
return request({
url: `/person/paymentRangeNew/createMemberPayRange`,
method: 'post',
data
})
}
// 获取订单详情
export function getOrderInfo(orderId) {
return request({
url: `/common/order/${orderId}`,
method: 'get'
})
}
\ No newline at end of file
......
{
"dependencies": {
"await-to-js": "^3.0.0",
"crypto-js": "^4.1.1",
"dayjs": "^1.11.6",
"lodash": "^4.17.21",
......
<template>
<view>
<uni-segmented-control class="whitebg" :current="current" :values="items" @clickItem="onClickItem"
styleType="text" activeColor="#C40F18"></uni-segmented-control>
<view class="hasfixedbottom">
<view>
<uni-forms ref="baseForm" :border="true" :modelValue="baseFormData" label-width="80">
<view class="nolineform">
<uni-forms-item label="姓名" required name="name" v-show="current === 0">
<uni-easyinput :styles="inputstyle" :clearable='false' :placeholderStyle="placeholderStyle"
v-model="baseFormData.name" placeholder="请输入姓名" />
</uni-forms-item>
<uni-forms-item label="证件类型" required name="idcType">
<uni-data-select v-model="baseFormData.idcType" style="width: 360rpx;"
@change="changeIdcType" :clear="false" :disabled="current === 0"
:localdata="idcTypeList"></uni-data-select>
</uni-forms-item>
<uni-forms-item label="证件照" required v-show="current === 1">
<view class="upCard">
<uni-file-picker v-model="cardObj" @delete="delimgFont" return-type="object" limit="1"
@select="upIdCardImgFront" :image-styles="imageStylesZJ">
<image v-if="!baseFormData.card" class="sfz"
:src="config.baseUrl_api+'/fs/static/login/sfz.png'">
</image>
</uni-file-picker>
</view>
</uni-forms-item>
<uni-forms-item label="姓名" required name="name" v-show="current === 1">
<text v-if="disabledName">{{baseFormData.name}}</text>
<uni-easyinput :styles="inputstyle" :clearable='false' :placeholderStyle="placeholderStyle"
v-model="baseFormData.name" v-else placeholder="请输入姓名" />
</uni-forms-item>
<uni-forms-item label="证件号码" required name="idcCode" v-show="current === 0">
<uni-easyinput :styles="inputstyle" :clearable='false' :placeholderStyle="placeholderStyle"
v-model="baseFormData.idcCode" @blur="giveBirthDay" placeholder="请输入证件号码" />
</uni-forms-item>
<uni-forms-item label="证件号码" required name="idcCode" v-show="current === 1">
<text>{{baseFormData.idcCode}}</text>
</uni-forms-item>
<uni-forms-item label="性别" required name="sex">
<text v-if="baseFormData.sex=='0'"></text>
<text v-else-if="baseFormData.sex=='1'"></text>
<!-- <uni-data-checkbox v-model="baseFormData.sex" @change="changeSex" :localdata="sexs" /> -->
</uni-forms-item>
<uni-forms-item label="出生日期" required name="birth">
{{baseFormData.birth?.slice(0,10)}}
<!-- <uni-datetime-picker type="date" placeholder="YYYY-MM-DD" :border='false'
:clear-icon="false" v-model="baseFormData.birth" /> -->
</uni-forms-item>
<uni-forms-item label="联系方式" name="phone">
<uni-easyinput :styles="inputstyle" :placeholderStyle="placeholderStyle"
v-model="baseFormData.phone" placeholder="请输入联系方式" />
</uni-forms-item>
<uni-forms-item label="所在地区">
<uni-data-picker class="fixUniFormItemStyle" v-model="baseFormData.cityId"
:localdata="regionsList" popup-title="请选择所在地区"></uni-data-picker>
</uni-forms-item>
<uni-forms-item label="详细地址"><uni-easyinput :styles="inputstyle"
:placeholderStyle="placeholderStyle" v-model="baseFormData.address"
placeholder="请输入详细地址" /></uni-forms-item>
<uni-forms-item label="头像" required>
<uni-file-picker v-model="photoArr" @delete="delPhoto" return-type="object" limit="1"
@select="upPhoto" :del-ico="false" :image-styles="imageStylesTx"></uni-file-picker>
<image mode="aspectFill" v-if="baseFormData.photo2" style="height:200rpx;width:200rpx;" :src="config.baseUrl_api + baseFormData.photo2"/>
</uni-forms-item>
</view>
</uni-forms>
</view>
<view class="agreeline">
<image @click="changeAgree(agree)" v-if="agree"
:src="config.baseUrl_api+'/fs/static/login/xz_dwn@2x.png'"></image>
<image @click="changeAgree(agree)" v-else :src="config.baseUrl_api+'/fs/static/login/xz@2x.png'">
</image>
<view>我已阅读<text @click="openpopup">《入会须知》</text></view>
</view>
</view>
<view class="fixedBottom"><button class="btn-red" @click="goSubmit">确 定</button></view>
<!-- 会员须知 -->
<uni-popup ref="popup" type="bottom" background-color="#fff" animation :disable-scroll="true" :mask-click="false">
<view class="tt">入会须知</view>
<view class="popBody">
_{{baseFormData.name}}_欢迎您申请成为中国跆拳道协会(以下简称中国跆协)会员,请确保本次申请是经过您本人或监护人授权同意后的自愿行为,请您务必仔细阅读本入会须知。
<br />
一、中国跆协会员分为个人会员和单位会员。
<br />
二、成为本协会会员条件:遵守中国跆协章程和协会各项规章制度及相关决议,按期交纳会费,积极支持和参与中国跆拳道事业发展的社会各届人士或地方跆拳道协会、俱乐部、培训机构等,均可自愿申请成为中国跆协会员。<br />
三、个人会员为在中国工作和生活的跆拳道爱好者,16 周岁以下应有监护人协助申请,会员须为中国公民。<br />
四、会员入会需向所在区域内中国跆协单位会员提出入会申请,并按程序报中国跆协批准,按规定交纳会费。<br />
五、会员享有《中国跆拳道协会会员管理办法》规定的会员权利。
<br />
六、会员应履行《中国跆拳道协会会员管理办法》规定的会员义务。
<br />
七、凡中国跆协会员,须按照《中国跆拳道协会会员会费标准(2021 版)》按时交纳年度会费。<br />
八、会员行为违反《中国跆拳道协会会员管理办法》中规定的,按照相关处罚规定进行处理。<br />
九、其它会员相关内容请查看《中国跆拳道协会章程》《中国跆拳道协会会员管理办法》。<br />
<button @click="closepopup" class="btn-red">我已阅读</button>
</view>
</uni-popup>
<uni-popup ref="infoConfirm" type="center" :disable-scroll="true" :mask-click="false">
<view class="tt">确认信息</view>
<view class="popBody">
<view>
</view>
<button @click="closepopup" class="btn-red">已确认</button>
</view>
</uni-popup>
</view>
</template>
<script setup>
import {
ref
} from 'vue'
import * as api from '@/common/api.js'
import {
onLoad
} from '@dcloudio/uni-app'
import config from '@/config.js'
import * as aes2 from '@/common/utils.js'
const current = ref(0)
const popup = ref(null)
const infoConfirm = ref(null)
const agree = ref(false)
const perId = ref()
const photoArr = ref({})
const regionsList = ref([])
const cardObj = ref({})
const disabledName = ref(true)
const baseFormData = ref({
photo: '',
sex: '',
idcType: '0',
perType: '1', // (1:个人会员;2:教练;3:考官;4:裁判;5:临时会员;)
})
const items = ref(['身份证添加', '证件照录入'])
const idcTypeList = ref([{
value: '0',
text: "身份证"
},
{
value: '1',
text: "来往大陆(内地)通行证"
},
{
value: '3',
text: "护照"
}, {
value: '4',
text: '户口本'
}, {
value: '5',
text: '香港身份证'
}
])
const sexs = ref([{
text: '女',
value: '1'
}, {
text: '男',
value: '0'
}])
const placeholderStyle = ref('text-align: right;font-size:30rpx')
const inputstyle = ref({
borderColor: '#fff',
fontSize: '30rpx'
})
const imageStylesTx = ref({
width: '210rpx',
height: '280rpx',
background: {
color: '#F4F6FA'
},
border: {
radius: '2px'
}
});
const imageStylesZJ = ref({
width: '500rpx',
height: '316rpx'
});
onLoad((option) => {
if (option.tab == '1') {
current.value = 1
baseFormData.value.sourceFlag = 1
baseFormData.value.idcType = option.idcType || 0
if (baseFormData.value.idcType == '3') {
disabledName.value = false
} else {
disabledName.value = true
}
}
// console.log(current.value,option.tab)
getRegionsList()
})
function getRegionsList() {
api.regionsList().then(res => {
regionsList.value = res.data
})
}
function onClickItem(e) {
if (current.value != e.currentIndex) {
current.value = e.currentIndex
}
cardObj.value = {}
photoArr.value = {}
if (current.value == 0) {
baseFormData.value = {
photo: '',
idcType: '0',
perType: '1'
}
} else {
baseFormData.value = {
photo: '',
idcType: '0',
perType: '1',
sourceFlag: 1
}
}
}
function changeAgree(item) {
agree.value = !item
}
//身份证识别
function upIdCardImgFront(e) {
let file = e.tempFiles[0]
if (!file) {
return
}
uni.showLoading({
title: '加载中'
});
baseFormData.value.card = e.tempFiles;
// console.log(e)
// const formData = new FormData()
// formData.append('pic', e.tempFiles[0].file)
api.carUrl(e.tempFilePaths[0], baseFormData.value.idcType).then(res => {
console.log(res)
if (res.data) {
baseFormData.value.sex = res.data.sex
baseFormData.value.birth = res.data.birth
baseFormData.value.idcCode = res.data.code
baseFormData.value.name = res.data.name
baseFormData.value.uuid = res.data.uuid
baseFormData.value.cityId = res.data.cityId
baseFormData.value.address = res.data.address
photoArr.value = {}
getExtractInfo({
idcCode: baseFormData.value.idcCode,
idcType: baseFormData.value.idcType,
perType: baseFormData.value.perType
})
} else {
uni.hideLoading()
uni.showModal({
content: res.msg,
success: function(modalRes) {
}
})
}
})
}
function upPhoto(e) {
const tempFilePaths = e.tempFilePaths;
const imgUrl = tempFilePaths[0]
if (!imgUrl) {
return
}
wx.cropImage({
src: imgUrl,
cropScale: '4:5',
success: function(resp) {
uni.showLoading({
title: '加载中'
});
api.uploadImgCorpPhoto(resp.tempFilePath).then(data => {
console.log(data)
baseFormData.value.photo = data.data.fang;
baseFormData.value.photo2 = data.data.yuan;
photoArr.value = {
url: config.baseUrl_api+baseFormData.value.photo,
name: '头像',
extname: 'jpg'
}
});
},
fail: function(err) {
photoArr.value = {}
}
})
}
function delimgFont(n) {
photoArr.value = {}
cardObj.value = {}
baseFormData.value = {
photo: '',
idcType: baseFormData.value.idcType,
perType: '1',
sourceFlag: 1
};
}
function delPhoto(n) {
photoArr.value = {};
baseFormData.value.photo = '';
baseFormData.value.photo2 = '';
}
function getExtractInfo(obj) {
photoArr.value = {}
// baseFormData.value = {
// photo: '',
// idcType: baseFormData.value.idcType,
// idcCode: baseFormData.value.idcCode,
// perType: '1'
// };
uni.showLoading({
title: '加载中'
})
api.extractInfoFromChinaIdCard(obj).then(res => {
if (res.data.perCode) {
// if(baseFormData.value.idcType != 3){
disabledName.value = true
// }
perId.value = res.data.perId
baseFormData.value.sex = res.data.sex
baseFormData.value.birth = res.data.birth
baseFormData.value.name = res.data.name
baseFormData.value.phone = res.data.phone
baseFormData.value.cityId = res.data.cityId
baseFormData.value.address = res.data.address
if (res.data.photo) {
console.log(res.data.photo)
if (res.data.photo.indexOf('http') == -1) {
baseFormData.value.photo = res.data.photo
let obj = {
url: config.baseUrl_api + res.data.photo,
name: '头像',
extname: 'jpg'
}
photoArr.value = obj
} else {
baseFormData.value.photo = res.data.photo
let obj = {
url: res.data.photo,
name: '头像',
extname: 'jpg'
}
photoArr.value = obj
}
}
// baseFormData.value.name = res.data.name
baseFormData.value.perId = res.data.perId
console.log(res.data.photo, baseFormData.value.photo)
uni.hideLoading()
} else {
uni.hideLoading()
// 新会员
if (res.data.sex) {
baseFormData.value.sex = res.data.sex
baseFormData.value.birth = res.data.birth
}
if (baseFormData.value.idcType != 3 && current.value == 1) {
disabledName.value = true
} else {
disabledName.value = false
}
return
}
})
}
function giveBirthDay() {
// 判断身份证正确性/赋值生日
if (baseFormData.value.idcType == 0) {
if (!(/(^\d{15}$)|(^\d{17}([0-9]|X)$)/.test(baseFormData.value.idcCode))) {
uni.showToast({
title: '请输入正确的身份证号码',
duration: 2000,
icon: 'none'
})
} else {
getExtractInfo({
idcCode: baseFormData.value.idcCode,
idcType: baseFormData.value.idcType,
perType: baseFormData.value.perType
})
}
}
// if (baseFormData.value.idcType == 1 || baseFormData.value.idcType == 3) {
// //转换为大写并判断位数12
// baseFormData.value.idcCode = baseFormData.value.idcCode.toUpperCase()
// // var regex = /^[a-zA-Z]/
// if (baseFormData.value.idcCode.length > 12) {
// uni.showToast({
// icon: 'none',
// title: '请输入正确的证件号',
// duration: 2000
// })
// return
// }
// }
}
function openpopup() {
popup.value.open()
}
function closepopup() {
agree.value = true
popup.value.close()
}
function changeIdcType(e) {
console.log(e)
// 切换证件照类型把当前页面数据清空
cardObj.value = {}
photoArr.value = {}
baseFormData.value = {
photo: '',
idcType: e,
perType: '1',
sourceFlag: 1
}
}
function goSubmit() {
if (!agree.value) {
uni.showToast({
icon: 'none',
title: '请阅知入会须知',
duration: 2000
});
return
}
// 验证必填项
if (!baseFormData.value.name) {
uni.showToast({
title: `请输入姓名`,
icon: 'none'
})
return
}
if (!baseFormData.value.idcCode) {
uni.showToast({
title: `请输入证件号码`,
icon: 'none'
})
return
}
console.log(baseFormData.value.photo)
if (baseFormData.value.photo == '' || baseFormData.value.photo == undefined || !baseFormData.value.photo) {
uni.showToast({
title: `请上传头像`,
icon: 'none'
})
return
}
//信息确认弹出
uni.showModal({
content: '请确认信息正确',
success: function(res) {
if (res.confirm) {
if(baseFormData.value.idcType=='4'){
baseFormData.value.idcType='0'
}
delete baseFormData.value.card
const time = new Date().valueOf() + ''
baseFormData.value.t = time + Math.floor(Math.random() * 10)
baseFormData.value.signT = aes2.AESEncrypt(baseFormData.value.idcType + time)
const baseFormDataJson = encodeURIComponent(JSON.stringify(baseFormData.value))
uni.navigateTo({
url: `/personal/goPay_per?baseFormData=${baseFormDataJson}`
})
// uni.showModal({
// content: '保存成功',
// title: '提示',
// confirmText:'去支付',
// cancelColor:'个人中心',
// success: function(res) {
// },
// fail:function(){
// uni.reLaunch({
// url:`/personal/home`
// })
// }
// })
// api.addPersonToMyDept(baseFormData.value).then(Response => {
// if (Response.data == 0) {
// let msg = '该成员,实名认证未通过,注册失败!'
// uni.showModal({
// content: msg,
// title: '提示',
// success: function() {}
// })
// return
// }
// if (Response.data * 1 < 0) {
// // 会员调入弹出
// uni.showModal({
// content: '该会员已存在其他道馆,如需添加,请发起会员调动',
// title: '提示',
// success: function() {}
// })
// return
// }
// // let msg = '保存成功'
// })
}
}
});
}
function getUserInfo() {
api.getInfo(perId.value).then(res => {
baseFormData.value = res.data
if (baseFormData.areaAssName) baseFormData.ancestorNameList = baseFormData.value.ancestorNameList.join(
',').replaceAll(',',
'/')
})
}
</script>
<style lang="scss">
/* 字段名左对齐 */
.uni-forms-item .uni-forms-item__label {
text-align: left !important;
justify-content: flex-start !important;
padding-left: 0 !important;
width: auto !important;
}
/* 内容右对齐 */
.uni-forms-item .uni-forms-item__content {
display: flex !important;
align-items: center !important;
justify-content: flex-end !important;
text-align: right !important;
flex-wrap: nowrap !important;
}
/* 输入框内容右对齐 */
.uni-forms-item .uni-easyinput .uni-easyinput__content-input,
.uni-forms-item .uni-easyinput input,
.uni-forms-item input,
.uni-forms-item .uni-data-select .uni-select__input-box,
.uni-forms-item .uni-data-picker .uni-data-picker__input-box {
text-align: right !important;
}
/* 文本内容右对齐 */
.uni-forms-item .uni-forms-item__content text,
.uni-forms-item .uni-forms-item__content > text {
display: inline-block !important;
white-space: nowrap !important;
}
</style>
<style lang="scss" scoped>
:deep(.uni-popup__mask) {
overflow: hidden !important;
position: fixed !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
:deep(.uni-popup) {
overflow: hidden !important;
}
:deep(.segmented-control) {
height: 100rpx;
}
:deep(.segmented-control__text) {
line-height: 2;
font-size: 30rpx;
}
.tt {
text-align: center;
font-size: 30rpx;
padding: 40rpx 0 0;
}
.popBody {
font-size: 28rpx;
line-height: 1.5;
height: 70vh;
overflow-y: auto;
font-family: 华文仿宋;
height: 80vh;
overflow: auto;
padding: 30rpx;
.btn-red {
margin: 50rpx 0 30rpx;
}
}
.agreeline {
padding: 20rpx 40rpx;
box-sizing: border-box;
display: flex;
font-size: 30rpx;
text {
color: #014A9F;
}
image {
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
}
.upCard {
position: relative;
width: 500rpx;
height: 316rpx;
.uni-file-picker {
position: absolute;
z-index: 1;
}
.sfz {
width: 476rpx;
position: absolute;
top: 0;
left: 0;
height: 293rpx;
}
}
.op0 {
opacity: 0;
}
:deep(.item-text-overflow) {
text-align: left;
}
:deep(.fixUniFormItemStyle .uni-data-picker__input-box) {
justify-content: flex-start !important;
text-align: left !important;
}
/* 让地区选择器的文本左对齐 */
:deep(.fixUniFormItemStyle .uni-data-picker__text) {
text-align: left !important;
}
<template>
<view>
<uni-segmented-control class="whitebg" :current="current" :values="items" @clickItem="onClickItem"
styleType="text" activeColor="#C40F18"></uni-segmented-control>
<view class="hasfixedbottom">
<view>
<uni-forms ref="baseForm" :border="true" :modelValue="baseFormData" label-width="80">
<view class="nolineform">
<uni-forms-item label="姓名" required name="name" v-show="current === 0">
<uni-easyinput :styles="inputstyle" :clearable='false' :placeholderStyle="placeholderStyle"
v-model="baseFormData.name" placeholder="请输入姓名" />
</uni-forms-item>
<uni-forms-item label="证件类型" required name="idcType">
<uni-data-select v-model="baseFormData.idcType" style="width: 360rpx;"
@change="changeIdcType" :clear="false" :disabled="current === 0"
:localdata="idcTypeList"></uni-data-select>
</uni-forms-item>
<uni-forms-item label="证件照" required v-show="current === 1">
<view class="upCard">
<uni-file-picker v-model="cardObj" @delete="delimgFont" return-type="object" limit="1"
@select="upIdCardImgFront" :image-styles="imageStylesZJ">
<image v-if="!baseFormData.card" class="sfz"
:src="config.baseUrl_api+'/fs/static/login/sfz.png'">
</image>
</uni-file-picker>
</view>
</uni-forms-item>
<uni-forms-item label="姓名" required name="name" v-show="current === 1">
<text v-if="disabledName">{{baseFormData.name}}</text>
<uni-easyinput :styles="inputstyle" :clearable='false' :placeholderStyle="placeholderStyle"
v-model="baseFormData.name" v-else placeholder="请输入姓名" />
</uni-forms-item>
<uni-forms-item label="证件号码" required name="idcCode" v-show="current === 0">
<uni-easyinput :styles="inputstyle" :clearable='false' :placeholderStyle="placeholderStyle"
v-model="baseFormData.idcCode" @blur="giveBirthDay" placeholder="请输入证件号码" />
</uni-forms-item>
<uni-forms-item label="证件号码" required name="idcCode" v-show="current === 1">
<text>{{baseFormData.idcCode}}</text>
</uni-forms-item>
<uni-forms-item label="性别" required name="sex">
<text v-if="baseFormData.sex=='0'"></text>
<text v-else-if="baseFormData.sex=='1'"></text>
<!-- <uni-data-checkbox v-model="baseFormData.sex" @change="changeSex" :localdata="sexs" /> -->
</uni-forms-item>
<uni-forms-item label="出生日期" required name="birth">
{{baseFormData.birth?.slice(0,10)}}
<!-- <uni-datetime-picker type="date" placeholder="YYYY-MM-DD" :border='false'
:clear-icon="false" v-model="baseFormData.birth" /> -->
</uni-forms-item>
<uni-forms-item label="联系方式" name="phone">
<uni-easyinput :styles="inputstyle" :placeholderStyle="placeholderStyle"
v-model="baseFormData.phone" placeholder="请输入联系方式" />
</uni-forms-item>
<!-- <uni-forms-item label="所在地区">
<uni-data-picker class="fixUniFormItemStyle" v-model="baseFormData.cityId"
:localdata="regionsList" popup-title="请选择所在地区"></uni-data-picker>
</uni-forms-item>
<uni-forms-item label="详细地址"><uni-easyinput :styles="inputstyle"
:placeholderStyle="placeholderStyle" v-model="baseFormData.address"
placeholder="请输入详细地址" /></uni-forms-item>
<uni-forms-item label="头像" required>
<uni-file-picker v-model="photoArr" @delete="delPhoto" return-type="object" limit="1"
@select="upPhoto" :del-ico="false" :image-styles="imageStylesTx"></uni-file-picker>
<image mode="aspectFill" v-if="baseFormData.photo2" style="height:200rpx;width:200rpx;" :src="config.baseUrl_api + baseFormData.photo2"/>
</uni-forms-item> -->
</view>
</uni-forms>
</view>
</view>
<view class="fixed-agreeline">
<view class="agreeline">
<image @click="changeAgree(agree)" v-if="agree"
:src="config.baseUrl_api+'/fs/static/login/xz_dwn@2x.png'"></image>
<image @click="changeAgree(agree)" v-else :src="config.baseUrl_api+'/fs/static/login/xz@2x.png'">
</image>
<view>我已阅读<text @click="openpopup">《入会须知》</text></view>
</view>
</view>
<view class="fixedBottom"><button class="btn-red" @click="goSubmit">确 定</button></view>
<!-- 会员须知 -->
<uni-popup ref="popup" type="bottom" background-color="#fff" animation :disable-scroll="true"
:mask-click="false">
<view class="tt">入会须知</view>
<view class="popBody">
_{{baseFormData.name}}_欢迎您申请成为中国跆拳道协会(以下简称中国跆协)会员,请确保本次申请是经过您本人或监护人授权同意后的自愿行为,请您务必仔细阅读本入会须知。
<br />
一、中国跆协会员分为个人会员和单位会员。
<br />
二、成为本协会会员条件:遵守中国跆协章程和协会各项规章制度及相关决议,按期交纳会费,积极支持和参与中国跆拳道事业发展的社会各届人士或地方跆拳道协会、俱乐部、培训机构等,均可自愿申请成为中国跆协会员。<br />
三、个人会员为在中国工作和生活的跆拳道爱好者,16 周岁以下应有监护人协助申请,会员须为中国公民。<br />
四、会员入会需向所在区域内中国跆协单位会员提出入会申请,并按程序报中国跆协批准,按规定交纳会费。<br />
五、会员享有《中国跆拳道协会会员管理办法》规定的会员权利。
<br />
六、会员应履行《中国跆拳道协会会员管理办法》规定的会员义务。
<br />
七、凡中国跆协会员,须按照《中国跆拳道协会会员会费标准(2021 版)》按时交纳年度会费。<br />
八、会员行为违反《中国跆拳道协会会员管理办法》中规定的,按照相关处罚规定进行处理。<br />
九、其它会员相关内容请查看《中国跆拳道协会章程》《中国跆拳道协会会员管理办法》。<br />
<button @click="closepopup" class="btn-red">我已阅读</button>
</view>
</uni-popup>
<uni-popup ref="infoConfirm" type="center" :disable-scroll="true" :mask-click="false">
<view class="tt">确认信息</view>
<view class="popBody">
<view>
</view>
<button @click="closepopup" class="btn-red">已确认</button>
</view>
</uni-popup>
</view>
</template>
<script setup>
import {
ref
} from 'vue'
import * as api from '@/common/api.js'
import {
onLoad
} from '@dcloudio/uni-app'
import config from '@/config.js'
import * as aes2 from '@/common/utils.js'
const current = ref(0)
const popup = ref(null)
const infoConfirm = ref(null)
const agree = ref(false)
const perId = ref()
const photoArr = ref({})
const regionsList = ref([])
const cardObj = ref({})
const disabledName = ref(true)
const baseFormData = ref({
photo: '',
sex: '',
idcType: '0',
perType: '1', // (1:个人会员;2:教练;3:考官;4:裁判;5:临时会员;)
})
const items = ref(['身份证添加', '证件照录入'])
const idcTypeList = ref([{
value: '0',
text: "身份证"
},
{
value: '1',
text: "来往大陆(内地)通行证"
},
// {
// value: '3',
// text: "护照"
// },
{
value: '4',
text: '户口本'
},
{
value: '5',
text: '香港身份证'
}
])
const sexs = ref([{
text: '女',
value: '1'
}, {
text: '男',
value: '0'
}])
const placeholderStyle = ref('text-align: right;font-size:30rpx')
const inputstyle = ref({
borderColor: '#fff',
fontSize: '30rpx'
})
const imageStylesTx = ref({
width: '210rpx',
height: '280rpx',
background: {
color: '#F4F6FA'
},
border: {
radius: '2px'
}
});
const imageStylesZJ = ref({
width: '500rpx',
height: '316rpx'
});
onLoad((option) => {
if (option.tab == '1') {
current.value = 1
baseFormData.value.sourceFlag = 1
baseFormData.value.idcType = option.idcType || 0
if (baseFormData.value.idcType == '3') {
disabledName.value = false
} else {
disabledName.value = true
}
}
// console.log(current.value,option.tab)
// getRegionsList()
})
function getRegionsList() {
api.regionsList().then(res => {
regionsList.value = res.data
})
}
function onClickItem(e) {
if (current.value != e.currentIndex) {
current.value = e.currentIndex
}
cardObj.value = {}
photoArr.value = {}
if (current.value == 0) {
baseFormData.value = {
photo: '',
idcType: '0',
perType: '1'
}
} else {
baseFormData.value = {
photo: '',
idcType: '0',
perType: '1',
sourceFlag: 1
}
}
}
function changeAgree(item) {
agree.value = !item
}
//身份证识别
function upIdCardImgFront(e) {
let file = e.tempFiles[0]
if (!file) {
return
}
uni.showLoading({
title: '加载中'
});
baseFormData.value.card = e.tempFiles;
// console.log(e)
// const formData = new FormData()
// formData.append('pic', e.tempFiles[0].file)
api.carUrl(e.tempFilePaths[0], baseFormData.value.idcType).then(res => {
uni.hideLoading()
if (res.data) {
baseFormData.value.sex = res.data.sex
baseFormData.value.birth = res.data.birth
baseFormData.value.idcCode = res.data.code
baseFormData.value.name = res.data.name
baseFormData.value.uuid = res.data.uuid
// baseFormData.value.cityId = res.data.cityId
// baseFormData.value.address = res.data.address
} else {
uni.showToast({
title: res.msg,
duration: 2000,
icon: 'none'
})
}
})
}
function upPhoto(e) {
const tempFilePaths = e.tempFilePaths;
const imgUrl = tempFilePaths[0]
if (!imgUrl) {
return
}
wx.cropImage({
src: imgUrl,
cropScale: '4:5',
success: function(resp) {
uni.showLoading({
title: '加载中'
});
api.uploadImgCorpPhoto(resp.tempFilePath).then(data => {
console.log(data)
baseFormData.value.photo = data.data.fang;
baseFormData.value.photo2 = data.data.yuan;
photoArr.value = {
url: config.baseUrl_api + baseFormData.value.photo,
name: '头像',
extname: 'jpg'
}
});
},
fail: function(err) {
photoArr.value = {}
}
})
}
function delimgFont(n) {
photoArr.value = {}
cardObj.value = {}
baseFormData.value = {
photo: '',
idcType: baseFormData.value.idcType,
perType: '1',
sourceFlag: 1
};
}
function delPhoto(n) {
photoArr.value = {};
baseFormData.value.photo = '';
baseFormData.value.photo2 = '';
}
function getExtractInfo(obj) {
photoArr.value = {}
// baseFormData.value = {
// photo: '',
// idcType: baseFormData.value.idcType,
// idcCode: baseFormData.value.idcCode,
// perType: '1'
// };
uni.showLoading({
title: '加载中'
})
api.extractInfoFromChinaIdCard(obj).then(res => {
if (res.data.perCode) {
// if(baseFormData.value.idcType != 3){
disabledName.value = true
// }
perId.value = res.data.perId
baseFormData.value.sex = res.data.sex
baseFormData.value.birth = res.data.birth
baseFormData.value.name = res.data.name
baseFormData.value.phone = res.data.phone
// baseFormData.value.cityId = res.data.cityId
// baseFormData.value.address = res.data.address
if (res.data.photo) {
console.log(res.data.photo)
if (res.data.photo.indexOf('http') == -1) {
baseFormData.value.photo = res.data.photo
let obj = {
url: config.baseUrl_api + res.data.photo,
name: '头像',
extname: 'jpg'
}
photoArr.value = obj
} else {
baseFormData.value.photo = res.data.photo
let obj = {
url: res.data.photo,
name: '头像',
extname: 'jpg'
}
photoArr.value = obj
}
}
// baseFormData.value.name = res.data.name
baseFormData.value.perId = res.data.perId
console.log(res.data.photo, baseFormData.value.photo)
uni.hideLoading()
} else {
uni.hideLoading()
// 新会员
if (res.data.sex) {
baseFormData.value.sex = res.data.sex
baseFormData.value.birth = res.data.birth
}
if (baseFormData.value.idcType != 3 && current.value == 1) {
disabledName.value = true
} else {
disabledName.value = false
}
return
}
})
}
function giveBirthDay() {
if (!baseFormData.value.idcCode) {
return
}
// 判断身份证正确性/赋值生日
if (baseFormData.value.idcType == 0) {
if (!(/(^\d{15}$)|(^\d{17}([0-9]|X)$)/.test(baseFormData.value.idcCode))) {
uni.showToast({
title: '请输入正确的身份证号码',
duration: 2000,
icon: 'none'
})
} else {
getExtractInfo({
idcCode: baseFormData.value.idcCode,
idcType: baseFormData.value.idcType,
perType: baseFormData.value.perType
})
}
}
// if (baseFormData.value.idcType == 1 || baseFormData.value.idcType == 3) {
// //转换为大写并判断位数12
// baseFormData.value.idcCode = baseFormData.value.idcCode.toUpperCase()
// // var regex = /^[a-zA-Z]/
// if (baseFormData.value.idcCode.length > 12) {
// uni.showToast({
// icon: 'none',
// title: '请输入正确的证件号',
// duration: 2000
// })
// return
// }
// }
}
function openpopup() {
popup.value.open()
}
function closepopup() {
agree.value = true
popup.value.close()
}
function changeIdcType(e) {
console.log(e)
// 切换证件照类型把当前页面数据清空
cardObj.value = {}
photoArr.value = {}
baseFormData.value = {
photo: '',
idcType: e,
perType: '1',
sourceFlag: 1
}
}
function goSubmit() {
if (!agree.value) {
uni.showToast({
icon: 'none',
title: '请阅知入会须知',
duration: 2000
});
return
}
// 验证必填项
if (!baseFormData.value.name) {
uni.showToast({
title: `请输入姓名`,
icon: 'none'
})
return
}
if (!baseFormData.value.idcCode) {
uni.showToast({
title: `请输入证件号码`,
icon: 'none'
})
return
}
if (baseFormData.value.phone) {
const phoneReg = /^1[3-9]\d{9}$/
if (!phoneReg.test(baseFormData.value.phone)) {
uni.showToast({
title: '请输入正确的联系方式',
icon: 'none'
})
return
}
}
// if (baseFormData.value.photo == '' || baseFormData.value.photo == undefined || !baseFormData.value.photo) {
// uni.showToast({
// title: `请上传头像`,
// icon: 'none'
// })
// return
// }
//信息确认弹出
uni.showModal({
content: '请确认信息正确',
success: function(res) {
if (res.confirm) {
if (baseFormData.value.idcType == '4') {
baseFormData.value.idcType = '0'
}
delete baseFormData.value.card
const time = new Date().valueOf() + ''
baseFormData.value.t = time + Math.floor(Math.random() * 10)
baseFormData.value.signT = aes2.AESEncrypt(baseFormData.value.idcType + time)
const baseFormDataJson = encodeURIComponent(JSON.stringify(baseFormData.value))
uni.navigateTo({
url: `/personal/goPay_per?baseFormData=${baseFormDataJson}`
})
// uni.showModal({
// content: '保存成功',
// title: '提示',
// confirmText:'去支付',
// cancelColor:'个人中心',
// success: function(res) {
// },
// fail:function(){
// uni.reLaunch({
// url:`/personal/home`
// })
// }
// })
// api.addPersonToMyDept(baseFormData.value).then(Response => {
// if (Response.data == 0) {
// let msg = '该成员,实名认证未通过,注册失败!'
// uni.showModal({
// content: msg,
// title: '提示',
// success: function() {}
// })
// return
// }
// if (Response.data * 1 < 0) {
// // 会员调入弹出
// uni.showModal({
// content: '该会员已存在其他道馆,如需添加,请发起会员调动',
// title: '提示',
// success: function() {}
// })
// return
// }
// // let msg = '保存成功'
// })
}
}
});
}
function getUserInfo() {
api.getInfo(perId.value).then(res => {
baseFormData.value = res.data
if (baseFormData.areaAssName) baseFormData.ancestorNameList = baseFormData.value.ancestorNameList.join(
',').replaceAll(',', '/')
})
}
</script>
<style lang="scss">
/* 字段名左对齐 */
.uni-forms-item .uni-forms-item__label {
text-align: left !important;
justify-content: flex-start !important;
padding-left: 0 !important;
width: auto !important;
}
/* 内容右对齐 */
.uni-forms-item .uni-forms-item__content {
display: flex !important;
align-items: center !important;
justify-content: flex-end !important;
text-align: right !important;
flex-wrap: nowrap !important;
}
/* 输入框内容右对齐 */
.uni-forms-item .uni-easyinput .uni-easyinput__content-input,
.uni-forms-item .uni-easyinput input,
.uni-forms-item input,
.uni-forms-item .uni-data-select .uni-select__input-box,
.uni-forms-item .uni-data-picker .uni-data-picker__input-box {
text-align: right !important;
}
/* 文本内容右对齐 */
.uni-forms-item .uni-forms-item__content text,
.uni-forms-item .uni-forms-item__content>text {
display: inline-block !important;
white-space: nowrap !important;
}
</style>
<style lang="scss" scoped>
:deep(.uni-popup__mask) {
overflow: hidden !important;
position: fixed !important;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
:deep(.uni-popup) {
overflow: hidden !important;
}
:deep(.segmented-control) {
height: 100rpx;
}
:deep(.segmented-control__text) {
line-height: 2;
font-size: 30rpx;
}
.tt {
text-align: center;
font-size: 30rpx;
padding: 40rpx 0 0;
}
.popBody {
font-size: 28rpx;
line-height: 1.5;
height: 70vh;
overflow-y: auto;
font-family: 华文仿宋;
height: 80vh;
overflow: auto;
padding: 30rpx;
.btn-red {
margin: 50rpx 0 30rpx;
}
}
.hasfixedbottom {
padding-bottom: 200rpx;
}
.fixed-agreeline {
position: fixed;
bottom: 150rpx;
left: 0;
right: 0;
z-index: 1;
}
.agreeline {
padding: 20rpx 40rpx;
box-sizing: border-box;
display: flex;
font-size: 30rpx;
text {
color: #014A9F;
}
image {
width: 40rpx;
height: 40rpx;
margin-right: 20rpx;
}
}
.upCard {
position: relative;
width: 500rpx;
height: 316rpx;
.uni-file-picker {
position: absolute;
z-index: 1;
}
.sfz {
width: 476rpx;
position: absolute;
top: 0;
left: 0;
height: 293rpx;
}
}
.op0 {
opacity: 0;
}
:deep(.item-text-overflow) {
text-align: left;
}
:deep(.fixUniFormItemStyle .uni-data-picker__input-box) {
justify-content: flex-start !important;
text-align: left !important;
}
/* 让地区选择器的文本左对齐 */
:deep(.fixUniFormItemStyle .uni-data-picker__text) {
text-align: left !important;
}
</style>
\ No newline at end of file
......
<template>
<view class="container">
<view class="content">
<view class="card">
<view class="yearRow">
<view class="label">缴费年限</view>
<view class="control">
<image class="icon" @click="minusYear" src="/static/dd_02.png" mode="widthFix" v-if="form.payYear > 1" ></image>
<image class="icon" src="/static/dd_02_g.png" mode="widthFix" v-else ></image>
<text class="num">{{ form.payYear }}</text>
<image class="icon" src="/static/btn_03.png" mode="widthFix" @click="plusYear" v-if="form.payYear < 5" ></image>
<image class="icon" src="/static/btn_03_g.png" mode="widthFix" v-else ></image>
</view>
</view>
</view>
<view class="card ">
<view class="row ">
<text class="label">费用合计</text>
<text class="value red">{{ form.payYear * memberFee }}</text>
</view>
</view>
<view class="payRow ">
<radio-group @change="onPayTypeChange">
<label class="radioItem">
<radio value="1" :checked="payType === '1'" class="custom-radio" />
<view class="payInfo">
<image class="icon" src="/static/min.png" mode="widthFix"></image>
<text>民生付</text>
</view>
</label>
</radio-group>
</view>
<view class="totalRow ">
<text class="label">支付费用合计</text>
<text class="value redBig">{{ memberTotalFee }}</text>
</view>
</view>
<view class="bottomBtn">
<button class="payBtn" @click="handelPay" :loading="isPaying">立即支付 ¥{{ memberTotalFee }}</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app';
import * as api from '@/common/api.js'
const form = ref({
payYear: 1
})
// 支付方式
const payType = ref('1')
const isPaying = ref(false)
// 费用与优惠
const memberFee = ref(0)
const memberTotalFee = computed(() => {
return memberFee.value * form.value.payYear
})
onLoad((options) => {
if (options.baseFormData) {
const data = JSON.parse(decodeURIComponent(options.baseFormData))
form.value = {
...data,
payYear: 1 // 年限默认1
}
}
// 初始化接口
getMyMemberCertUnitFeeApi()
})
// 减年限
const minusYear = () => {
if (form.value.payYear > 1) {
form.value.payYear--
}
}
// 加年限(最大 5 年)
const plusYear = () => {
if (form.value.payYear < 5) {
form.value.payYear++
}
}
// 支付方式切换
const onPayTypeChange = (e) => {
payType.value = e.detail.value
}
const handelPay = async () => {
if (memberTotalFee.value <= 0) {
uni.showToast({ title: '支付金额异常', icon: 'none' })
return
}
isPaying.value = true
try {
// 拼接完整参数
const postData = {
...form.value,
payYear: form.value.payYear,
payType: payType.value,
totalFee: memberTotalFee.value
}
const res = await api.insertSinglePay(postData)
console.log(777,res)
if (res.data?.orderId) {
api.pcallBack2(res.data.orderId)
uni.navigateTo({
url: `/personal/sucPay`
})
}
// if (data.payFlag == 0 || data.orderId) {
// data.orderId && api.callBack2(data.orderId)
// uni.navigateTo({ url: `/personal/submitPay?price=${res.data.price}` })
// }
} catch (err) {
uni.showToast({ title: '支付失败', icon: 'none' })
} finally {
isPaying.value = false
}
}
// 获取会员费
async function getMyMemberCertUnitFeeApi() {
const res = await api.getZtxFeeConfig()
memberFee.value = Number(res.data.personMemberFee || 1500)
}
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f7f7f7;
}
.content {
padding: 20rpx 20rpx 120rpx;
}
.card {
background: #fff;
border-radius: 8rpx;
padding: 25rpx 20rpx;
margin-bottom: 20rpx;
}
.yearRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.yearRow .label {
font-size: 28rpx;
color: #333;
}
.yearRow .control {
display: flex;
align-items: center;
}
.control image {
width: 50rpx;
height: 50rpx;
}
.yearRow .num {
font-size: 28rpx;
color: #333;
min-width: 80rpx;
text-align: center;
margin: 0 10rpx;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.row .label {
font-size: 28rpx;
color: #333;
}
.row .value {
font-size: 30rpx;
color: #C4121B;
font-weight: 500;
}
.hintRow {
display: flex;
align-items: flex-start;
font-size: 24rpx;
line-height: 1.4;
}
.hintRow .hintText {
color: #FF8124;
flex: 1;
margin-top: 10rpx;
}
.deductRow {
background: #fff;
padding: 20rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
border-radius: 8rpx;
}
.deductRow .label {
font-size: 28rpx;
color: #333;
}
.deductRow .value {
font-size: 30rpx;
color: #C4121B;
}
.payRow {
background: #fff;
border-radius: 8rpx;
padding: 20rpx 20rpx;
margin-bottom: 20rpx;
}
.radioItem {
display: flex;
align-items: center;
}
.payInfo {
display: flex;
align-items: center;
margin-left: 15rpx;
}
.payInfo .icon {
width: 40rpx;
height: 40rpx;
margin-right: 10rpx;
}
.payInfo text {
font-size: 28rpx;
color: #333;
}
.totalRow {
background: #fff;
border-radius: 8rpx;
padding: 20rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10rpx;
}
.totalRow .label {
font-size: 28rpx;
color: #333;
}
.redBig {
font-size: 32rpx;
color: #C4121B;
font-weight: bold;
}
.bottomBtn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 20rpx;
background: #fff;
border-top: 1rpx solid #eee;
}
.payBtn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background-color: #C4121B;
color: #fff;
border-radius: 8rpx;
font-size: 32rpx;
text-align: center;
border: none;
}
.payBtn[disabled] {
background-color: #ccc;
color: #999;
}
.red {
color: #C4121B;
}
.icon{
width:30px;
}
::v-deep .custom-radio .wx-radio-input {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
border: 2rpx solid #ccc;
}
::v-deep .custom-radio .wx-radio-input.wx-radio-input-checked {
border-color: #C4121B !important;
background: #C4121B !important;
}
<template>
<view class="container">
<view class="content">
<view class="card">
<view class="yearRow">
<view class="label">缴费年限</view>
<view class="control">
<image class="icon" @click="minusYear" src="/static/dd_02.png" mode="widthFix"
v-if="form.payYear > 1"></image>
<image class="icon" src="/static/dd_02_g.png" mode="widthFix" v-else></image>
<text class="num">{{ form.payYear }}</text>
<image class="icon" src="/static/btn_03.png" mode="widthFix" @click="plusYear"
v-if="form.payYear < 5"></image>
<image class="icon" src="/static/btn_03_g.png" mode="widthFix" v-else></image>
</view>
</view>
</view>
<view class="card ">
<view class="row ">
<text class="label">费用合计</text>
<text class="value red">{{ form.payYear * memberFee }}</text>
</view>
</view>
<view class="payRow ">
<radio-group @change="onPayTypeChange">
<label class="radioItem">
<radio value="1" :checked="payType === '1'" class="custom-radio" />
<view class="payInfo">
<image class="icon" src="/static/min.png" mode="widthFix"></image>
<text>民生付</text>
</view>
</label>
</radio-group>
</view>
<view class="totalRow ">
<text class="label">支付费用合计</text>
<text class="value redBig">{{ memberTotalFee }}</text>
</view>
</view>
<view class="bottomBtn">
<button class="payBtn" @click="handelPay" :loading="isPaying">立即支付 ¥{{ memberTotalFee }}</button>
</view>
</view>
</template>
<script setup>
import {
ref,
computed,
onMounted
} from 'vue'
import {
onLoad
} from '@dcloudio/uni-app';
import to from 'await-to-js'
import * as api from '@/common/api.js'
const form = ref({
payYear: 1
})
// 支付方式
const payType = ref('1')
const isPaying = ref(false)
// 费用与优惠
const memberFee = ref(0)
const memberTotalFee = computed(() => {
return memberFee.value * form.value.payYear
})
onLoad((options) => {
if (options.baseFormData) {
const data = JSON.parse(decodeURIComponent(options.baseFormData))
form.value = {
...data,
payYear: 1 // 年限默认1
}
}
// 初始化接口
getMyMemberCertUnitFeeApi()
})
// 减年限
const minusYear = () => {
if (form.value.payYear > 1) {
form.value.payYear--
}
}
// 加年限(最大 5 年)
const plusYear = () => {
if (form.value.payYear < 5) {
form.value.payYear++
}
}
// 支付方式切换
const onPayTypeChange = (e) => {
payType.value = e.detail.value
}
const handelPay = async () => {
if (memberTotalFee.value <= 0) {
uni.showToast({
title: '支付金额异常',
icon: 'none'
})
return
}
// 显示 loading
uni.showLoading({
title: '支付中...',
mask: true
})
isPaying.value = true
// 拼接完整参数
const postData = {
...form.value,
payYear: form.value.payYear,
payType: payType.value,
totalFee: memberTotalFee.value
}
// 创建订单
const [orderErr, orderRes] = await to(api.insertSinglePay(postData))
if (orderErr) {
uni.hideLoading()
isPaying.value = false
uni.showToast({
title: '创建订单失败',
icon: 'none'
})
return
}
if (!orderRes.data?.orderId) {
uni.hideLoading()
isPaying.value = false
uni.showToast({
title: '订单创建异常',
icon: 'none'
})
return
}
// 等待支付回调
await to(api.pcallBack2(orderRes.data.orderId))
uni.hideLoading()
isPaying.value = false
// 支付成功,跳转页面
uni.navigateTo({
url: `/personal/sucPay?orderId=${orderRes.data.orderId}`
})
}
// 获取会员费
async function getMyMemberCertUnitFeeApi() {
const res = await api.getZtxFeeConfig()
memberFee.value = Number(res.data.personMemberFee || 1500)
}
</script>
<style scoped>
.container {
min-height: 100vh;
background-color: #f7f7f7;
}
.content {
padding: 20rpx 20rpx 120rpx;
}
.card {
background: #fff;
border-radius: 8rpx;
padding: 25rpx 20rpx;
margin-bottom: 20rpx;
}
.yearRow {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.yearRow .label {
font-size: 28rpx;
color: #333;
}
.yearRow .control {
display: flex;
align-items: center;
}
.control image {
width: 50rpx;
height: 50rpx;
}
.yearRow .num {
font-size: 28rpx;
color: #333;
min-width: 80rpx;
text-align: center;
margin: 0 10rpx;
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
}
.row .label {
font-size: 28rpx;
color: #333;
}
.row .value {
font-size: 30rpx;
color: #C4121B;
font-weight: 500;
}
.hintRow {
display: flex;
align-items: flex-start;
font-size: 24rpx;
line-height: 1.4;
}
.hintRow .hintText {
color: #FF8124;
flex: 1;
margin-top: 10rpx;
}
.deductRow {
background: #fff;
padding: 20rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
border-radius: 8rpx;
}
.deductRow .label {
font-size: 28rpx;
color: #333;
}
.deductRow .value {
font-size: 30rpx;
color: #C4121B;
}
.payRow {
background: #fff;
border-radius: 8rpx;
padding: 20rpx 20rpx;
margin-bottom: 20rpx;
}
.radioItem {
display: flex;
align-items: center;
}
.payInfo {
display: flex;
align-items: center;
margin-left: 15rpx;
}
.payInfo .icon {
width: 40rpx;
height: 40rpx;
margin-right: 10rpx;
}
.payInfo text {
font-size: 28rpx;
color: #333;
}
.totalRow {
background: #fff;
border-radius: 8rpx;
padding: 20rpx 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 10rpx;
}
.totalRow .label {
font-size: 28rpx;
color: #333;
}
.redBig {
font-size: 32rpx;
color: #C4121B;
font-weight: bold;
}
.bottomBtn {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 20rpx 20rpx;
background: #fff;
border-top: 1rpx solid #eee;
}
.payBtn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background-color: #C4121B;
color: #fff;
border-radius: 8rpx;
font-size: 32rpx;
text-align: center;
border: none;
}
.payBtn[disabled] {
background-color: #ccc;
color: #999;
}
.red {
color: #C4121B;
}
.icon {
width: 30px;
}
::v-deep .custom-radio .wx-radio-input {
width: 30rpx;
height: 30rpx;
border-radius: 50%;
border: 2rpx solid #ccc;
}
::v-deep .custom-radio .wx-radio-input.wx-radio-input-checked {
border-color: #C4121B !important;
background: #C4121B !important;
}
</style>
\ No newline at end of file
......
<template>
<view class="success-container">
<!-- 成功图标(渐变圆形+动效) -->
<view class="success-icon">
<view class="icon-circle">
<text class="check-icon"></text>
</view>
</view>
<!-- 支付成功标题(动画) -->
<view class="success-title">支付成功</view>
<view class="success-subtitle">支付成功,请等待审核</view>
<!-- 订单信息卡片(带阴影) -->
<view class="info-card">
<view class="info-item">
<text class="label">付款账户</text>
<text class="value">(5437)</text>
</view>
<view class="info-item">
<text class="label">交易流水号</text>
<text class="value">2205051351076117833</text>
</view>
<view class="info-item">
<text class="label">商户名称</text>
<text class="value">中国跆拳道协会</text>
</view>
<view class="info-item">
<text class="label">订单金额</text>
<text class="value amount">1500.00元</text>
</view>
<view class="info-item">
<text class="label">会员编号</text>
<text class="value">CTA00004</text>
</view>
<view class="info-item">
<text class="label">会员有效期</text>
<text class="value">2028年1月25日</text>
</view>
</view>
<!-- 确定按钮(渐变+动效) -->
<view class="confirm-btn-area">
<button class="confirm-btn" @click="goBack">确定</button>
</view>
</view>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app'
const goBack = () => {
uni.navigateTo({
url: `/personal/home`
})
}
onLoad((option) => {
})
</script>
<style scoped>
/* 全局容器 */
.success-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx 60rpx;
min-height: 100vh;
background-color: #f8f9fa;
box-sizing: border-box;
}
/* 成功图标容器 */
.success-icon {
margin-bottom: 40rpx;
animation: fadeIn 0.6s ease-out;
}
/* 渐变圆形背景 */
.icon-circle {
width: 180rpx;
height: 180rpx;
border-radius: 50%;
/* 青绿色渐变 */
background: linear-gradient(135deg, #06c1ae, #04a896);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 30rpx rgba(6, 193, 174, 0.3);
/* 轻微上浮动效 */
animation: scaleIn 0.8s ease-out;
}
/* 对勾图标 */
.check-icon {
font-size: 90rpx;
color: #ffffff;
font-weight: bold;
}
/* 支付成功标题 */
.success-title {
font-size: 48rpx;
font-weight: 700;
color: #333333;
margin-bottom: 12rpx;
animation: slideUp 0.6s ease-out;
}
/* 副标题 */
.success-subtitle {
font-size: 28rpx;
color: #666666;
margin-bottom: 60rpx;
animation: slideUp 0.8s ease-out;
}
/* 订单信息卡片 */
.info-card {
width: 100%;
background: #ffffff;
border-radius: 20rpx;
padding: 40rpx 30rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
margin-bottom: 80rpx;
animation: fadeIn 1s ease-out;
}
/* 单个信息项 */
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
/* 最后一项去掉下划线 */
.info-item:last-child {
border-bottom: none;
}
/* 标签样式 */
.label {
font-size: 32rpx;
color: #666666;
}
/* 值样式 */
.value {
font-size: 32rpx;
color: #333333;
text-align: right;
}
/* 金额特殊样式 */
.amount {
color: #cd1e27;
font-weight: 600;
}
/* 确定按钮区域 */
.confirm-btn-area {
width: 100%;
padding: 0 20rpx;
box-sizing: border-box;
}
/* 确定按钮(渐变+动效) */
.confirm-btn {
width: 100%;
height: 90rpx;
line-height: 90rpx;
/* 按钮渐变背景 */
background: #fff;
color: #C4121B;
font-size: 36rpx;
font-weight: 600;
border-radius: 45rpx;
border: 1px solid #C4121B;
animation: slideUp 1s ease-out;
/* 禁止默认样式 */
position: relative;
overflow: hidden;
}
/* 按钮点击反馈 */
.confirm-btn::after {
border: none;
}
.confirm-btn:active {
transform: scale(0.98);
box-shadow: 0 4rpx 10rpx rgba(6, 193, 174, 0.2);
}
/* 动画定义 */
@keyframes fadeIn {
0% { opacity: 0; }
100% { opacity: 1; }
}
@keyframes scaleIn {
0% { transform: scale(0); }
70% { transform: scale(1.1); }
100% { transform: scale(1); }
}
@keyframes slideUp {
0% { opacity: 0; transform: translateY(30rpx); }
100% { opacity: 1; transform: translateY(0); }
}
<template>
<view class="success-container">
<!-- 成功图标(渐变圆形+动效) -->
<view class="success-icon">
<view class="icon-circle">
<text class="check-icon"></text>
</view>
</view>
<!-- 支付成功标题(动画) -->
<view class="success-title">支付成功</view>
<view class="success-subtitle">支付成功,请等待审核</view>
<!-- 订单信息卡片(带阴影) -->
<view class="info-card">
<view class="info-item">
<text class="label">交易流水号</text>
<text class="value">{{ orderInfo.tradeNo }}</text>
</view>
<view class="info-item">
<text class="label">商户名称</text>
<text class="value">{{ orderInfo.merchantName || '中国跆拳道协会' }}</text>
</view>
<view class="info-item">
<text class="label">订单金额</text>
<text class="value amount">{{ orderInfo.price ? orderInfo.price + '元' : '--' }}</text>
</view>
</view>
<!-- 确定按钮(渐变+动效) -->
<view class="confirm-btn-area">
<button class="confirm-btn" @click="goBack">确定</button>
</view>
</view>
</template>
<script setup>
import {
ref
} from 'vue'
import {
onLoad
} from '@dcloudio/uni-app'
import to from 'await-to-js'
import * as api from '@/common/api.js'
const orderInfo = ref({
id: '',
tradeNo: '',
merchantName: '中国跆拳道协会',
price: ''
})
const goBack = () => {
uni.reLaunch({
url: '/login/login'
})
}
onLoad(async (option) => {
if (option.orderId) {
const [err, res] = await to(api.getOrderInfo(option.orderId))
if (!err && res.data) {
orderInfo.value = res.data
} else {
orderInfo.value.id = option.orderId
}
}
})
</script>
<style scoped>
/* 全局容器 */
.success-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 100rpx 40rpx 60rpx;
min-height: 100vh;
background-color: #f8f9fa;
box-sizing: border-box;
}
/* 成功图标容器 */
.success-icon {
margin-bottom: 40rpx;
animation: fadeIn 0.6s ease-out;
}
/* 渐变圆形背景 */
.icon-circle {
width: 180rpx;
height: 180rpx;
border-radius: 50%;
/* 青绿色渐变 */
background: linear-gradient(135deg, #06c1ae, #04a896);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 30rpx rgba(6, 193, 174, 0.3);
/* 轻微上浮动效 */
animation: scaleIn 0.8s ease-out;
}
/* 对勾图标 */
.check-icon {
font-size: 90rpx;
color: #ffffff;
font-weight: bold;
}
/* 支付成功标题 */
.success-title {
font-size: 48rpx;
font-weight: 700;
color: #333333;
margin-bottom: 12rpx;
animation: slideUp 0.6s ease-out;
}
/* 副标题 */
.success-subtitle {
font-size: 28rpx;
color: #666666;
margin-bottom: 60rpx;
animation: slideUp 0.8s ease-out;
}
/* 订单信息卡片 */
.info-card {
width: 100%;
background: #ffffff;
border-radius: 20rpx;
padding: 40rpx 30rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
margin-bottom: 80rpx;
animation: fadeIn 1s ease-out;
}
/* 单个信息项 */
.info-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24rpx 0;
border-bottom: 1rpx solid #f5f5f5;
}
/* 最后一项去掉下划线 */
.info-item:last-child {
border-bottom: none;
}
/* 标签样式 */
.label {
font-size: 32rpx;
color: #666666;
white-space: nowrap;
margin-right: 20rpx;
flex-shrink: 0;
}
/* 值样式 */
.value {
font-size: 32rpx;
color: #333333;
text-align: right;
word-break: break-all;
word-wrap: break-word;
}
/* 金额特殊样式 */
.amount {
color: #cd1e27;
font-weight: 600;
}
/* 确定按钮区域 */
.confirm-btn-area {
width: 100%;
padding: 0 20rpx;
box-sizing: border-box;
}
/* 确定按钮(渐变+动效) */
.confirm-btn {
width: 100%;
height: 90rpx;
line-height: 90rpx;
/* 按钮渐变背景 */
background: #fff;
color: #C4121B;
font-size: 36rpx;
font-weight: 600;
border-radius: 45rpx;
border: 1px solid #C4121B;
animation: slideUp 1s ease-out;
/* 禁止默认样式 */
position: relative;
overflow: hidden;
}
/* 按钮点击反馈 */
.confirm-btn::after {
border: none;
}
.confirm-btn:active {
transform: scale(0.98);
box-shadow: 0 4rpx 10rpx rgba(6, 193, 174, 0.2);
}
/* 动画定义 */
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes scaleIn {
0% {
transform: scale(0);
}
70% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
@keyframes slideUp {
0% {
opacity: 0;
transform: translateY(30rpx);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>
\ No newline at end of file
......
## 2.1.6(2023-04-16)
* 修复 组件使用 v-show 指令会导致选择图片后初始位置严重偏位的问题
## 2.1.5(2023-04-16)
* 新增 兼容APP平台
## 2.1.4(2023-03-13)
* 新增 fileType 属性,用于指定生成文件的类型,只支持 'jpg' 或 'png',默认为 'png'
* 新增 delay 属性,微信小程序平台使用 `Canvas 2D` 绘制时控制图片从绘制到生成所需时间
* 优化 当生成图片的尺寸宽/高超过 Canvas 2D 最大限制(1365*1365)则将画布尺寸缩放在限制范围内绘制完成后输出目标尺寸
* 优化 旋转图标指示方向与实际旋转方向不符
## 2.1.3(2023-02-06)
* 优化 vue3支持
## 2.1.2(2023-02-03)
* 新增 navigation 属性,H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差
* 修复 H5平台部分设备(已知iPhone11以下机型)拍照的图片缩放时会闪动的问题
## 2.1.1(2022-12-06)
* 修复 横屏适配问题
## 2.1.0(2022-12-06)
* 新增 兼容H5平台,使用 renderjs 响应手势事件
## 2.0.0(2022-12-05)
* 重构 插件,使用 WXS 响应手势事件
* 新增 图片翻转
* 新增 拉伸裁剪框放大图片
* 新增 监听PC鼠标滚轮触发缩放
* 新增 圆形、圆角矩形的图片裁剪
* 优化 图片缩放,移动端以双指触摸中心点为缩放中心点,PC端以鼠标所在点为缩放中心点
* 优化 裁剪框样式
* 优化 图片位置拖动 支持边界回弹效果(滑动时可滑出边界,释放时回弹到边界)
* 优化 生成图片使用新版 Canvas 2D 接口
/**
* 图片编辑器-手势监听
* 1. 支持编译到app-vue(uni-app 2.5.5及以上版本)、H5上
*/
/** 图片偏移量 */
var offset = { x: 0, y: 0 };
/** 图片缩放比例 */
var scale = 1;
/** 图片最小缩放比例 */
var minScale = 1;
/** 图片旋转角度 */
var rotate = 0;
/** 触摸点 */
var touches = [];
/** 图片布局信息 */
var img = {};
/** 系统信息 */
var sys = {};
/** 裁剪区域布局信息 */
var area = {};
/** 触摸行为类型 */
var touchType = '';
/** 操作角的位置 */
var activeAngle = 0;
/** 裁剪区域布局信息偏移量 */
var areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
/** 元素ID */
var elIds = {
'imageStyles': 'crop-image',
'maskStylesList': 'crop-mask-block',
'borderStyles': 'crop-border',
'circleBoxStyles': 'crop-circle-box',
'circleStyles': 'crop-circle',
'gridStylesList': 'crop-grid',
'angleStylesList': 'crop-angle',
}
/** 记录上次初始化时间戳,排除APP重复更新 */
var timestamp = 0;
/**
* 样式对象转字符串
* @param {Object} style 样式对象
*/
function styleToString(style) {
if(typeof style === 'string') return style;
var str = '';
for (let k in style) {
str += k + ':' + style[k] + ';';
}
return str;
}
/**
*
* @param {Object} instance 页面实例对象
* @param {Object} key 要修改样式的key
* @param {Object|Array} style 样式
*/
function setStyle(instance, key, style) {
// console.log('setStyle', instance, key, JSON.stringify(style))
// #ifdef APP-PLUS
if(Object.prototype.toString.call(style) === '[object Array]') {
for (var i = 0, len = style.length; i < len; i++) {
var el = window.document.getElementById(elIds[key] + '-' + (i + 1));
el && (el.style = styleToString(style[i]));
}
} else {
var el = window.document.getElementById(elIds[key]);
el && (el.style = styleToString(style));
}
// #endif
// #ifdef H5
instance[key] = style;
// #endif
}
/**
* 触发页面实例指定方法
* @param {Object} instance 页面实例对象
* @param {Object} name 方法名称
* @param {Object} obj 传递参数
*/
function callMethod(instance, name, obj) {
// #ifdef APP-PLUS
instance.callMethod(name, obj);
// #endif
// #ifdef H5
instance[name](obj);
// #endif
}
/**
* 计算两点间距
* @param {Object} touches 触摸点信息
*/
function getDistanceByTouches(touches) {
// 根据勾股定理求两点间距离
var a = touches[1].pageX - touches[0].pageX;
var b = touches[1].pageY - touches[0].pageY;
var c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
// 求两点间的中点坐标
// 1. a、b可能为负值
// 2. 在求a、b时,如用touches[1]减touches[0],则求中点坐标也得用touches[1]减a/2、b/2
// 3. 同理,在求a、b时,也可用touches[0]减touches[1],则求中点坐标也得用touches[0]减a/2、b/2
var x = touches[1].pageX - a / 2;
var y = touches[1].pageY - b / 2;
return { c, x, y };
};
/**
* 检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
*/
function checkRange(e) {
var r = rotate / 90 % 2;
if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
var o = (img.height - img.width) / 2; // 宽高差值一半
return {
x: Math.min(Math.max(e.x, -img.height + o + area.width + area.left), area.left + o),
y: Math.min(Math.max(e.y, -img.width - o + area.height + area.top), area.top - o)
}
}
return {
x: Math.min(Math.max(e.x, -img.width + area.width + area.left), area.left),
y: Math.min(Math.max(e.y, -img.height + area.height + area.top), area.top)
}
};
/**
* 变更图片布局信息
* @param {Object} e 布局信息
*/
function changeImageRect(e) {
// console.log('changeImageRect', e)
offset.x += e.x || 0;
offset.y += e.y || 0;
if(e.check) { // 检查边界
var point = checkRange(offset);
if(offset.x !== point.x || offset.y !== point.y) {
offset = point;
}
}
// 因频繁修改 width/height 会造成大量的内存消耗,改为scale
// e.instance.imageStyles = {
// width: img.width + 'px',
// height: img.height + 'px',
// transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + ox) + 'px) rotate(' + rotate +'deg)'
// };
var ox = (img.width - img.oldWidth) / 2;
var oy = (img.height - img.oldHeight) / 2;
// e.instance.imageStyles = {
// width: img.oldWidth + 'px',
// height: img.oldHeight + 'px',
// transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
// };
setStyle(e.instance, 'imageStyles', {
width: img.oldWidth + 'px',
height: img.oldHeight + 'px',
transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
});
callMethod(e.instance, 'dataChange', {
width: img.width,
height: img.height,
x: offset.x,
y: offset.y,
rotate: rotate
});
};
/**
* 变更裁剪区域布局信息
* @param {Object} e 布局信息
*/
function changeAreaRect(e) {
// console.log('changeAreaRect', e)
// 变更蒙版样式
setStyle(e.instance, 'maskStylesList', [
{
left: 0,
width: (area.left + areaOffset.left) + 'px',
top: 0,
bottom: 0,
},
{
left: (area.right + areaOffset.right) + 'px',
right: 0,
top: 0,
bottom: 0,
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: 0,
height: (area.top + areaOffset.top) + 'px',
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: (area.bottom + areaOffset.bottom) + 'px',
// height: (area.top - areaOffset.bottom + sys.offsetBottom) + 'px',
bottom: 0,
}
]);
// 变更边框样式
if(area.showBorder) {
setStyle(e.instance, 'borderStyles', {
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
});
}
// 变更参考线样式
if(area.showGrid) {
setStyle(e.instance, 'gridStylesList', [
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px'
},
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) * 2 / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px'
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) * 2 / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
}
]);
}
// 变更四个伸缩角样式
if(area.showAngle) {
setStyle(e.instance, 'angleStylesList', [
{
'border-width': area.angleBorderWidth + 'px 0 0 ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
},
{
'border-width': area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
},
{
'border-width': '0 0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
},
{
'border-width': '0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
}
]);
}
// 变更圆角样式
if(area.radius > 0) {
var radius = area.radius;
if(area.width === area.height && area.radius >= area.width / 2) { // 圆形
radius = (area.width / 2);
} else { // 圆角矩形
if(area.width !== area.height) { // 限制圆角半径不能超过短边的一半
radius = Math.min(area.width / 2, area.height / 2, radius);
}
}
setStyle(e.instance, 'circleBoxStyles', {
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
});
setStyle(e.instance, 'circleStyles', {
'box-shadow': '0 0 0 ' + Math.max(area.width, area.height) + 'px rgba(51, 51, 51, 0.8)',
'border-radius': radius + 'px'
});
}
};
/**
* 缩放图片
* @param {Object} e 布局信息
*/
function scaleImage(e) {
// console.log('scaleImage', e)
var last = scale;
scale = Math.min(Math.max(e.scale + scale, minScale), img.maxScale);
if(last !== scale) {
img.width = img.oldWidth * scale;
img.height = img.oldHeight * scale;
// 参考问题:有一个长4000px、宽4000px的四方形ABCD,A点的坐标固定在(-2000,-2000),
// 该四边形上有一个点E,坐标为(-100,-300),将该四方形复制一份并缩小到90%后,
// 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合?
// 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
// 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
e.x = (e.x - offset.x) * (1 - scale / last);
e.y = (e.y - offset.y) * (1 - scale / last);
changeImageRect(e);
return true;
}
return false;
};
/**
* 获取触摸点在哪个角
* @param {number} x 触摸点x轴坐标
* @param {number} y 触摸点y轴坐标
* @return {number} 角的位置:0=无;1=左上;2=右上;3=左下;4=右下;
*/
function getToucheAngle(x, y) {
// console.log('getToucheAngle', x, y, JSON.stringify(area))
var o = area.angleBorderWidth; // 需扩大触发范围则把 o 值加大即可
var oy = sys.navigation ? 0 : sys.windowTop;
if(y >= area.top - o + oy && y <= area.top + area.angleSize + o + oy) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 1; // 左上角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 2; // 右上角
}
} else if(y >= area.bottom - area.angleSize - o + oy && y <= area.bottom + o + oy) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 3; // 左下角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 4; // 右下角
}
}
return 0; // 无触摸到角
};
/**
* 重置数据
*/
function resetData() {
offset = { x: 0, y: 0 };
scale = 1;
minScale = 1;
rotate = 0;
};
function getTouchs(touches) {
var result = [];
var len = touches ? touches.length : 0
for (var i = 0; i < len; i++) {
result[i] = {
pageX: touches[i].pageX,
// h5无标题栏时,窗口顶部距离仍为标题栏高度,且触摸点y轴坐标还是有标题栏的值,即减去标题栏高度的值
pageY: touches[i].pageY + sys.windowTop
};
}
return result;
};
export default {
data() {
return {
imageStyles: {},
maskStylesList: [{}, {}, {}, {}],
borderStyles: {},
gridStylesList: [{}, {}, {}, {}],
angleStylesList: [{}, {}, {}, {}],
circleBoxStyles: {},
circleStyles: {}
}
},
created() {
// 监听 PC 端鼠标滚轮
// #ifdef H5
window.addEventListener('mousewheel', (e) => {
var touchs = getTouchs([e])
img.src && scaleImage({
instance: this.getInstance(),
check: true,
// 鼠标向上滚动时,deltaY 固定 -100,鼠标向下滚动时,deltaY 固定 100
scale: e.deltaY > 0 ? -0.05 : 0.05,
x: touchs[0].pageX,
y: touchs[0].pageY
});
});
// #endif
},
methods: {
getInstance() {
// #ifdef APP-PLUS
return this.$ownerInstance;
// #endif
// #ifdef H5
return this;
// #endif
},
/**
* 初始化:观察数据变更
* @param {Object} newVal 新数据
* @param {Object} oldVal 旧数据
* @param {Object} o 组件实例对象
*/
initObserver: function(newVal, oldVal, o, i) {
console.log('initObserver', newVal, oldVal, o, i)
if(newVal && (!img.src || timestamp !== newVal.timestamp)) {
timestamp = newVal.timestamp;
img = newVal.img;
sys = newVal.sys;
area = newVal.area;
resetData();
img.src && changeImageRect({
instance: this.getInstance(),
x: (sys.windowWidth - img.width) / 2,
y: (sys.windowHeight + sys.windowTop - sys.offsetBottom - img.height) / 2
});
changeAreaRect({
instance: this.getInstance()
});
}
},
/**
* 鼠标滚轮滚动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
mousewheel: function(e, o) {
// h5平台 wheel 事件无法判断滚轮滑动方向,需使用 mousewheel
},
/**
* 触摸开始
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchstart: function(e, o) {
if(!img.src) return;
touches = getTouchs(e.touches);
activeAngle = area.showAngle ? getToucheAngle(touches[0].pageX, touches[0].pageY) : 0;
if(touches.length === 1 && activeAngle !== 0) {
touchType = 'stretch'; // 伸缩裁剪区域
} else {
touchType = '';
}
// console.log('touchstart', e, activeAngle)
},
/**
* 触摸移动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchmove: function(e, o) {
if(!img.src) return;
// console.log('touchmove', e, o)
e.touches = getTouchs(e.touches);
if(touchType === 'stretch') { // 触摸四个角进行拉伸
var point = e.touches[0];
var start = touches[0];
var x = point.pageX - start.pageX;
var y = point.pageY - start.pageY;
if(x !== 0 || y !== 0) {
var maxX = area.width * (1 - area.minScale);
var maxY = area.height * (1 - area.minScale);
// console.log(x, y, maxX, maxY)
touches[0] = point;
switch(activeAngle) {
case 1: // 左上角
x += areaOffset.left;
y += areaOffset.top;
if(x >= 0 && y >= 0) { // 有效滑动
if(x > y) { // 以x轴滑动距离为缩放基准
if(x > maxX) x = maxX;
y = x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(y > maxY) y = maxY;
x = y * area.width / area.height;
}
areaOffset.left = x;
areaOffset.top = y;
}
break;
case 2: // 右上角
x += areaOffset.right;
y += areaOffset.top;
if(x <= 0 && y >= 0) { // 有效滑动
if(-x > y) { // 以x轴滑动距离为缩放基准
if(-x > maxX) x = -maxX;
y = -x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(y > maxY) y = maxY;
x = -y * area.width / area.height;
}
areaOffset.right = x;
areaOffset.top = y;
}
break;
case 3: // 左下角
x += areaOffset.left;
y += areaOffset.bottom;
if(x >= 0 && y <= 0) { // 有效滑动
if(x > -y) { // 以x轴滑动距离为缩放基准
if(x > maxX) x = maxX;
y = -x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(-y > maxY) y = -maxY;
x = -y * area.width / area.height;
}
areaOffset.left = x;
areaOffset.bottom = y;
}
break;
case 4: // 右下角
x += areaOffset.right;
y += areaOffset.bottom;
if(x <= 0 && y <= 0) { // 有效滑动
if(-x > -y) { // 以x轴滑动距离为缩放基准
if(-x > maxX) x = -maxX;
y = x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(-y > maxY) y = -maxY;
x = y * area.width / area.height;
}
areaOffset.right = x;
areaOffset.bottom = y;
}
break;
}
// console.log(x, y, JSON.stringify(areaOffset))
changeAreaRect({
instance: this.getInstance(),
});
// this.draw();
}
} else if (e.touches.length == 2) { // 双点触摸缩放
var start = getDistanceByTouches(touches);
var end = getDistanceByTouches(e.touches);
scaleImage({
instance: this.getInstance(),
check: !area.bounce,
scale: (end.c - start.c) / 100,
x: end.x,
y: end.y
});
touchType = 'scale';
} else if(touchType === 'scale') {// 从双点触摸变成单点触摸 / 从缩放变成拖动
touchType = 'move';
} else {
changeImageRect({
instance: this.getInstance(),
check: !area.bounce,
x: e.touches[0].pageX - touches[0].pageX,
y: e.touches[0].pageY - touches[0].pageY
});
touchType = 'move';
}
touches = e.touches;
},
/**
* 触摸结束
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchend: function(e, o) {
if(!img.src) return;
if(touchType === 'stretch') { // 拉伸裁剪区域的四个角缩放
// 裁剪区域宽度被缩放到多少
var left = areaOffset.left;
var right = areaOffset.right;
var top = areaOffset.top;
var bottom = areaOffset.bottom;
var w = area.width + right - left;
var h = area.height + bottom - top;
// 图像放大倍数
var p = scale * (area.width / w) - scale;
// 复原裁剪区域
areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
changeAreaRect({
instance: this.getInstance(),
});
scaleImage({
instance: this.getInstance(),
scale: p,
x: area.left + left + (1 === activeAngle || 3 === activeAngle ? w : 0),
y: area.top + top + (1 === activeAngle || 2 === activeAngle ? h : 0)
});
} else if (area.bounce) { // 检查边界并矫正,实现拖动到边界时有回弹效果
changeImageRect({
instance: this.getInstance(),
check: true
});
}
},
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
rotateImage: function(e, o) {
rotate = (rotate + 90) % 360;
// 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
var r = rotate / 90 % 2;
minScale = 1;
if(img.width < area.height) {
minScale = area.height / img.oldWidth;
} else if(img.height < area.width) {
minScale = (area.width / img.oldHeight)
}
if(minScale !== 1) {
scaleImage({
instance: this.getInstance(),
scale: minScale - scale,
x: sys.windowWidth / 2,
y: (sys.windowHeight - sys.offsetBottom) / 2
});
}
// 由于拖动画布后会导致图片位置偏移,翻转时的旋转中心点需是图片区域+偏移区域的中心点
// 翻转x轴中心点 = (超出裁剪区域右侧的图片宽度 - 超出裁剪区域左侧的图片宽度) / 2
// 翻转y轴中心点 = (超出裁剪区域下方的图片宽度 - 超出裁剪区域上方的图片宽度) / 2
var ox = ((offset.x + img.width - area.right) - (area.left - offset.x)) / 2;
var oy = ((offset.y + img.height - area.bottom) - (area.top - offset.y)) / 2;
changeImageRect({
instance: this.getInstance(),
check: true,
x: -ox - oy,
y: -oy + ox
});
}
}
}
\ No newline at end of file
<template>
<view class="image-cropper" @wheel="cropper.mousewheel">
<canvas v-if="use2d" type="2d" id="imgCanvas" class="img-canvas" :style="{
width: `${canvansWidth}px`,
height: `${canvansHeight}px`
}"></canvas>
<canvas v-else id="imgCanvas" canvas-id="imgCanvas" class="img-canvas" :style="{
width: `${canvansWidth}px`,
height: `${canvansHeight}px`
}"></canvas>
<view class="pic-preview" :change:init="cropper.initObserver" :init="initData" @touchstart="cropper.touchstart" @touchmove="cropper.touchmove" @touchend="cropper.touchend">
<image v-if="imgSrc" id="crop-image" class="crop-image" :style="cropper.imageStyles" :src="imgSrc" webp></image>
<view v-for="(item, index) in maskList" :key="item.id" :id="item.id" class="crop-mask-block" :style="cropper.maskStylesList[index]"></view>
<view v-if="showBorder" id="crop-border" class="crop-border" :style="cropper.borderStyles"></view>
<view v-if="radius > 0" id="crop-circle-box" class="crop-circle-box" :style="cropper.circleBoxStyles">
<view class="crop-circle" id="crop-circle" :style="cropper.circleStyles"></view>
</view>
<block v-if="showGrid">
<view v-for="(item, index) in gridList" :key="item.id" :id="item.id" class="crop-grid" :style="cropper.gridStylesList[index]"></view>
</block>
<block v-if="showAngle">
<view v-for="(item, index) in angleList" :key="item.id" :id="item.id" class="crop-angle" :style="cropper.angleStylesList[index]">
<view :style="[{
width: `${angleSize}px`,
height: `${angleSize}px`
}]"></view>
</view>
</block>
</view>
<view class="fixed-bottom safe-area-inset-bottom">
<view v-if="rotatable && !!imgSrc" class="rotate-icon" @click="cropper.rotateImage"></view>
<view v-if="!choosable" class="choose-btn" @click="cropClick">确定</view>
<block v-else-if="!!imgSrc">
<view class="rechoose" @click="chooseImage">重选</view>
<button class="button" size="mini" @click="cropClick">确定</button>
</block>
<view v-else class="choose-btn" @click="chooseImage">选择图片</view>
</view>
</view>
</template>
<!-- #ifdef APP-VUE || H5 -->
<script module="cropper" lang="renderjs">
import cropper from './qf-image-cropper.render.js';
export default {
mixins: [ cropper ]
}
</script>
<!-- #endif -->
<!-- #ifdef MP-WEIXIN || MP-QQ -->
<script module="cropper" lang="wxs" src="./qf-image-cropper.wxs"></script>
<!-- #endif -->
<script>
/** 裁剪区域最大宽高所占屏幕宽度百分比 */
const AREA_SIZE = 75;
/** 图片默认宽高 */
const IMG_SIZE = 300;
export default {
name:"qf-image-cropper",
// #ifdef MP-WEIXIN
options: {
// 表示启用样式隔离,在自定义组件内外,使用 class 指定的样式将不会相互影响
styleIsolation: "isolated"
},
// #endif
props: {
/** 图片资源地址 */
src: {
type: String,
default: ''
},
/** 裁剪宽度,有些平台或设备对于canvas的尺寸有限制,过大可能会导致无法正常绘制 */
width: {
type: Number,
default: IMG_SIZE
},
/** 裁剪高度,有些平台或设备对于canvas的尺寸有限制,过大可能会导致无法正常绘制 */
height: {
type: Number,
default: IMG_SIZE
},
/** 是否绘制裁剪区域边框 */
showBorder: {
type: Boolean,
default: true
},
/** 是否绘制裁剪区域网格参考线 */
showGrid: {
type: Boolean,
default: true
},
/** 是否展示四个支持伸缩的角 */
showAngle: {
type: Boolean,
default: true
},
/** 裁剪区域最小缩放倍数 */
areaScale: {
type: Number,
default: 0.3
},
/** 图片最大缩放倍数 */
maxScale: {
type: Number,
default: 5
},
/** 是否有回弹效果:拖动时可以拖出边界,释放时会弹回边界 */
bounce: {
type: Boolean,
default: true
},
/** 是否支持翻转 */
rotatable: {
type: Boolean,
default: true
},
/** 是否支持从本地选择素材 */
choosable: {
type: Boolean,
default: true
},
/** 四个角尺寸,单位px */
angleSize: {
type: Number,
default: 20
},
/** 四个角边框宽度,单位px */
angleBorderWidth: {
type: Number,
default: 2
},
/** 裁剪图片圆角半径,单位px */
radius: {
type: Number,
default: 0
},
/** 生成文件的类型,只支持 'jpg' 或 'png'。默认为 'png' */
fileType: {
type: String,
default: 'png'
},
/**
* 图片从绘制到生成所需时间,单位ms
* 微信小程序平台使用 `Canvas 2D` 绘制时有效
* 如绘制大图或出现裁剪图片空白等情况应适当调大该值,因 `Canvas 2d` 采用同步绘制,需自己把控绘制完成时间
*/
delay: {
type: Number,
default: 1000
},
// #ifdef H5
/**
* 页面是否是原生标题栏
* H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差。
* 注:因H5平台的窗口高度是包含标题栏的,而屏幕触摸点的坐标是不包含的
*/
navigation: {
type: Boolean,
default: true
}
// #endif
},
emits: ["crop"],
data() {
return {
// 用不同 id 使 v-for key 不重复
maskList: [
{ id: 'crop-mask-block-1' },
{ id: 'crop-mask-block-2' },
{ id: 'crop-mask-block-3' },
{ id: 'crop-mask-block-4' },
],
gridList: [
{ id: 'crop-grid-1' },
{ id: 'crop-grid-2' },
{ id: 'crop-grid-3' },
{ id: 'crop-grid-4' },
],
angleList: [
{ id: 'crop-angle-1' },
{ id: 'crop-angle-2' },
{ id: 'crop-angle-3' },
{ id: 'crop-angle-4' },
],
/** 本地缓存的图片路径 */
imgSrc: '',
/** 图片的裁剪宽度 */
imgWidth: IMG_SIZE,
/** 图片的裁剪高度 */
imgHeight: IMG_SIZE,
/** 裁剪区域最大宽度所占屏幕宽度百分比 */
widthPercent: AREA_SIZE,
/** 裁剪区域最大高度所占屏幕宽度百分比 */
heightPercent: AREA_SIZE,
/** 裁剪区域布局信息 */
area: {},
/** 未被缩放过的图片宽 */
oldWidth: 0,
/** 未被缩放过的图片高 */
oldHeight: 0,
/** 系统信息 */
sys: uni.getSystemInfoSync(),
scaleWidth: 0,
scaleHeight: 0,
rotate: 0,
offsetX: 0,
offsetY: 0,
use2d: false,
canvansWidth: 0,
canvansHeight: 0,
// imageStyles: {},
// maskStylesList: [{}, {}, {}, {}],
// borderStyles: {},
// gridStylesList: [{}, {}, {}, {}],
// angleStylesList: [{}, {}, {}, {}],
// circleBoxStyles: {},
// circleStyles: {},
}
},
computed: {
initData() {
// console.log('initData')
return {
timestamp: new Date().getTime(),
area: {
...this.area,
bounce: this.bounce,
showBorder: this.showBorder,
showGrid: this.showGrid,
showAngle: this.showAngle,
angleSize: this.angleSize,
angleBorderWidth: this.angleBorderWidth,
minScale: this.areaScale,
widthPercent: this.widthPercent,
heightPercent: this.heightPercent,
radius: this.radius
},
sys: this.sys,
img: {
maxScale: this.maxScale,
src: this.imgSrc,
width: this.oldWidth,
height: this.oldHeight,
oldWidth: this.oldWidth,
oldHeight: this.oldHeight,
}
}
},
imgProps() {
return {
width: this.width,
height: this.height,
src: this.src,
}
}
},
watch: {
imgProps: {
handler(val) {
// console.log('imgProps', val)
// 自定义裁剪尺,示例如下:
this.imgWidth = Number(val.width) || IMG_SIZE;
this.imgHeight = Number(val.height) || IMG_SIZE;
let use2d = true;
// #ifndef MP-WEIXIN
use2d = false;
// #endif
// if(use2d && (this.imgWidth > 1365 || this.imgHeight > 1365)) {
// use2d = false;
// }
let canvansWidth = this.imgWidth;
let canvansHeight = this.imgHeight;
let size = Math.max(canvansWidth, canvansHeight)
let scalc = 1;
if(size > 1365) {
scalc = 1365 / size;
}
this.canvansWidth = canvansWidth * scalc;
this.canvansHeight = canvansHeight * scalc;
this.use2d = use2d;
this.initArea();
val.src && this.initImage(val.src);
},
immediate: true
},
},
methods: {
/** 提供给wxs调用,用来接收图片变更数据 */
dataChange(e) {
// console.log('dataChange', e)
this.scaleWidth = e.width;
this.scaleHeight = e.height;
this.rotate = e.rotate;
this.offsetX = e.x;
this.offsetY = e.y;
},
/** 初始化裁剪区域布局信息 */
initArea() {
// 底部操作栏高度 = 底部底部操作栏内容高度 + 设备底部安全区域高度
this.sys.offsetBottom = uni.upx2px(100) + this.sys.safeAreaInsets.bottom;
// #ifndef H5
this.sys.windowTop = 0;
this.sys.navigation = true;
// #endif
// #ifdef H5
// h5平台的窗口高度是包含标题栏的
this.sys.windowTop = this.sys.windowTop || 44;
this.sys.navigation = this.navigation;
// #endif
let wp = this.widthPercent;
let hp = this.heightPercent;
if (this.imgWidth > this.imgHeight) {
hp = hp * this.imgHeight / this.imgWidth;
} else if (this.imgWidth < this.imgHeight) {
wp = wp * this.imgWidth / this.imgHeight;
}
const size = this.sys.windowWidth > this.sys.windowHeight ? this.sys.windowHeight : this.sys.windowWidth;
const width = size * wp / 100;
const height = size * hp / 100;
const left = (this.sys.windowWidth - width) / 2;
const right = left + width;
const top = (this.sys.windowHeight + this.sys.windowTop - this.sys.offsetBottom - height) / 2;
const bottom = this.sys.windowHeight + this.sys.windowTop - this.sys.offsetBottom - top;
this.area = { width, height, left, right, top, bottom };
this.scaleWidth = width;
this.scaleHeight = height;
},
/** 从本地选取图片 */
chooseImage() {
// #ifdef MP-WEIXIN || MP-JD
if(uni.chooseMedia) {
uni.chooseMedia({
count: 1,
mediaType: ['image'],
success: (res) => {
this.resetData();
this.initImage(res.tempFiles[0].tempFilePath);
}
});
return;
}
// #endif
uni.chooseImage({
count: 1,
success: (res) => {
this.resetData();
this.initImage(res.tempFiles[0].path);
}
});
},
/** 重置数据 */
resetData() {
this.imgSrc = '';
this.rotate = 0;
this.offsetX = 0;
this.offsetY = 0;
this.initArea();
},
/**
* 初始化图片信息
* @param {String} url 图片链接
*/
initImage(url) {
uni.getImageInfo({
src: url,
success: (res) => {
this.imgSrc = res.path;
let scale = res.width / res.height;
let areaScale = this.area.width / this.area.height;
if (scale > 1) { // 横向图片
if (scale >= areaScale) { // 图片宽不小于目标宽,则高固定,宽自适应
this.scaleWidth = (this.scaleHeight / res.height) * this.scaleWidth * (res.width / this.scaleWidth);
} else { // 否则宽固定、高自适应
this.scaleHeight = res.height * this.scaleWidth / res.width;
}
} else { // 纵向图片
if (scale <= areaScale) { // 图片高不小于目标高,宽固定,高自适应
this.scaleHeight = (this.scaleWidth / res.width) * this.scaleHeight / (this.scaleHeight / res.height);
} else { // 否则高固定,宽自适应
this.scaleWidth = res.width * this.scaleHeight / res.height;
}
}
// 记录原始宽高,为缩放比列做限制
this.oldWidth = this.scaleWidth;
this.oldHeight = this.scaleHeight;
},
fail: (err) => {
console.error(err)
}
});
},
/**
* 剪切图片圆角
* @param {Object} ctx canvas 的绘图上下文对象
* @param {Number} radius 圆角半径
* @param {Number} scale 生成图片的实际尺寸与截取区域比
* @param {Function} drawImage 执行剪切时所调用的绘图方法,入参为是否执行了剪切
*/
drawClipImage(ctx, radius, scale, drawImage) {
if(radius > 0) {
ctx.save();
ctx.beginPath();
const w = this.canvansWidth;
const h = this.canvansHeight;
if(w === h && radius >= w / 2) { // 圆形
ctx.arc(w / 2, h / 2, w / 2, 0, 2 * Math.PI);
} else { // 圆角矩形
if(w !== h) { // 限制圆角半径不能超过短边的一半
radius = Math.min(w / 2, h / 2, radius);
// radius = Math.min(Math.max(w, h) / 2, radius);
}
ctx.moveTo(radius, 0);
ctx.arcTo(w, 0, w, h, radius);
ctx.arcTo(w, h, 0, h, radius);
ctx.arcTo(0, h, 0, 0, radius);
ctx.arcTo(0, 0, w, 0, radius);
ctx.closePath();
}
ctx.clip();
drawImage && drawImage(true);
ctx.restore();
} else {
drawImage && drawImage(false);
}
},
/**
* 旋转图片
* @param {Object} ctx canvas 的绘图上下文对象
* @param {Number} rotate 旋转角度
* @param {Number} scale 生成图片的实际尺寸与截取区域比
*/
drawRotateImage(ctx, rotate, scale) {
if(rotate !== 0) {
// 1. 以图片中心点为旋转中心点
const x = this.scaleWidth * scale / 2;
const y = this.scaleHeight * scale / 2;
ctx.translate(x, y);
// 2. 旋转画布
ctx.rotate(rotate * Math.PI / 180);
// 3. 旋转完画布后恢复设置旋转中心时所做的偏移
ctx.translate(-x, -y);
}
},
drawImage(ctx, image, callback) {
// 生成图片的实际尺寸与截取区域比
const scale = this.canvansWidth / this.area.width;
this.drawClipImage(ctx, this.radius, scale, () => {
this.drawRotateImage(ctx, this.rotate, scale);
const r = this.rotate / 90;
ctx.drawImage(
image,
[
(this.offsetX - this.area.left),
(this.offsetY - this.area.top),
-(this.offsetX - this.area.left),
-(this.offsetY - this.area.top)
][r] * scale,
[
(this.offsetY - this.area.top),
-(this.offsetX - this.area.left),
-(this.offsetY - this.area.top),
(this.offsetX - this.area.left)
][r] * scale,
this.scaleWidth * scale,
this.scaleHeight * scale
);
});
},
/**
* 绘图
* @param {Object} canvas
* @param {Object} ctx canvas 的绘图上下文对象
* @param {String} src 图片路径
* @param {Function} callback 开始绘制时回调
*/
draw2DImage(canvas, ctx, src, callback) {
// console.log('draw2DImage', canvas, ctx, src, callback)
if(canvas) {
const image = canvas.createImage();
image.onload = () => {
this.drawImage(ctx, image);
// 如果觉得`生成时间过长`或`出现生成图片空白`可尝试调整延迟时间
callback && setTimeout(callback, this.delay);
};
image.onerror = (err) => {
console.error(err)
uni.hideLoading();
};
image.src = src;
} else {
this.drawImage(ctx, src);
setTimeout(() => {
ctx.draw(false, callback);
}, 200);
}
},
/**
* 画布转图片到本地缓存
* @param {Object} canvas
* @param {String} canvasId
*/
canvasToTempFilePath(canvas, canvasId) {
// console.log('canvasToTempFilePath', canvas, canvasId)
uni.canvasToTempFilePath({
canvas,
canvasId,
x: 0,
y: 0,
width: this.canvansWidth,
height: this.canvansHeight,
destWidth: this.imgWidth, // 必要,保证生成图片宽度不受设备分辨率影响
destHeight: this.imgHeight, // 必要,保证生成图片高度不受设备分辨率影响
fileType: this.fileType, // 目标文件的类型,默认png
success: (res) => {
// 生成的图片临时文件路径
this.handleImage(res.tempFilePath);
},
fail: (err) => {
uni.hideLoading();
uni.showToast({ title: '裁剪失败,生成图片异常!', icon: 'none' });
}
}, this);
},
/** 确认裁剪 */
cropClick() {
uni.showLoading({ title: '裁剪中...', mask: true });
if(!this.use2d) {
const ctx = uni.createCanvasContext('imgCanvas', this);
ctx.clearRect(0, 0, this.canvansWidth, this.canvansHeight);
this.draw2DImage(null, ctx, this.imgSrc, () => {
this.canvasToTempFilePath(null, 'imgCanvas');
});
return;
}
// #ifdef MP-WEIXIN
const query = uni.createSelectorQuery().in(this);
query.select('#imgCanvas')
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node;
const dpr = uni.getSystemInfoSync().pixelRatio;
canvas.width = res[0].width * dpr;
canvas.height = res[0].height * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, this.canvansWidth, this.canvansHeight);
this.draw2DImage(canvas, ctx, this.imgSrc, () => {
this.canvasToTempFilePath(canvas);
});
});
// #endif
},
handleImage(tempFilePath){
// 在H5平台下,tempFilePath 为 base64
// console.log(tempFilePath)
uni.hideLoading();
this.$emit('crop', { tempFilePath });
}
}
}
</script>
<style lang="scss" scoped>
.image-cropper {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
overflow: hidden;
display: flex;
flex-direction: column;
background-color: #000;
.img-canvas {
position: absolute !important;
transform: translateX(-100%);
}
.pic-preview {
width: 100%;
flex: 1;
position: relative;
.crop-mask-block {
background-color: rgba(51, 51, 51, 0.8);
z-index: 2;
position: fixed;
box-sizing: border-box;
pointer-events: none;
}
.crop-circle-box {
position: fixed;
box-sizing: border-box;
z-index: 2;
pointer-events: none;
overflow: hidden;
.crop-circle {
width: 100%;
height: 100%;
}
}
.crop-image {
padding: 0 !important;
margin: 0 !important;
border-radius: 0 !important;
display: block !important;
}
.crop-border {
position: fixed;
border: 1px solid #fff;
box-sizing: border-box;
z-index: 3;
pointer-events: none;
}
.crop-grid {
position: fixed;
z-index: 3;
border-style: dashed;
border-color: #fff;
pointer-events: none;
opacity: 0.5;
}
.crop-angle {
position: fixed;
z-index: 3;
border-style: solid;
border-color: #fff;
pointer-events: none;
}
}
.fixed-bottom {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
display: flex;
flex-direction: row;
background-color: $uni-bg-color-grey;
.rotate-icon {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAABCFJREFUaEPtml3IpVMUx3//ko/ChTIyiGFSMyhllI8bc4F85yuNC2FCqLmQC1+FZORiEkUMNW7UjKjJULgxV+NzSkxDhEkZgwsyigv119J63p7zvOc8z37OmXdOb51dz82711r7/99r7bXXXucVi3xokeNnRqCvB20fDmwAlgK/5bcD+FTSr33tHXQP2H4MeHQE0A+B5yRtLiUyDQJrgVc6AAaBpyV93kXkoBMIQLbfBS5NcK8BRwDXNcD+AdwnaVMbiWkRCPBBohpxHuK7M7865sclRdgNHVMhkF6IMIpwirFEUhzo8M7lwIvASTXEqyVtH8ZgagQSbOzsDknv18HZXpHn5IL8+94IOUm7miSmSqAttjPdbgGuTrnNktYsGgLpoYuAD2qg1zRTbG8P2D4SOC6/Q7vSHPALsE/S7wWy80RsPw/ckxMfSTq/LtRJwPbxwF3ASiCUTxwHCPAnEBfVF8AWSTtL7Ng+LfWOTfmlkn6udFsJ5K15R6a4kvX6yGyUFBvTOWzHXXFzCt4g6c1OArYj9iIGh43YgR+BvztXh1PSa4cMkd0jaVmXDduPAE+k3HpJD7cSGFKvfAc8FQUX8IOk/V2L1udtB/hTgdOBW4Aba/M7Ja1qs2f7euCNlHlZUlx4/495IWQ7Jl+qGbxX0gt9AHfJ2o6zFBVoNVrDKe+F3Sm8VdK1bQQ+A85JgXckXdkFaJx527cC9TpnVdvBtl3h2iapuhsGPdBw1b9xnUvaNw7AEh3bnwDnpuwGSfeP0rN9NvAMELXRXFkxEEK2nwQeSiOtRVQJwC4Z29cAW1Nuu6TVXTrN+SaBt4ErUug2Sa/2NdhH3vZy4NvU2S/p6D768w5xI3WOrAD7LtISFpGdIhVXKfaYvjd20wP13L9M0p4DBbaFRKToSLExVkr6qs+aIwlI6iwz+izUQqC+ab29PiMwqRcmPXczD8w8MFj1zg7xXEqbpdHCw7FgWSjafZL+KcQxtpjteCeflwYulFR/J3TabSslVkj6utPChAK2f6q9uZdLitKieLQRuExSvX9ZbLRUMFs09efpUZL+KtUfVo1GW/umNHC3pOhRLtiwfSbwZS6wV9IJfRdreuBBYH0a2STp9r4G+8jbXgc8mzoDT8VSO00ClwDv1ZR7XyylC4ec7ejaLUmdsV6Aw7oSbwFXpdFdks7qA6pU1na0aR6owgeIR/1cx63UzjAC0YXYVjMQHlkn6ZtSo21ytuPZGKFagQ/xsXZ/3iGuFrYdjafXG0DiQMeBi47c9/GV3BO247UV38n5o0UAP6xmu7jFOGxjRr66On5NPBDOCBsDTapxjHY1dyOcolNXnYlx1himE53p2PmNkxosevfavhg4Izt2k7TXPwZ2S6p6QZPin/2rwcQ7OKmBohCadJGF1P8PG6aaQBKVX/8AAAAASUVORK5CYII=');
background-size: 60% 60%;
background-repeat: no-repeat;
background-position:center;
width: 80rpx;
height: 80rpx;
position: absolute;
top: -90rpx;
left: 10rpx;
transform: rotateY(180deg);
}
.rechoose {
color: $uni-color-primary;
padding: 0 $uni-spacing-row-lg;
line-height: 100rpx;
}
.choose-btn {
color: $uni-color-primary;
text-align: center;
line-height: 100rpx;
flex: 1;
}
.button {
margin: auto $uni-spacing-row-lg auto auto;
background-color: $uni-color-primary;
color: #fff;
}
}
.safe-area-inset-bottom {
padding-bottom: 0;
padding-bottom: constant(safe-area-inset-bottom); // 兼容 IOS<11.2
padding-bottom: env(safe-area-inset-bottom); // 兼容 IOS>=11.2
}
}
</style>
\ No newline at end of file
/**
* 图片编辑器-手势监听
* 1. wxs 暂不支持 es6 语法
* 2. 支持编译到微信小程序、QQ小程序、app-vue、H5上(uni-app 2.2.5及以上版本)
*/
/** 图片偏移量 */
var offset = { x: 0, y: 0 };
/** 图片缩放比例 */
var scale = 1;
/** 图片最小缩放比例 */
var minScale = 1;
/** 图片旋转角度 */
var rotate = 0;
/** 触摸点 */
var touches = [];
/** 图片布局信息 */
var img = {};
/** 系统信息 */
var sys = {};
/** 裁剪区域布局信息 */
var area = {};
/** 触摸行为类型 */
var touchType = '';
/** 操作角的位置 */
var activeAngle = 0;
/** 裁剪区域布局信息偏移量 */
var areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
/**
* 计算两点间距
* @param {Object} touches 触摸点信息
*/
function getDistanceByTouches(touches) {
// 根据勾股定理求两点间距离
var a = touches[1].pageX - touches[0].pageX;
var b = touches[1].pageY - touches[0].pageY;
var c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
// 求两点间的中点坐标
// 1. a、b可能为负值
// 2. 在求a、b时,如用touches[1]减touches[0],则求中点坐标也得用touches[1]减a/2、b/2
// 3. 同理,在求a、b时,也可用touches[0]减touches[1],则求中点坐标也得用touches[0]减a/2、b/2
var x = touches[1].pageX - a / 2;
var y = touches[1].pageY - b / 2;
return { c, x, y };
};
/**
* 检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
*/
function checkRange(e) {
var r = rotate / 90 % 2;
if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
var o = (img.height - img.width) / 2; // 宽高差值一半
return {
x: Math.min(Math.max(e.x, -img.height + o + area.width + area.left), area.left + o),
y: Math.min(Math.max(e.y, -img.width - o + area.height + area.top), area.top - o)
}
}
return {
x: Math.min(Math.max(e.x, -img.width + area.width + area.left), area.left),
y: Math.min(Math.max(e.y, -img.height + area.height + area.top), area.top)
}
};
/**
* 变更图片布局信息
* @param {Object} e 布局信息
*/
function changeImageRect(e) {
offset.x += e.x || 0;
offset.y += e.y || 0;
var image = e.instance.selectComponent('.crop-image');
if(e.check) { // 检查边界
var point = checkRange(offset);
if(offset.x !== point.x || offset.y !== point.y) {
offset = point;
}
}
// image.setStyle({
// width: img.width + 'px',
// height: img.height + 'px',
// transform: 'translate(' + offset.x + 'px, ' + offset.y + 'px) rotate(' + rotate +'deg)'
// });
var ox = (img.width - img.oldWidth) / 2;
var oy = (img.height - img.oldHeight) / 2;
image.setStyle({
width: img.oldWidth + 'px',
height: img.oldHeight + 'px',
transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
});
e.instance.callMethod('dataChange', {
width: img.width,
height: img.height,
x: offset.x,
y: offset.y,
rotate: rotate
});
};
/**
* 变更裁剪区域布局信息
* @param {Object} e 布局信息
*/
function changeAreaRect(e) {
// 变更蒙版样式
var masks = e.instance.selectAllComponents('.crop-mask-block');
var maskStyles = [
{
left: 0,
width: (area.left + areaOffset.left) + 'px',
top: 0,
bottom: 0,
},
{
left: (area.right + areaOffset.right) + 'px',
right: 0,
top: 0,
bottom: 0,
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: 0,
height: (area.top + areaOffset.top) + 'px',
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: (area.bottom + areaOffset.bottom) + 'px',
// height: (area.top - areaOffset.bottom + sys.offsetBottom) + 'px',
bottom: 0,
}
];
var len = masks.length;
for (var i = 0; i < len; i++) {
masks[i].setStyle(maskStyles[i]);
}
// 变更边框样式
if(area.showBorder) {
var border = e.instance.selectComponent('.crop-border');
border.setStyle({
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
});
}
// 变更参考线样式
if(area.showGrid) {
var grids = e.instance.selectAllComponents('.crop-grid');
var gridStyles = [
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px'
},
{
'border-width': '1px 0 0 0',
left: (area.left + areaOffset.left) + 'px',
right: (area.right + areaOffset.right) + 'px',
top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) * 2 / 3 - 0.5) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px'
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
},
{
'border-width': '0 1px 0 0',
top: (area.top + areaOffset.top) + 'px',
bottom: (area.bottom + areaOffset.bottom) + 'px',
left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) * 2 / 3 - 0.5) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
}
];
var len = grids.length;
for (var i = 0; i < len; i++) {
grids[i].setStyle(gridStyles[i]);
}
}
// 变更四个伸缩角样式
if(area.showAngle) {
var angles = e.instance.selectAllComponents('.crop-angle');
var angleStyles = [
{
'border-width': area.angleBorderWidth + 'px 0 0 ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
},
{
'border-width': area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
},
{
'border-width': '0 0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px',
left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
},
{
'border-width': '0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0',
left: (area.right + areaOffset.right - area.angleSize) + 'px',
top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
}
];
var len = angles.length;
for (var i = 0; i < len; i++) {
angles[i].setStyle(angleStyles[i]);
}
}
// 变更圆角样式
if(area.radius > 0) {
var circleBox = e.instance.selectComponent('.crop-circle-box');
var circle = e.instance.selectComponent('.crop-circle');
var radius = area.radius;
if(area.width === area.height && area.radius >= area.width / 2) { // 圆形
radius = (area.width / 2);
} else { // 圆角矩形
if(area.width !== area.height) { // 限制圆角半径不能超过短边的一半
radius = Math.min(area.width / 2, area.height / 2, radius);
}
}
circleBox.setStyle({
left: (area.left + areaOffset.left) + 'px',
top: (area.top + areaOffset.top) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
});
circle.setStyle({
'box-shadow': '0 0 0 ' + Math.max(area.width, area.height) + 'px rgba(51, 51, 51, 0.8)',
'border-radius': radius + 'px'
});
}
};
/**
* 缩放图片
* @param {Object} e 布局信息
*/
function scaleImage(e) {
var last = scale;
scale = Math.min(Math.max(e.scale + scale, minScale), img.maxScale);
if(last !== scale) {
img.width = img.oldWidth * scale;
img.height = img.oldHeight * scale;
// 参考问题:有一个长4000px、宽4000px的四方形ABCD,A点的坐标固定在(-2000,-2000),
// 该四边形上有一个点E,坐标为(-100,-300),将该四方形复制一份并缩小到90%后,
// 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合?
// 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
// 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
e.x = (e.x - offset.x) * (1 - scale / last);
e.y = (e.y - offset.y) * (1 - scale / last);
changeImageRect(e);
return true;
}
return false;
};
/**
* 获取触摸点在哪个角
* @param {number} x 触摸点x轴坐标
* @param {number} y 触摸点y轴坐标
* @return {number} 角的位置:0=无;1=左上;2=右上;3=左下;4=右下;
*/
function getToucheAngle(x, y) {
// console.log('getToucheAngle', x, y, JSON.stringify(area))
var o = area.angleBorderWidth; // 需扩大触发范围则把 o 值加大即可
if(y >= area.top - o && y <= area.top + area.angleSize + o) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 1; // 左上角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 2; // 右上角
}
} else if(y >= area.bottom - area.angleSize - o && y <= area.bottom + o) {
if(x >= area.left - o && x <= area.left + area.angleSize + o) {
return 3; // 左下角
} else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
return 4; // 右下角
}
}
return 0; // 无触摸到角
};
/**
* 重置数据
*/
function resetData() {
offset = { x: 0, y: 0 };
scale = 1;
minScale = 1;
rotate = 0;
};
module.exports = {
/**
* 初始化:观察数据变更
* @param {Object} newVal 新数据
* @param {Object} oldVal 旧数据
* @param {Object} o 组件实例对象
*/
initObserver: function(newVal, oldVal, o, i) {
if(newVal) {
img = newVal.img;
sys = newVal.sys;
area = newVal.area;
resetData();
img.src && changeImageRect({
instance: o,
x: (sys.windowWidth - img.width) / 2,
y: (sys.windowHeight - sys.offsetBottom - img.height) / 2
});
changeAreaRect({
instance: o
});
// console.log('initRect', JSON.stringify(newVal))
}
},
/**
* 鼠标滚轮滚动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
mousewheel: function(e, o) {
if(!img.src) return;
scaleImage({
instance: o,
check: true,
// 鼠标向上滚动时,deltaY 固定 -100,鼠标向下滚动时,deltaY 固定 100
scale: e.detail.deltaY > 0 ? -0.05 : 0.05,
x: e.touches[0].pageX,
y: e.touches[0].pageY
});
},
/**
* 触摸开始
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchstart: function(e, o) {
if(!img.src) return;
touches = e.touches;
activeAngle = area.showAngle ? getToucheAngle(touches[0].pageX, touches[0].pageY) : 0;
if(touches.length === 1 && activeAngle !== 0) {
touchType = 'stretch'; // 伸缩裁剪区域
} else {
touchType = '';
}
// console.log('touchstart', JSON.stringify(e), activeAngle)
},
/**
* 触摸移动
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchmove: function(e, o) {
if(!img.src) return;
// console.log('touchmove', JSON.stringify(e), JSON.stringify(o))
if(touchType === 'stretch') { // 触摸四个角进行拉伸
var point = e.touches[0];
var start = touches[0];
var x = point.pageX - start.pageX;
var y = point.pageY - start.pageY;
if(x !== 0 || y !== 0) {
var maxX = area.width * (1 - area.minScale);
var maxY = area.height * (1 - area.minScale);
// console.log(x, y, maxX, maxY)
touches[0] = point;
switch(activeAngle) {
case 1: // 左上角
x += areaOffset.left;
y += areaOffset.top;
if(x >= 0 && y >= 0) { // 有效滑动
if(x > y) { // 以x轴滑动距离为缩放基准
if(x > maxX) x = maxX;
y = x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(y > maxY) y = maxY;
x = y * area.width / area.height;
}
areaOffset.left = x;
areaOffset.top = y;
}
break;
case 2: // 右上角
x += areaOffset.right;
y += areaOffset.top;
if(x <= 0 && y >= 0) { // 有效滑动
if(-x > y) { // 以x轴滑动距离为缩放基准
if(-x > maxX) x = -maxX;
y = -x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(y > maxY) y = maxY;
x = -y * area.width / area.height;
}
areaOffset.right = x;
areaOffset.top = y;
}
break;
case 3: // 左下角
x += areaOffset.left;
y += areaOffset.bottom;
if(x >= 0 && y <= 0) { // 有效滑动
if(x > -y) { // 以x轴滑动距离为缩放基准
if(x > maxX) x = maxX;
y = -x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(-y > maxY) y = -maxY;
x = -y * area.width / area.height;
}
areaOffset.left = x;
areaOffset.bottom = y;
}
break;
case 4: // 右下角
x += areaOffset.right;
y += areaOffset.bottom;
if(x <= 0 && y <= 0) { // 有效滑动
if(-x > -y) { // 以x轴滑动距离为缩放基准
if(-x > maxX) x = -maxX;
y = x * area.height / area.width;
} else { // 以y轴滑动距离为缩放基准
if(-y > maxY) y = -maxY;
x = y * area.width / area.height;
}
areaOffset.right = x;
areaOffset.bottom = y;
}
break;
}
// console.log(x, y, JSON.stringify(areaOffset))
changeAreaRect({
instance: o,
});
// this.draw();
}
} else if (e.touches.length == 2) { // 双点触摸缩放
var start = getDistanceByTouches(touches);
var end = getDistanceByTouches(e.touches);
scaleImage({
instance: o,
check: !area.bounce,
scale: (end.c - start.c) / 100,
x: end.x,
y: end.y
});
touchType = 'scale';
} else if(touchType === 'scale') {// 从双点触摸变成单点触摸 / 从缩放变成拖动
touchType = 'move';
} else {
changeImageRect({
instance: o,
check: !area.bounce,
x: e.touches[0].pageX - touches[0].pageX,
y: e.touches[0].pageY - touches[0].pageY
});
touchType = 'move';
}
touches = e.touches;
},
/**
* 触摸结束
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
touchend: function(e, o) {
if(!img.src) return;
if(touchType === 'stretch') { // 拉伸裁剪区域的四个角缩放
// 裁剪区域宽度被缩放到多少
var left = areaOffset.left;
var right = areaOffset.right;
var top = areaOffset.top;
var bottom = areaOffset.bottom;
var w = area.width + right - left;
var h = area.height + bottom - top;
// 图像放大倍数
var p = scale * (area.width / w) - scale;
// 复原裁剪区域
areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
changeAreaRect({
instance: o,
});
scaleImage({
instance: o,
scale: p,
x: area.left + left + (1 === activeAngle || 3 === activeAngle ? w : 0),
y: area.top + top + (1 === activeAngle || 2 === activeAngle ? h : 0)
});
} else if (area.bounce) { // 检查边界并矫正,实现拖动到边界时有回弹效果
changeImageRect({
instance: o,
check: true
});
}
},
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
rotateImage: function(e, o) {
rotate = (rotate + 90) % 360;
// 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
var r = rotate / 90 % 2;
minScale = 1;
if(img.width < area.height) {
minScale = area.height / img.oldWidth;
} else if(img.height < area.width) {
minScale = (area.width / img.oldHeight)
}
if(minScale !== 1) {
scaleImage({
instance: o,
scale: minScale - scale,
x: sys.windowWidth / 2,
y: (sys.windowHeight - sys.offsetBottom) / 2
});
}
// 由于拖动画布后会导致图片位置偏移,翻转时的旋转中心点需是图片区域+偏移区域的中心点
// 翻转x轴中心点 = (超出裁剪区域右侧的图片宽度 - 超出裁剪区域左侧的图片宽度) / 2
// 翻转y轴中心点 = (超出裁剪区域下方的图片宽度 - 超出裁剪区域上方的图片宽度) / 2
var ox = ((offset.x + img.width - area.right) - (area.left - offset.x)) / 2;
var oy = ((offset.y + img.height - area.bottom) - (area.top - offset.y)) / 2;
changeImageRect({
instance: o,
check: true,
x: -ox - oy,
y: -oy + ox
});
},
// 此处只用于对齐其他平台端的样式参数,防止异常,无作用
imageStyles: {},
maskStylesList: [{}, {}, {}, {}],
borderStyles: {},
gridStylesList: [{}, {}, {}, {}],
angleStylesList: [{}, {}, {}, {}],
circleBoxStyles: {},
circleStyles: {},
}
\ No newline at end of file
{
"id": "qf-image-cropper",
"displayName": "图片裁剪插件",
"version": "2.1.6",
"description": "图片裁剪插件,支持自定义尺寸、定点等比例缩放、拖动、图片翻转、剪切圆形/圆角图片、定制样式,功能多性能高体验好注释全。",
"keywords": [
"qf-image-cropper",
"图片裁剪",
"图片编辑",
"头像裁剪",
"小程序"
],
"repository": "",
"engines": {
"HBuilderX": "^3.1.0"
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "n"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "y",
"阿里": "n",
"百度": "n",
"字节跳动": "n",
"QQ": "u",
"钉钉": "n",
"快手": "n",
"飞书": "n",
"京东": "n"
},
"快应用": {
"华为": "n",
"联盟": "n"
}
}
}
}
}
\ No newline at end of file
# qf-image-cropper
## 图片裁剪插件
uniapp微信小程序图片裁剪插件,支持自定义尺寸、定点等比例缩放、拖动、图片翻转、剪切圆形/圆角图片、定制样式,功能多性能高体验好注释全。
### 平台支持:
1. 支持微信小程序:移动端、PC端、开发者工具
2. 支持H5平台(2.1.0版本起)
3. 支持APP平台(2.1.5版本起):Android、IOS
4. 其他平台暂未测试兼容性未知
### 支持功能:
1. 自定义裁剪尺寸
2. 定点等比例缩放:移动端以双指触摸中心点为缩放中心点,PC端以鼠标所在点为缩放中心点
3. 自由拖动:支持限制滑出边界,也支持回弹效果(滑动时可滑出边界,释放时回弹到边界)
4. 图片翻转:在裁剪尺寸非 1:1 的情况下,翻转时宽高无法铺满裁剪区域时,图片会自动放大到合适尺寸
5. 裁剪生成新图片
6. 本地选择图片
7. 可定制样式:可自由选择是否渲染裁剪边框、可伸缩裁剪顶角、参考线
8. 裁剪圆角图片:圆形、圆角矩形
### 属性说明
| 属性名 | 类型 | 默认值 | 说明 |
|:---|:---|:---|:---|
| src | String | | 图片资源地址 |
| width | Number | 300 | 裁剪宽度 |
| height | Number | 300 | 裁剪高度 |
| showBorder | Boolean | true | 是否绘制裁剪区域边框 |
| showGrid | Boolean | true | 是否绘制裁剪区域网格参考线 |
| showAngle | Boolean | true | 是否展示四个支持伸缩的角 |
| areaScale | Number | 0.3 | 裁剪区域最小缩放倍数 |
| maxScale | Number | 5 | 图片最大缩放倍数 |
| bounce | Boolean | true | 是否有回弹效果:拖动时可以拖出边界,释放时会弹回边界 |
| rotatable | Boolean | true | 是否支持翻转 |
| choosable | Boolean | true | 是否支持从本地选择素材 |
| angleSize | Number | 20 | 四个角尺寸,单位px |
| angleBorderWidth | Number | 2 | 四个角边框宽度,单位px |
| radius | Number | | 裁剪图片圆角半径,单位px |
| fileType | String | png | 生成文件的类型,只支持 'jpg' 或 'png'。默认为 'png' |
| delay | Number | 1000 | 图片从绘制到生成所需时间,单位ms<br>微信小程序平台使用 `Canvas 2D` 绘制时有效<br>如绘制大图或出现裁剪图片空白等情况应适当调大该值,因 `Canvas 2d` 采用同步绘制,需自己把控绘制完成时间 |
| navigation | Boolean | true | 页面是否是原生标题栏:<br>H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 `"navigationStyle": "custom"` 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差。<br>注:因H5平台的窗口高度是包含标题栏的,而屏幕触摸点的坐标是不包含的 |
| @crop | EventHandle | | 剪裁完成后触发,event = { tempFilePath }。在H5平台下,tempFilePath 为 base64 |
### 基本用法
```
<template>
<div>
<qf-image-cropper :width="500" :height="500" :radius="30" @crop="handleCrop"></qf-image-cropper>
</div>
</template>
<script>
import QfImageCropper from '@/components/qf-image-cropper/qf-image-cropper.vue';
export default {
components: {
QfImageCropper
},
methods: {
handleCrop(e) {
uni.previewImage({
urls: [e.tempFilePath],
current: 0
});
}
}
}
</script>
```
### 使用说明
1.建议在`pages.json`中将引用插件的页面添加一下配置禁止下拉刷新和禁止页面滑动,防止出现性能或页面抖动等问题。
```
{
"enablePullDownRefresh": false,
"disableScroll": true
}
```
2.建议使用本插件不要设置过大宽高的目标图片尺寸,建议1365x1365以内,否则可能会导致如下问题:
```
1.界面卡顿,内存占用过高
2.生成图片失真(模糊)
3.确定裁剪后一直显示 `裁剪中...`,该问题是由 `uni.canvasToTempFilePath` 无法回调导致,不同平台不同设备限制可能有所不同。
```
3.如裁剪后的图片存在偏移的问题,请检查是否受自己项目中父组件或全局样式影响。
4.src属性设置网络图片时,图片资源必须是能触发 `getImageInfo` API 的 success 回调才可用于插件裁剪。因此小程序平台获取网络图片信息需先配置download域名白名单才能生效。
\ No newline at end of file
Styling with Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!