87d7b854 by 华明祺

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

- 协议勾选区域固定在页面底部,优化用户体验
- 联系方式改为非必填,但填写时验证手机号格式
- 支付逻辑使用 await-to-js 重构,统一错误处理
- 支付流程添加 loading 状态,防止重复提交
- 支付成功后传递 orderId,跳转时获取并展示订单详情
- 支付成功页面优化:标签不换行、值支持自动换行
- 新增获取订单详情接口 /common/order/{orderId}
1 parent 7d54d38d
...@@ -195,11 +195,11 @@ export function regionsList(params) { ...@@ -195,11 +195,11 @@ export function regionsList(params) {
195 export function carUrl(data, type) { 195 export function carUrl(data, type) {
196 return uni.uploadFile({ 196 return uni.uploadFile({
197 url: `${config.baseUrl_api}/person/info/getPersonInfoFromCert/${type}`, 197 url: `${config.baseUrl_api}/person/info/getPersonInfoFromCert/${type}`,
198 header: { 198 // header: {
199 'Authorization': uni.getStorageSync('token'), 199 // 'Authorization': uni.getStorageSync('token'),
200 'Content-Language': 'zh_CN', 200 // 'Content-Language': 'zh_CN',
201 'Accept-Language': 'zh-CN,zh', 201 // 'Accept-Language': 'zh-CN,zh',
202 }, 202 // },
203 name: 'pic', 203 name: 'pic',
204 filePath: data 204 filePath: data
205 }).then(res => { 205 }).then(res => {
...@@ -1415,3 +1415,11 @@ export function createMemberPayRange(data) { ...@@ -1415,3 +1415,11 @@ export function createMemberPayRange(data) {
1415 data 1415 data
1416 }) 1416 })
1417 } 1417 }
1418
1419 // 获取订单详情
1420 export function getOrderInfo(orderId) {
1421 return request({
1422 url: `/common/order/${orderId}`,
1423 method: 'get'
1424 })
1425 }
...\ No newline at end of file ...\ No newline at end of file
......
1 { 1 {
2 "dependencies": { 2 "dependencies": {
3 "await-to-js": "^3.0.0",
3 "crypto-js": "^4.1.1", 4 "crypto-js": "^4.1.1",
4 "dayjs": "^1.11.6", 5 "dayjs": "^1.11.6",
5 "lodash": "^4.17.21", 6 "lodash": "^4.17.21",
......
...@@ -53,7 +53,7 @@ ...@@ -53,7 +53,7 @@
53 </uni-forms-item> 53 </uni-forms-item>
54 54
55 55
56 <uni-forms-item label="所在地区"> 56 <!-- <uni-forms-item label="所在地区">
57 <uni-data-picker class="fixUniFormItemStyle" v-model="baseFormData.cityId" 57 <uni-data-picker class="fixUniFormItemStyle" v-model="baseFormData.cityId"
58 :localdata="regionsList" popup-title="请选择所在地区"></uni-data-picker> 58 :localdata="regionsList" popup-title="请选择所在地区"></uni-data-picker>
59 </uni-forms-item> 59 </uni-forms-item>
...@@ -65,10 +65,12 @@ ...@@ -65,10 +65,12 @@
65 <uni-file-picker v-model="photoArr" @delete="delPhoto" return-type="object" limit="1" 65 <uni-file-picker v-model="photoArr" @delete="delPhoto" return-type="object" limit="1"
66 @select="upPhoto" :del-ico="false" :image-styles="imageStylesTx"></uni-file-picker> 66 @select="upPhoto" :del-ico="false" :image-styles="imageStylesTx"></uni-file-picker>
67 <image mode="aspectFill" v-if="baseFormData.photo2" style="height:200rpx;width:200rpx;" :src="config.baseUrl_api + baseFormData.photo2"/> 67 <image mode="aspectFill" v-if="baseFormData.photo2" style="height:200rpx;width:200rpx;" :src="config.baseUrl_api + baseFormData.photo2"/>
68 </uni-forms-item> 68 </uni-forms-item> -->
69 </view> 69 </view>
70 </uni-forms> 70 </uni-forms>
71 </view> 71 </view>
72 </view>
73 <view class="fixed-agreeline">
72 <view class="agreeline"> 74 <view class="agreeline">
73 <image @click="changeAgree(agree)" v-if="agree" 75 <image @click="changeAgree(agree)" v-if="agree"
74 :src="config.baseUrl_api+'/fs/static/login/xz_dwn@2x.png'"></image> 76 :src="config.baseUrl_api+'/fs/static/login/xz_dwn@2x.png'"></image>
...@@ -80,7 +82,8 @@ ...@@ -80,7 +82,8 @@
80 <view class="fixedBottom"><button class="btn-red" @click="goSubmit">确 定</button></view> 82 <view class="fixedBottom"><button class="btn-red" @click="goSubmit">确 定</button></view>
81 83
82 <!-- 会员须知 --> 84 <!-- 会员须知 -->
83 <uni-popup ref="popup" type="bottom" background-color="#fff" animation :disable-scroll="true" :mask-click="false"> 85 <uni-popup ref="popup" type="bottom" background-color="#fff" animation :disable-scroll="true"
86 :mask-click="false">
84 <view class="tt">入会须知</view> 87 <view class="tt">入会须知</view>
85 <view class="popBody"> 88 <view class="popBody">
86 _{{baseFormData.name}}_欢迎您申请成为中国跆拳道协会(以下简称中国跆协)会员,请确保本次申请是经过您本人或监护人授权同意后的自愿行为,请您务必仔细阅读本入会须知。 89 _{{baseFormData.name}}_欢迎您申请成为中国跆拳道协会(以下简称中国跆协)会员,请确保本次申请是经过您本人或监护人授权同意后的自愿行为,请您务必仔细阅读本入会须知。
...@@ -150,13 +153,15 @@ ...@@ -150,13 +153,15 @@
150 value: '1', 153 value: '1',
151 text: "来往大陆(内地)通行证" 154 text: "来往大陆(内地)通行证"
152 }, 155 },
156 // {
157 // value: '3',
158 // text: "护照"
159 // },
153 { 160 {
154 value: '3',
155 text: "护照"
156 }, {
157 value: '4', 161 value: '4',
158 text: '户口本' 162 text: '户口本'
159 }, { 163 },
164 {
160 value: '5', 165 value: '5',
161 text: '香港身份证' 166 text: '香港身份证'
162 } 167 }
...@@ -200,7 +205,7 @@ ...@@ -200,7 +205,7 @@
200 } 205 }
201 } 206 }
202 // console.log(current.value,option.tab) 207 // console.log(current.value,option.tab)
203 getRegionsList() 208 // getRegionsList()
204 }) 209 })
205 210
206 function getRegionsList() { 211 function getRegionsList() {
...@@ -246,33 +251,26 @@ ...@@ -246,33 +251,26 @@
246 title: '加载中' 251 title: '加载中'
247 }); 252 });
248 baseFormData.value.card = e.tempFiles; 253 baseFormData.value.card = e.tempFiles;
254
249 // console.log(e) 255 // console.log(e)
250 // const formData = new FormData() 256 // const formData = new FormData()
251 // formData.append('pic', e.tempFiles[0].file) 257 // formData.append('pic', e.tempFiles[0].file)
252 api.carUrl(e.tempFilePaths[0], baseFormData.value.idcType).then(res => { 258 api.carUrl(e.tempFilePaths[0], baseFormData.value.idcType).then(res => {
253 console.log(res) 259 uni.hideLoading()
260
254 if (res.data) { 261 if (res.data) {
255 baseFormData.value.sex = res.data.sex 262 baseFormData.value.sex = res.data.sex
256 baseFormData.value.birth = res.data.birth 263 baseFormData.value.birth = res.data.birth
257 baseFormData.value.idcCode = res.data.code 264 baseFormData.value.idcCode = res.data.code
258 baseFormData.value.name = res.data.name 265 baseFormData.value.name = res.data.name
259 baseFormData.value.uuid = res.data.uuid 266 baseFormData.value.uuid = res.data.uuid
260 baseFormData.value.cityId = res.data.cityId 267 // baseFormData.value.cityId = res.data.cityId
261 baseFormData.value.address = res.data.address 268 // baseFormData.value.address = res.data.address
262 photoArr.value = {}
263 getExtractInfo({
264 idcCode: baseFormData.value.idcCode,
265 idcType: baseFormData.value.idcType,
266 perType: baseFormData.value.perType
267 })
268
269 } else { 269 } else {
270 uni.hideLoading() 270 uni.showToast({
271 uni.showModal({ 271 title: res.msg,
272 content: res.msg, 272 duration: 2000,
273 success: function(modalRes) { 273 icon: 'none'
274
275 }
276 }) 274 })
277 } 275 }
278 276
...@@ -297,7 +295,7 @@ ...@@ -297,7 +295,7 @@
297 baseFormData.value.photo = data.data.fang; 295 baseFormData.value.photo = data.data.fang;
298 baseFormData.value.photo2 = data.data.yuan; 296 baseFormData.value.photo2 = data.data.yuan;
299 photoArr.value = { 297 photoArr.value = {
300 url: config.baseUrl_api+baseFormData.value.photo, 298 url: config.baseUrl_api + baseFormData.value.photo,
301 name: '头像', 299 name: '头像',
302 extname: 'jpg' 300 extname: 'jpg'
303 } 301 }
...@@ -352,8 +350,8 @@ ...@@ -352,8 +350,8 @@
352 baseFormData.value.birth = res.data.birth 350 baseFormData.value.birth = res.data.birth
353 baseFormData.value.name = res.data.name 351 baseFormData.value.name = res.data.name
354 baseFormData.value.phone = res.data.phone 352 baseFormData.value.phone = res.data.phone
355 baseFormData.value.cityId = res.data.cityId 353 // baseFormData.value.cityId = res.data.cityId
356 baseFormData.value.address = res.data.address 354 // baseFormData.value.address = res.data.address
357 if (res.data.photo) { 355 if (res.data.photo) {
358 console.log(res.data.photo) 356 console.log(res.data.photo)
359 if (res.data.photo.indexOf('http') == -1) { 357 if (res.data.photo.indexOf('http') == -1) {
...@@ -398,6 +396,10 @@ ...@@ -398,6 +396,10 @@
398 396
399 397
400 function giveBirthDay() { 398 function giveBirthDay() {
399 if (!baseFormData.value.idcCode) {
400 return
401 }
402
401 // 判断身份证正确性/赋值生日 403 // 判断身份证正确性/赋值生日
402 if (baseFormData.value.idcType == 0) { 404 if (baseFormData.value.idcType == 0) {
403 if (!(/(^\d{15}$)|(^\d{17}([0-9]|X)$)/.test(baseFormData.value.idcCode))) { 405 if (!(/(^\d{15}$)|(^\d{17}([0-9]|X)$)/.test(baseFormData.value.idcCode))) {
...@@ -480,21 +482,33 @@ ...@@ -480,21 +482,33 @@
480 }) 482 })
481 return 483 return
482 } 484 }
483 console.log(baseFormData.value.photo) 485
484 if (baseFormData.value.photo == '' || baseFormData.value.photo == undefined || !baseFormData.value.photo) { 486 if (baseFormData.value.phone) {
487 const phoneReg = /^1[3-9]\d{9}$/
488 if (!phoneReg.test(baseFormData.value.phone)) {
485 uni.showToast({ 489 uni.showToast({
486 title: `请上传头像`, 490 title: '请输入正确的联系方式',
487 icon: 'none' 491 icon: 'none'
488 }) 492 })
489 return 493 return
490 } 494 }
495 }
496
497 // if (baseFormData.value.photo == '' || baseFormData.value.photo == undefined || !baseFormData.value.photo) {
498 // uni.showToast({
499 // title: `请上传头像`,
500 // icon: 'none'
501 // })
502 // return
503 // }
504
491 //信息确认弹出 505 //信息确认弹出
492 uni.showModal({ 506 uni.showModal({
493 content: '请确认信息正确', 507 content: '请确认信息正确',
494 success: function(res) { 508 success: function(res) {
495 if (res.confirm) { 509 if (res.confirm) {
496 if(baseFormData.value.idcType=='4'){ 510 if (baseFormData.value.idcType == '4') {
497 baseFormData.value.idcType='0' 511 baseFormData.value.idcType = '0'
498 } 512 }
499 delete baseFormData.value.card 513 delete baseFormData.value.card
500 514
...@@ -547,18 +561,17 @@ ...@@ -547,18 +561,17 @@
547 } 561 }
548 }); 562 });
549 } 563 }
564
550 function getUserInfo() { 565 function getUserInfo() {
551 api.getInfo(perId.value).then(res => { 566 api.getInfo(perId.value).then(res => {
552 baseFormData.value = res.data 567 baseFormData.value = res.data
553 if (baseFormData.areaAssName) baseFormData.ancestorNameList = baseFormData.value.ancestorNameList.join( 568 if (baseFormData.areaAssName) baseFormData.ancestorNameList = baseFormData.value.ancestorNameList.join(
554 ',').replaceAll(',', 569 ',').replaceAll(',', '/')
555 '/')
556 }) 570 })
557 } 571 }
558 </script> 572 </script>
559 573
560 <style lang="scss"> 574 <style lang="scss">
561
562 /* 字段名左对齐 */ 575 /* 字段名左对齐 */
563 .uni-forms-item .uni-forms-item__label { 576 .uni-forms-item .uni-forms-item__label {
564 text-align: left !important; 577 text-align: left !important;
...@@ -587,11 +600,10 @@ ...@@ -587,11 +600,10 @@
587 600
588 /* 文本内容右对齐 */ 601 /* 文本内容右对齐 */
589 .uni-forms-item .uni-forms-item__content text, 602 .uni-forms-item .uni-forms-item__content text,
590 .uni-forms-item .uni-forms-item__content > text { 603 .uni-forms-item .uni-forms-item__content>text {
591 display: inline-block !important; 604 display: inline-block !important;
592 white-space: nowrap !important; 605 white-space: nowrap !important;
593 } 606 }
594
595 </style> 607 </style>
596 608
597 <style lang="scss" scoped> 609 <style lang="scss" scoped>
...@@ -603,9 +615,11 @@ ...@@ -603,9 +615,11 @@
603 right: 0; 615 right: 0;
604 bottom: 0; 616 bottom: 0;
605 } 617 }
618
606 :deep(.uni-popup) { 619 :deep(.uni-popup) {
607 overflow: hidden !important; 620 overflow: hidden !important;
608 } 621 }
622
609 :deep(.segmented-control) { 623 :deep(.segmented-control) {
610 height: 100rpx; 624 height: 100rpx;
611 } 625 }
...@@ -636,13 +650,24 @@ ...@@ -636,13 +650,24 @@
636 } 650 }
637 } 651 }
638 652
653 .hasfixedbottom {
654 padding-bottom: 200rpx;
655 }
656
657 .fixed-agreeline {
658 position: fixed;
659 bottom: 150rpx;
660 left: 0;
661 right: 0;
662 z-index: 1;
663 }
664
639 .agreeline { 665 .agreeline {
640 padding: 20rpx 40rpx; 666 padding: 20rpx 40rpx;
641 box-sizing: border-box; 667 box-sizing: border-box;
642 display: flex; 668 display: flex;
643 font-size: 30rpx; 669 font-size: 30rpx;
644 670
645
646 text { 671 text {
647 color: #014A9F; 672 color: #014A9F;
648 } 673 }
...@@ -681,6 +706,7 @@ ...@@ -681,6 +706,7 @@
681 :deep(.item-text-overflow) { 706 :deep(.item-text-overflow) {
682 text-align: left; 707 text-align: left;
683 } 708 }
709
684 :deep(.fixUniFormItemStyle .uni-data-picker__input-box) { 710 :deep(.fixUniFormItemStyle .uni-data-picker__input-box) {
685 justify-content: flex-start !important; 711 justify-content: flex-start !important;
686 text-align: left !important; 712 text-align: left !important;
......
...@@ -5,11 +5,13 @@ ...@@ -5,11 +5,13 @@
5 <view class="yearRow"> 5 <view class="yearRow">
6 <view class="label">缴费年限</view> 6 <view class="label">缴费年限</view>
7 <view class="control"> 7 <view class="control">
8 <image class="icon" @click="minusYear" src="/static/dd_02.png" mode="widthFix" v-if="form.payYear > 1" ></image> 8 <image class="icon" @click="minusYear" src="/static/dd_02.png" mode="widthFix"
9 <image class="icon" src="/static/dd_02_g.png" mode="widthFix" v-else ></image> 9 v-if="form.payYear > 1"></image>
10 <image class="icon" src="/static/dd_02_g.png" mode="widthFix" v-else></image>
10 <text class="num">{{ form.payYear }}</text> 11 <text class="num">{{ form.payYear }}</text>
11 <image class="icon" src="/static/btn_03.png" mode="widthFix" @click="plusYear" v-if="form.payYear < 5" ></image> 12 <image class="icon" src="/static/btn_03.png" mode="widthFix" @click="plusYear"
12 <image class="icon" src="/static/btn_03_g.png" mode="widthFix" v-else ></image> 13 v-if="form.payYear < 5"></image>
14 <image class="icon" src="/static/btn_03_g.png" mode="widthFix" v-else></image>
13 </view> 15 </view>
14 </view> 16 </view>
15 </view> 17 </view>
...@@ -49,25 +51,32 @@ ...@@ -49,25 +51,32 @@
49 </template> 51 </template>
50 52
51 <script setup> 53 <script setup>
52 import { ref, computed, onMounted } from 'vue' 54 import {
53 import { onLoad } from '@dcloudio/uni-app'; 55 ref,
54 import * as api from '@/common/api.js' 56 computed,
57 onMounted
58 } from 'vue'
59 import {
60 onLoad
61 } from '@dcloudio/uni-app';
62 import to from 'await-to-js'
63 import * as api from '@/common/api.js'
55 64
56 const form = ref({ 65 const form = ref({
57 payYear: 1 66 payYear: 1
58 }) 67 })
59 68
60 // 支付方式 69 // 支付方式
61 const payType = ref('1') 70 const payType = ref('1')
62 const isPaying = ref(false) 71 const isPaying = ref(false)
63 72
64 // 费用与优惠 73 // 费用与优惠
65 const memberFee = ref(0) 74 const memberFee = ref(0)
66 const memberTotalFee = computed(() => { 75 const memberTotalFee = computed(() => {
67 return memberFee.value * form.value.payYear 76 return memberFee.value * form.value.payYear
68 77
69 }) 78 })
70 onLoad((options) => { 79 onLoad((options) => {
71 if (options.baseFormData) { 80 if (options.baseFormData) {
72 const data = JSON.parse(decodeURIComponent(options.baseFormData)) 81 const data = JSON.parse(decodeURIComponent(options.baseFormData))
73 form.value = { 82 form.value = {
...@@ -77,37 +86,45 @@ onLoad((options) => { ...@@ -77,37 +86,45 @@ onLoad((options) => {
77 } 86 }
78 // 初始化接口 87 // 初始化接口
79 getMyMemberCertUnitFeeApi() 88 getMyMemberCertUnitFeeApi()
80 }) 89 })
81 90
82 91
83 92
84 // 减年限 93 // 减年限
85 const minusYear = () => { 94 const minusYear = () => {
86 if (form.value.payYear > 1) { 95 if (form.value.payYear > 1) {
87 form.value.payYear-- 96 form.value.payYear--
88 } 97 }
89 } 98 }
90 99
91 // 加年限(最大 5 年) 100 // 加年限(最大 5 年)
92 const plusYear = () => { 101 const plusYear = () => {
93 if (form.value.payYear < 5) { 102 if (form.value.payYear < 5) {
94 form.value.payYear++ 103 form.value.payYear++
95 } 104 }
96 } 105 }
97 106
98 // 支付方式切换 107 // 支付方式切换
99 const onPayTypeChange = (e) => { 108 const onPayTypeChange = (e) => {
100 payType.value = e.detail.value 109 payType.value = e.detail.value
101 } 110 }
102 111
103 const handelPay = async () => { 112 const handelPay = async () => {
104 if (memberTotalFee.value <= 0) { 113 if (memberTotalFee.value <= 0) {
105 uni.showToast({ title: '支付金额异常', icon: 'none' }) 114 uni.showToast({
115 title: '支付金额异常',
116 icon: 'none'
117 })
106 return 118 return
107 } 119 }
108 120
121 // 显示 loading
122 uni.showLoading({
123 title: '支付中...',
124 mask: true
125 })
109 isPaying.value = true 126 isPaying.value = true
110 try { 127
111 // 拼接完整参数 128 // 拼接完整参数
112 const postData = { 129 const postData = {
113 ...form.value, 130 ...form.value,
...@@ -116,100 +133,126 @@ const handelPay = async () => { ...@@ -116,100 +133,126 @@ const handelPay = async () => {
116 totalFee: memberTotalFee.value 133 totalFee: memberTotalFee.value
117 } 134 }
118 135
119 const res = await api.insertSinglePay(postData) 136 // 创建订单
120 console.log(777,res) 137 const [orderErr, orderRes] = await to(api.insertSinglePay(postData))
121 if (res.data?.orderId) { 138 if (orderErr) {
122 api.pcallBack2(res.data.orderId) 139 uni.hideLoading()
123 uni.navigateTo({ 140 isPaying.value = false
124 url: `/personal/sucPay` 141 uni.showToast({
142 title: '创建订单失败',
143 icon: 'none'
125 }) 144 })
145 return
146 }
147
148 if (!orderRes.data?.orderId) {
149 uni.hideLoading()
150 isPaying.value = false
151 uni.showToast({
152 title: '订单创建异常',
153 icon: 'none'
154 })
155 return
126 } 156 }
127 // if (data.payFlag == 0 || data.orderId) { 157
128 // data.orderId && api.callBack2(data.orderId) 158 // 等待支付回调
129 // uni.navigateTo({ url: `/personal/submitPay?price=${res.data.price}` }) 159 await to(api.pcallBack2(orderRes.data.orderId))
130 // } 160 uni.hideLoading()
131 } catch (err) {
132 uni.showToast({ title: '支付失败', icon: 'none' })
133 } finally {
134 isPaying.value = false 161 isPaying.value = false
162
163 // 支付成功,跳转页面
164 uni.navigateTo({
165 url: `/personal/sucPay?orderId=${orderRes.data.orderId}`
166 })
135 } 167 }
136 }
137 168
138 169
139 170
140 // 获取会员费 171 // 获取会员费
141 async function getMyMemberCertUnitFeeApi() { 172 async function getMyMemberCertUnitFeeApi() {
142 const res = await api.getZtxFeeConfig() 173 const res = await api.getZtxFeeConfig()
143 memberFee.value = Number(res.data.personMemberFee || 1500) 174 memberFee.value = Number(res.data.personMemberFee || 1500)
144 } 175 }
145
146 </script> 176 </script>
147 177
148 <style scoped> 178 <style scoped>
149 .container { 179 .container {
150 min-height: 100vh; 180 min-height: 100vh;
151 background-color: #f7f7f7; 181 background-color: #f7f7f7;
152 } 182 }
153 .content { 183
184 .content {
154 padding: 20rpx 20rpx 120rpx; 185 padding: 20rpx 20rpx 120rpx;
155 } 186 }
156 .card { 187
188 .card {
157 background: #fff; 189 background: #fff;
158 border-radius: 8rpx; 190 border-radius: 8rpx;
159 padding: 25rpx 20rpx; 191 padding: 25rpx 20rpx;
160 margin-bottom: 20rpx; 192 margin-bottom: 20rpx;
161 } 193 }
162 .yearRow { 194
195 .yearRow {
163 display: flex; 196 display: flex;
164 align-items: center; 197 align-items: center;
165 justify-content: space-between; 198 justify-content: space-between;
166 margin-bottom: 20rpx; 199 margin-bottom: 20rpx;
167 } 200 }
168 .yearRow .label { 201
202 .yearRow .label {
169 font-size: 28rpx; 203 font-size: 28rpx;
170 color: #333; 204 color: #333;
171 } 205 }
172 .yearRow .control { 206
207 .yearRow .control {
173 display: flex; 208 display: flex;
174 align-items: center; 209 align-items: center;
175 } 210 }
176 .control image { 211
212 .control image {
177 width: 50rpx; 213 width: 50rpx;
178 height: 50rpx; 214 height: 50rpx;
179 } 215 }
180 .yearRow .num { 216
217 .yearRow .num {
181 font-size: 28rpx; 218 font-size: 28rpx;
182 color: #333; 219 color: #333;
183 min-width: 80rpx; 220 min-width: 80rpx;
184 text-align: center; 221 text-align: center;
185 margin: 0 10rpx; 222 margin: 0 10rpx;
186 } 223 }
187 .row { 224
225 .row {
188 display: flex; 226 display: flex;
189 justify-content: space-between; 227 justify-content: space-between;
190 align-items: center; 228 align-items: center;
191 } 229 }
192 .row .label { 230
231 .row .label {
193 font-size: 28rpx; 232 font-size: 28rpx;
194 color: #333; 233 color: #333;
195 } 234 }
196 .row .value { 235
236 .row .value {
197 font-size: 30rpx; 237 font-size: 30rpx;
198 color: #C4121B; 238 color: #C4121B;
199 font-weight: 500; 239 font-weight: 500;
200 } 240 }
201 .hintRow { 241
242 .hintRow {
202 display: flex; 243 display: flex;
203 align-items: flex-start; 244 align-items: flex-start;
204 font-size: 24rpx; 245 font-size: 24rpx;
205 line-height: 1.4; 246 line-height: 1.4;
206 } 247 }
207 .hintRow .hintText { 248
249 .hintRow .hintText {
208 color: #FF8124; 250 color: #FF8124;
209 flex: 1; 251 flex: 1;
210 margin-top: 10rpx; 252 margin-top: 10rpx;
211 } 253 }
212 .deductRow { 254
255 .deductRow {
213 background: #fff; 256 background: #fff;
214 padding: 20rpx 20rpx; 257 padding: 20rpx 20rpx;
215 display: flex; 258 display: flex;
...@@ -217,40 +260,48 @@ async function getMyMemberCertUnitFeeApi() { ...@@ -217,40 +260,48 @@ async function getMyMemberCertUnitFeeApi() {
217 align-items: center; 260 align-items: center;
218 margin-bottom: 10rpx; 261 margin-bottom: 10rpx;
219 border-radius: 8rpx; 262 border-radius: 8rpx;
220 } 263 }
221 .deductRow .label { 264
265 .deductRow .label {
222 font-size: 28rpx; 266 font-size: 28rpx;
223 color: #333; 267 color: #333;
224 } 268 }
225 .deductRow .value { 269
270 .deductRow .value {
226 font-size: 30rpx; 271 font-size: 30rpx;
227 color: #C4121B; 272 color: #C4121B;
228 } 273 }
229 .payRow { 274
275 .payRow {
230 background: #fff; 276 background: #fff;
231 border-radius: 8rpx; 277 border-radius: 8rpx;
232 padding: 20rpx 20rpx; 278 padding: 20rpx 20rpx;
233 margin-bottom: 20rpx; 279 margin-bottom: 20rpx;
234 } 280 }
235 .radioItem { 281
282 .radioItem {
236 display: flex; 283 display: flex;
237 align-items: center; 284 align-items: center;
238 } 285 }
239 .payInfo { 286
287 .payInfo {
240 display: flex; 288 display: flex;
241 align-items: center; 289 align-items: center;
242 margin-left: 15rpx; 290 margin-left: 15rpx;
243 } 291 }
244 .payInfo .icon { 292
293 .payInfo .icon {
245 width: 40rpx; 294 width: 40rpx;
246 height: 40rpx; 295 height: 40rpx;
247 margin-right: 10rpx; 296 margin-right: 10rpx;
248 } 297 }
249 .payInfo text { 298
299 .payInfo text {
250 font-size: 28rpx; 300 font-size: 28rpx;
251 color: #333; 301 color: #333;
252 } 302 }
253 .totalRow { 303
304 .totalRow {
254 background: #fff; 305 background: #fff;
255 border-radius: 8rpx; 306 border-radius: 8rpx;
256 padding: 20rpx 20rpx; 307 padding: 20rpx 20rpx;
...@@ -258,17 +309,20 @@ async function getMyMemberCertUnitFeeApi() { ...@@ -258,17 +309,20 @@ async function getMyMemberCertUnitFeeApi() {
258 justify-content: space-between; 309 justify-content: space-between;
259 align-items: center; 310 align-items: center;
260 margin-top: 10rpx; 311 margin-top: 10rpx;
261 } 312 }
262 .totalRow .label { 313
314 .totalRow .label {
263 font-size: 28rpx; 315 font-size: 28rpx;
264 color: #333; 316 color: #333;
265 } 317 }
266 .redBig { 318
319 .redBig {
267 font-size: 32rpx; 320 font-size: 32rpx;
268 color: #C4121B; 321 color: #C4121B;
269 font-weight: bold; 322 font-weight: bold;
270 } 323 }
271 .bottomBtn { 324
325 .bottomBtn {
272 position: fixed; 326 position: fixed;
273 bottom: 0; 327 bottom: 0;
274 left: 0; 328 left: 0;
...@@ -276,8 +330,9 @@ async function getMyMemberCertUnitFeeApi() { ...@@ -276,8 +330,9 @@ async function getMyMemberCertUnitFeeApi() {
276 padding: 20rpx 20rpx; 330 padding: 20rpx 20rpx;
277 background: #fff; 331 background: #fff;
278 border-top: 1rpx solid #eee; 332 border-top: 1rpx solid #eee;
279 } 333 }
280 .payBtn { 334
335 .payBtn {
281 width: 100%; 336 width: 100%;
282 height: 88rpx; 337 height: 88rpx;
283 line-height: 88rpx; 338 line-height: 88rpx;
...@@ -287,25 +342,30 @@ async function getMyMemberCertUnitFeeApi() { ...@@ -287,25 +342,30 @@ async function getMyMemberCertUnitFeeApi() {
287 font-size: 32rpx; 342 font-size: 32rpx;
288 text-align: center; 343 text-align: center;
289 border: none; 344 border: none;
290 } 345 }
291 .payBtn[disabled] { 346
347 .payBtn[disabled] {
292 background-color: #ccc; 348 background-color: #ccc;
293 color: #999; 349 color: #999;
294 } 350 }
295 .red { 351
352 .red {
296 color: #C4121B; 353 color: #C4121B;
297 } 354 }
298 .icon{ 355
299 width:30px; 356 .icon {
300 } 357 width: 30px;
301 ::v-deep .custom-radio .wx-radio-input { 358 }
359
360 ::v-deep .custom-radio .wx-radio-input {
302 width: 30rpx; 361 width: 30rpx;
303 height: 30rpx; 362 height: 30rpx;
304 border-radius: 50%; 363 border-radius: 50%;
305 border: 2rpx solid #ccc; 364 border: 2rpx solid #ccc;
306 } 365 }
307 ::v-deep .custom-radio .wx-radio-input.wx-radio-input-checked { 366
367 ::v-deep .custom-radio .wx-radio-input.wx-radio-input-checked {
308 border-color: #C4121B !important; 368 border-color: #C4121B !important;
309 background: #C4121B !important; 369 background: #C4121B !important;
310 } 370 }
311 </style> 371 </style>
...\ No newline at end of file ...\ No newline at end of file
......
...@@ -14,28 +14,16 @@ ...@@ -14,28 +14,16 @@
14 <!-- 订单信息卡片(带阴影) --> 14 <!-- 订单信息卡片(带阴影) -->
15 <view class="info-card"> 15 <view class="info-card">
16 <view class="info-item"> 16 <view class="info-item">
17 <text class="label">付款账户</text>
18 <text class="value">(5437)</text>
19 </view>
20 <view class="info-item">
21 <text class="label">交易流水号</text> 17 <text class="label">交易流水号</text>
22 <text class="value">2205051351076117833</text> 18 <text class="value">{{ orderInfo.tradeNo }}</text>
23 </view> 19 </view>
24 <view class="info-item"> 20 <view class="info-item">
25 <text class="label">商户名称</text> 21 <text class="label">商户名称</text>
26 <text class="value">中国跆拳道协会</text> 22 <text class="value">{{ orderInfo.merchantName || '中国跆拳道协会' }}</text>
27 </view> 23 </view>
28 <view class="info-item"> 24 <view class="info-item">
29 <text class="label">订单金额</text> 25 <text class="label">订单金额</text>
30 <text class="value amount">1500.00元</text> 26 <text class="value amount">{{ orderInfo.price ? orderInfo.price + '元' : '--' }}</text>
31 </view>
32 <view class="info-item">
33 <text class="label">会员编号</text>
34 <text class="value">CTA00004</text>
35 </view>
36 <view class="info-item">
37 <text class="label">会员有效期</text>
38 <text class="value">2028年1月25日</text>
39 </view> 27 </view>
40 </view> 28 </view>
41 29
...@@ -47,20 +35,43 @@ ...@@ -47,20 +35,43 @@
47 </template> 35 </template>
48 36
49 <script setup> 37 <script setup>
50 import { onLoad } from '@dcloudio/uni-app' 38 import {
51 const goBack = () => { 39 ref
52 uni.navigateTo({ 40 } from 'vue'
53 url: `/personal/home` 41 import {
42 onLoad
43 } from '@dcloudio/uni-app'
44 import to from 'await-to-js'
45 import * as api from '@/common/api.js'
46
47 const orderInfo = ref({
48 id: '',
49 tradeNo: '',
50 merchantName: '中国跆拳道协会',
51 price: ''
54 }) 52 })
55 }
56 53
57 onLoad((option) => { 54 const goBack = () => {
58 }) 55 uni.reLaunch({
56 url: '/login/login'
57 })
58 }
59
60 onLoad(async (option) => {
61 if (option.orderId) {
62 const [err, res] = await to(api.getOrderInfo(option.orderId))
63 if (!err && res.data) {
64 orderInfo.value = res.data
65 } else {
66 orderInfo.value.id = option.orderId
67 }
68 }
69 })
59 </script> 70 </script>
60 71
61 <style scoped> 72 <style scoped>
62 /* 全局容器 */ 73 /* 全局容器 */
63 .success-container { 74 .success-container {
64 display: flex; 75 display: flex;
65 flex-direction: column; 76 flex-direction: column;
66 align-items: center; 77 align-items: center;
...@@ -68,16 +79,16 @@ onLoad((option) => { ...@@ -68,16 +79,16 @@ onLoad((option) => {
68 min-height: 100vh; 79 min-height: 100vh;
69 background-color: #f8f9fa; 80 background-color: #f8f9fa;
70 box-sizing: border-box; 81 box-sizing: border-box;
71 } 82 }
72 83
73 /* 成功图标容器 */ 84 /* 成功图标容器 */
74 .success-icon { 85 .success-icon {
75 margin-bottom: 40rpx; 86 margin-bottom: 40rpx;
76 animation: fadeIn 0.6s ease-out; 87 animation: fadeIn 0.6s ease-out;
77 } 88 }
78 89
79 /* 渐变圆形背景 */ 90 /* 渐变圆形背景 */
80 .icon-circle { 91 .icon-circle {
81 width: 180rpx; 92 width: 180rpx;
82 height: 180rpx; 93 height: 180rpx;
83 border-radius: 50%; 94 border-radius: 50%;
...@@ -89,34 +100,34 @@ onLoad((option) => { ...@@ -89,34 +100,34 @@ onLoad((option) => {
89 box-shadow: 0 8rpx 30rpx rgba(6, 193, 174, 0.3); 100 box-shadow: 0 8rpx 30rpx rgba(6, 193, 174, 0.3);
90 /* 轻微上浮动效 */ 101 /* 轻微上浮动效 */
91 animation: scaleIn 0.8s ease-out; 102 animation: scaleIn 0.8s ease-out;
92 } 103 }
93 104
94 /* 对勾图标 */ 105 /* 对勾图标 */
95 .check-icon { 106 .check-icon {
96 font-size: 90rpx; 107 font-size: 90rpx;
97 color: #ffffff; 108 color: #ffffff;
98 font-weight: bold; 109 font-weight: bold;
99 } 110 }
100 111
101 /* 支付成功标题 */ 112 /* 支付成功标题 */
102 .success-title { 113 .success-title {
103 font-size: 48rpx; 114 font-size: 48rpx;
104 font-weight: 700; 115 font-weight: 700;
105 color: #333333; 116 color: #333333;
106 margin-bottom: 12rpx; 117 margin-bottom: 12rpx;
107 animation: slideUp 0.6s ease-out; 118 animation: slideUp 0.6s ease-out;
108 } 119 }
109 120
110 /* 副标题 */ 121 /* 副标题 */
111 .success-subtitle { 122 .success-subtitle {
112 font-size: 28rpx; 123 font-size: 28rpx;
113 color: #666666; 124 color: #666666;
114 margin-bottom: 60rpx; 125 margin-bottom: 60rpx;
115 animation: slideUp 0.8s ease-out; 126 animation: slideUp 0.8s ease-out;
116 } 127 }
117 128
118 /* 订单信息卡片 */ 129 /* 订单信息卡片 */
119 .info-card { 130 .info-card {
120 width: 100%; 131 width: 100%;
121 background: #ffffff; 132 background: #ffffff;
122 border-radius: 20rpx; 133 border-radius: 20rpx;
...@@ -124,48 +135,55 @@ onLoad((option) => { ...@@ -124,48 +135,55 @@ onLoad((option) => {
124 box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05); 135 box-shadow: 0 6rpx 20rpx rgba(0, 0, 0, 0.05);
125 margin-bottom: 80rpx; 136 margin-bottom: 80rpx;
126 animation: fadeIn 1s ease-out; 137 animation: fadeIn 1s ease-out;
127 } 138 }
128 139
129 /* 单个信息项 */ 140 /* 单个信息项 */
130 .info-item { 141 .info-item {
131 display: flex; 142 display: flex;
132 justify-content: space-between; 143 justify-content: space-between;
133 align-items: center; 144 align-items: center;
134 padding: 24rpx 0; 145 padding: 24rpx 0;
135 border-bottom: 1rpx solid #f5f5f5; 146 border-bottom: 1rpx solid #f5f5f5;
136 } 147 }
137 /* 最后一项去掉下划线 */ 148
138 .info-item:last-child { 149 /* 最后一项去掉下划线 */
150 .info-item:last-child {
139 border-bottom: none; 151 border-bottom: none;
140 } 152 }
141 153
142 /* 标签样式 */ 154 /* 标签样式 */
143 .label { 155 .label {
144 font-size: 32rpx; 156 font-size: 32rpx;
145 color: #666666; 157 color: #666666;
146 } 158 white-space: nowrap;
159 margin-right: 20rpx;
160 flex-shrink: 0;
161 }
147 162
148 /* 值样式 */ 163 /* 值样式 */
149 .value { 164 .value {
150 font-size: 32rpx; 165 font-size: 32rpx;
151 color: #333333; 166 color: #333333;
152 text-align: right; 167 text-align: right;
153 } 168 word-break: break-all;
154 /* 金额特殊样式 */ 169 word-wrap: break-word;
155 .amount { 170 }
171
172 /* 金额特殊样式 */
173 .amount {
156 color: #cd1e27; 174 color: #cd1e27;
157 font-weight: 600; 175 font-weight: 600;
158 } 176 }
159 177
160 /* 确定按钮区域 */ 178 /* 确定按钮区域 */
161 .confirm-btn-area { 179 .confirm-btn-area {
162 width: 100%; 180 width: 100%;
163 padding: 0 20rpx; 181 padding: 0 20rpx;
164 box-sizing: border-box; 182 box-sizing: border-box;
165 } 183 }
166 184
167 /* 确定按钮(渐变+动效) */ 185 /* 确定按钮(渐变+动效) */
168 .confirm-btn { 186 .confirm-btn {
169 width: 100%; 187 width: 100%;
170 height: 90rpx; 188 height: 90rpx;
171 line-height: 90rpx; 189 line-height: 90rpx;
...@@ -180,28 +198,52 @@ onLoad((option) => { ...@@ -180,28 +198,52 @@ onLoad((option) => {
180 /* 禁止默认样式 */ 198 /* 禁止默认样式 */
181 position: relative; 199 position: relative;
182 overflow: hidden; 200 overflow: hidden;
183 } 201 }
184 /* 按钮点击反馈 */ 202
185 .confirm-btn::after { 203 /* 按钮点击反馈 */
204 .confirm-btn::after {
186 border: none; 205 border: none;
187 } 206 }
188 .confirm-btn:active { 207
208 .confirm-btn:active {
189 transform: scale(0.98); 209 transform: scale(0.98);
190 box-shadow: 0 4rpx 10rpx rgba(6, 193, 174, 0.2); 210 box-shadow: 0 4rpx 10rpx rgba(6, 193, 174, 0.2);
191 } 211 }
192 212
193 /* 动画定义 */ 213 /* 动画定义 */
194 @keyframes fadeIn { 214 @keyframes fadeIn {
195 0% { opacity: 0; } 215 0% {
196 100% { opacity: 1; } 216 opacity: 0;
197 } 217 }
198 @keyframes scaleIn { 218
199 0% { transform: scale(0); } 219 100% {
200 70% { transform: scale(1.1); } 220 opacity: 1;
201 100% { transform: scale(1); } 221 }
202 } 222 }
203 @keyframes slideUp { 223
204 0% { opacity: 0; transform: translateY(30rpx); } 224 @keyframes scaleIn {
205 100% { opacity: 1; transform: translateY(0); } 225 0% {
206 } 226 transform: scale(0);
227 }
228
229 70% {
230 transform: scale(1.1);
231 }
232
233 100% {
234 transform: scale(1);
235 }
236 }
237
238 @keyframes slideUp {
239 0% {
240 opacity: 0;
241 transform: translateY(30rpx);
242 }
243
244 100% {
245 opacity: 1;
246 transform: translateY(0);
247 }
248 }
207 </style> 249 </style>
...\ No newline at end of file ...\ No newline at end of file
......
1 ## 2.1.6(2023-04-16)
2 * 修复 组件使用 v-show 指令会导致选择图片后初始位置严重偏位的问题
3 ## 2.1.5(2023-04-16)
4 * 新增 兼容APP平台
5
6 ## 2.1.4(2023-03-13)
7 * 新增 fileType 属性,用于指定生成文件的类型,只支持 'jpg' 或 'png',默认为 'png'
8 * 新增 delay 属性,微信小程序平台使用 `Canvas 2D` 绘制时控制图片从绘制到生成所需时间
9 * 优化 当生成图片的尺寸宽/高超过 Canvas 2D 最大限制(1365*1365)则将画布尺寸缩放在限制范围内绘制完成后输出目标尺寸
10 * 优化 旋转图标指示方向与实际旋转方向不符
11
12 ## 2.1.3(2023-02-06)
13 * 优化 vue3支持
14
15 ## 2.1.2(2023-02-03)
16 * 新增 navigation 属性,H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差
17 * 修复 H5平台部分设备(已知iPhone11以下机型)拍照的图片缩放时会闪动的问题
18
19 ## 2.1.1(2022-12-06)
20 * 修复 横屏适配问题
21
22 ## 2.1.0(2022-12-06)
23 * 新增 兼容H5平台,使用 renderjs 响应手势事件
24
25 ## 2.0.0(2022-12-05)
26 * 重构 插件,使用 WXS 响应手势事件
27 * 新增 图片翻转
28 * 新增 拉伸裁剪框放大图片
29 * 新增 监听PC鼠标滚轮触发缩放
30 * 新增 圆形、圆角矩形的图片裁剪
31 * 优化 图片缩放,移动端以双指触摸中心点为缩放中心点,PC端以鼠标所在点为缩放中心点
32 * 优化 裁剪框样式
33 * 优化 图片位置拖动 支持边界回弹效果(滑动时可滑出边界,释放时回弹到边界)
34 * 优化 生成图片使用新版 Canvas 2D 接口
1 /**
2 * 图片编辑器-手势监听
3 * 1. 支持编译到app-vue(uni-app 2.5.5及以上版本)、H5上
4 */
5 /** 图片偏移量 */
6 var offset = { x: 0, y: 0 };
7 /** 图片缩放比例 */
8 var scale = 1;
9 /** 图片最小缩放比例 */
10 var minScale = 1;
11 /** 图片旋转角度 */
12 var rotate = 0;
13 /** 触摸点 */
14 var touches = [];
15 /** 图片布局信息 */
16 var img = {};
17 /** 系统信息 */
18 var sys = {};
19 /** 裁剪区域布局信息 */
20 var area = {};
21 /** 触摸行为类型 */
22 var touchType = '';
23 /** 操作角的位置 */
24 var activeAngle = 0;
25 /** 裁剪区域布局信息偏移量 */
26 var areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
27 /** 元素ID */
28 var elIds = {
29 'imageStyles': 'crop-image',
30 'maskStylesList': 'crop-mask-block',
31 'borderStyles': 'crop-border',
32 'circleBoxStyles': 'crop-circle-box',
33 'circleStyles': 'crop-circle',
34 'gridStylesList': 'crop-grid',
35 'angleStylesList': 'crop-angle',
36 }
37 /** 记录上次初始化时间戳,排除APP重复更新 */
38 var timestamp = 0;
39 /**
40 * 样式对象转字符串
41 * @param {Object} style 样式对象
42 */
43 function styleToString(style) {
44 if(typeof style === 'string') return style;
45 var str = '';
46 for (let k in style) {
47 str += k + ':' + style[k] + ';';
48 }
49 return str;
50 }
51 /**
52 *
53 * @param {Object} instance 页面实例对象
54 * @param {Object} key 要修改样式的key
55 * @param {Object|Array} style 样式
56 */
57 function setStyle(instance, key, style) {
58 // console.log('setStyle', instance, key, JSON.stringify(style))
59 // #ifdef APP-PLUS
60 if(Object.prototype.toString.call(style) === '[object Array]') {
61 for (var i = 0, len = style.length; i < len; i++) {
62 var el = window.document.getElementById(elIds[key] + '-' + (i + 1));
63 el && (el.style = styleToString(style[i]));
64 }
65 } else {
66 var el = window.document.getElementById(elIds[key]);
67 el && (el.style = styleToString(style));
68 }
69 // #endif
70 // #ifdef H5
71 instance[key] = style;
72 // #endif
73 }
74 /**
75 * 触发页面实例指定方法
76 * @param {Object} instance 页面实例对象
77 * @param {Object} name 方法名称
78 * @param {Object} obj 传递参数
79 */
80 function callMethod(instance, name, obj) {
81 // #ifdef APP-PLUS
82 instance.callMethod(name, obj);
83 // #endif
84 // #ifdef H5
85 instance[name](obj);
86 // #endif
87 }
88 /**
89 * 计算两点间距
90 * @param {Object} touches 触摸点信息
91 */
92 function getDistanceByTouches(touches) {
93 // 根据勾股定理求两点间距离
94 var a = touches[1].pageX - touches[0].pageX;
95 var b = touches[1].pageY - touches[0].pageY;
96 var c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
97 // 求两点间的中点坐标
98 // 1. a、b可能为负值
99 // 2. 在求a、b时,如用touches[1]减touches[0],则求中点坐标也得用touches[1]减a/2、b/2
100 // 3. 同理,在求a、b时,也可用touches[0]减touches[1],则求中点坐标也得用touches[0]减a/2、b/2
101 var x = touches[1].pageX - a / 2;
102 var y = touches[1].pageY - b / 2;
103 return { c, x, y };
104 };
105 /**
106 * 检查边界:限制 x、y 拖动范围,禁止滑出边界
107 * @param {Object} e 点坐标
108 */
109 function checkRange(e) {
110 var r = rotate / 90 % 2;
111 if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
112 var o = (img.height - img.width) / 2; // 宽高差值一半
113 return {
114 x: Math.min(Math.max(e.x, -img.height + o + area.width + area.left), area.left + o),
115 y: Math.min(Math.max(e.y, -img.width - o + area.height + area.top), area.top - o)
116 }
117 }
118 return {
119 x: Math.min(Math.max(e.x, -img.width + area.width + area.left), area.left),
120 y: Math.min(Math.max(e.y, -img.height + area.height + area.top), area.top)
121 }
122 };
123 /**
124 * 变更图片布局信息
125 * @param {Object} e 布局信息
126 */
127 function changeImageRect(e) {
128 // console.log('changeImageRect', e)
129 offset.x += e.x || 0;
130 offset.y += e.y || 0;
131 if(e.check) { // 检查边界
132 var point = checkRange(offset);
133 if(offset.x !== point.x || offset.y !== point.y) {
134 offset = point;
135 }
136 }
137
138 // 因频繁修改 width/height 会造成大量的内存消耗,改为scale
139 // e.instance.imageStyles = {
140 // width: img.width + 'px',
141 // height: img.height + 'px',
142 // transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + ox) + 'px) rotate(' + rotate +'deg)'
143 // };
144 var ox = (img.width - img.oldWidth) / 2;
145 var oy = (img.height - img.oldHeight) / 2;
146 // e.instance.imageStyles = {
147 // width: img.oldWidth + 'px',
148 // height: img.oldHeight + 'px',
149 // transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
150 // };
151 setStyle(e.instance, 'imageStyles', {
152 width: img.oldWidth + 'px',
153 height: img.oldHeight + 'px',
154 transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
155 });
156 callMethod(e.instance, 'dataChange', {
157 width: img.width,
158 height: img.height,
159 x: offset.x,
160 y: offset.y,
161 rotate: rotate
162 });
163 };
164 /**
165 * 变更裁剪区域布局信息
166 * @param {Object} e 布局信息
167 */
168 function changeAreaRect(e) {
169 // console.log('changeAreaRect', e)
170 // 变更蒙版样式
171 setStyle(e.instance, 'maskStylesList', [
172 {
173 left: 0,
174 width: (area.left + areaOffset.left) + 'px',
175 top: 0,
176 bottom: 0,
177 },
178 {
179 left: (area.right + areaOffset.right) + 'px',
180 right: 0,
181 top: 0,
182 bottom: 0,
183 },
184 {
185 left: (area.left + areaOffset.left) + 'px',
186 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
187 top: 0,
188 height: (area.top + areaOffset.top) + 'px',
189 },
190 {
191 left: (area.left + areaOffset.left) + 'px',
192 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
193 top: (area.bottom + areaOffset.bottom) + 'px',
194 // height: (area.top - areaOffset.bottom + sys.offsetBottom) + 'px',
195 bottom: 0,
196 }
197 ]);
198 // 变更边框样式
199 if(area.showBorder) {
200 setStyle(e.instance, 'borderStyles', {
201 left: (area.left + areaOffset.left) + 'px',
202 top: (area.top + areaOffset.top) + 'px',
203 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
204 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
205 });
206 }
207
208 // 变更参考线样式
209 if(area.showGrid) {
210 setStyle(e.instance, 'gridStylesList', [
211 {
212 'border-width': '1px 0 0 0',
213 left: (area.left + areaOffset.left) + 'px',
214 right: (area.right + areaOffset.right) + 'px',
215 top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) / 3 - 0.5) + 'px',
216 width: (area.width + areaOffset.right - areaOffset.left) + 'px'
217 },
218 {
219 'border-width': '1px 0 0 0',
220 left: (area.left + areaOffset.left) + 'px',
221 right: (area.right + areaOffset.right) + 'px',
222 top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) * 2 / 3 - 0.5) + 'px',
223 width: (area.width + areaOffset.right - areaOffset.left) + 'px'
224 },
225 {
226 'border-width': '0 1px 0 0',
227 top: (area.top + areaOffset.top) + 'px',
228 bottom: (area.bottom + areaOffset.bottom) + 'px',
229 left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) / 3 - 0.5) + 'px',
230 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
231 },
232 {
233 'border-width': '0 1px 0 0',
234 top: (area.top + areaOffset.top) + 'px',
235 bottom: (area.bottom + areaOffset.bottom) + 'px',
236 left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) * 2 / 3 - 0.5) + 'px',
237 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
238 }
239 ]);
240 }
241
242 // 变更四个伸缩角样式
243 if(area.showAngle) {
244 setStyle(e.instance, 'angleStylesList', [
245 {
246 'border-width': area.angleBorderWidth + 'px 0 0 ' + area.angleBorderWidth + 'px',
247 left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
248 top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
249 },
250 {
251 'border-width': area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0 0',
252 left: (area.right + areaOffset.right - area.angleSize) + 'px',
253 top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
254 },
255 {
256 'border-width': '0 0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px',
257 left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
258 top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
259 },
260 {
261 'border-width': '0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0',
262 left: (area.right + areaOffset.right - area.angleSize) + 'px',
263 top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
264 }
265 ]);
266 }
267
268 // 变更圆角样式
269 if(area.radius > 0) {
270 var radius = area.radius;
271 if(area.width === area.height && area.radius >= area.width / 2) { // 圆形
272 radius = (area.width / 2);
273 } else { // 圆角矩形
274 if(area.width !== area.height) { // 限制圆角半径不能超过短边的一半
275 radius = Math.min(area.width / 2, area.height / 2, radius);
276 }
277 }
278 setStyle(e.instance, 'circleBoxStyles', {
279 left: (area.left + areaOffset.left) + 'px',
280 top: (area.top + areaOffset.top) + 'px',
281 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
282 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
283 });
284 setStyle(e.instance, 'circleStyles', {
285 'box-shadow': '0 0 0 ' + Math.max(area.width, area.height) + 'px rgba(51, 51, 51, 0.8)',
286 'border-radius': radius + 'px'
287 });
288 }
289 };
290 /**
291 * 缩放图片
292 * @param {Object} e 布局信息
293 */
294 function scaleImage(e) {
295 // console.log('scaleImage', e)
296 var last = scale;
297 scale = Math.min(Math.max(e.scale + scale, minScale), img.maxScale);
298 if(last !== scale) {
299 img.width = img.oldWidth * scale;
300 img.height = img.oldHeight * scale;
301 // 参考问题:有一个长4000px、宽4000px的四方形ABCD,A点的坐标固定在(-2000,-2000),
302 // 该四边形上有一个点E,坐标为(-100,-300),将该四方形复制一份并缩小到90%后,
303 // 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合?
304 // 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
305 // 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
306 e.x = (e.x - offset.x) * (1 - scale / last);
307 e.y = (e.y - offset.y) * (1 - scale / last);
308 changeImageRect(e);
309 return true;
310 }
311 return false;
312 };
313 /**
314 * 获取触摸点在哪个角
315 * @param {number} x 触摸点x轴坐标
316 * @param {number} y 触摸点y轴坐标
317 * @return {number} 角的位置:0=无;1=左上;2=右上;3=左下;4=右下;
318 */
319 function getToucheAngle(x, y) {
320 // console.log('getToucheAngle', x, y, JSON.stringify(area))
321 var o = area.angleBorderWidth; // 需扩大触发范围则把 o 值加大即可
322 var oy = sys.navigation ? 0 : sys.windowTop;
323 if(y >= area.top - o + oy && y <= area.top + area.angleSize + o + oy) {
324 if(x >= area.left - o && x <= area.left + area.angleSize + o) {
325 return 1; // 左上角
326 } else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
327 return 2; // 右上角
328 }
329 } else if(y >= area.bottom - area.angleSize - o + oy && y <= area.bottom + o + oy) {
330 if(x >= area.left - o && x <= area.left + area.angleSize + o) {
331 return 3; // 左下角
332 } else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
333 return 4; // 右下角
334 }
335 }
336 return 0; // 无触摸到角
337 };
338 /**
339 * 重置数据
340 */
341 function resetData() {
342 offset = { x: 0, y: 0 };
343 scale = 1;
344 minScale = 1;
345 rotate = 0;
346 };
347 function getTouchs(touches) {
348 var result = [];
349 var len = touches ? touches.length : 0
350 for (var i = 0; i < len; i++) {
351 result[i] = {
352 pageX: touches[i].pageX,
353 // h5无标题栏时,窗口顶部距离仍为标题栏高度,且触摸点y轴坐标还是有标题栏的值,即减去标题栏高度的值
354 pageY: touches[i].pageY + sys.windowTop
355 };
356 }
357 return result;
358 };
359 export default {
360 data() {
361 return {
362 imageStyles: {},
363 maskStylesList: [{}, {}, {}, {}],
364 borderStyles: {},
365 gridStylesList: [{}, {}, {}, {}],
366 angleStylesList: [{}, {}, {}, {}],
367 circleBoxStyles: {},
368 circleStyles: {}
369 }
370 },
371 created() {
372 // 监听 PC 端鼠标滚轮
373 // #ifdef H5
374 window.addEventListener('mousewheel', (e) => {
375 var touchs = getTouchs([e])
376 img.src && scaleImage({
377 instance: this.getInstance(),
378 check: true,
379 // 鼠标向上滚动时,deltaY 固定 -100,鼠标向下滚动时,deltaY 固定 100
380 scale: e.deltaY > 0 ? -0.05 : 0.05,
381 x: touchs[0].pageX,
382 y: touchs[0].pageY
383 });
384 });
385 // #endif
386 },
387 methods: {
388 getInstance() {
389 // #ifdef APP-PLUS
390 return this.$ownerInstance;
391 // #endif
392 // #ifdef H5
393 return this;
394 // #endif
395 },
396 /**
397 * 初始化:观察数据变更
398 * @param {Object} newVal 新数据
399 * @param {Object} oldVal 旧数据
400 * @param {Object} o 组件实例对象
401 */
402 initObserver: function(newVal, oldVal, o, i) {
403 console.log('initObserver', newVal, oldVal, o, i)
404 if(newVal && (!img.src || timestamp !== newVal.timestamp)) {
405 timestamp = newVal.timestamp;
406 img = newVal.img;
407 sys = newVal.sys;
408 area = newVal.area;
409 resetData();
410 img.src && changeImageRect({
411 instance: this.getInstance(),
412 x: (sys.windowWidth - img.width) / 2,
413 y: (sys.windowHeight + sys.windowTop - sys.offsetBottom - img.height) / 2
414 });
415 changeAreaRect({
416 instance: this.getInstance()
417 });
418 }
419 },
420 /**
421 * 鼠标滚轮滚动
422 * @param {Object} e 事件对象
423 * @param {Object} o 组件实例对象
424 */
425 mousewheel: function(e, o) {
426 // h5平台 wheel 事件无法判断滚轮滑动方向,需使用 mousewheel
427 },
428 /**
429 * 触摸开始
430 * @param {Object} e 事件对象
431 * @param {Object} o 组件实例对象
432 */
433 touchstart: function(e, o) {
434 if(!img.src) return;
435 touches = getTouchs(e.touches);
436 activeAngle = area.showAngle ? getToucheAngle(touches[0].pageX, touches[0].pageY) : 0;
437 if(touches.length === 1 && activeAngle !== 0) {
438 touchType = 'stretch'; // 伸缩裁剪区域
439 } else {
440 touchType = '';
441 }
442 // console.log('touchstart', e, activeAngle)
443 },
444 /**
445 * 触摸移动
446 * @param {Object} e 事件对象
447 * @param {Object} o 组件实例对象
448 */
449 touchmove: function(e, o) {
450 if(!img.src) return;
451 // console.log('touchmove', e, o)
452 e.touches = getTouchs(e.touches);
453 if(touchType === 'stretch') { // 触摸四个角进行拉伸
454 var point = e.touches[0];
455 var start = touches[0];
456 var x = point.pageX - start.pageX;
457 var y = point.pageY - start.pageY;
458 if(x !== 0 || y !== 0) {
459 var maxX = area.width * (1 - area.minScale);
460 var maxY = area.height * (1 - area.minScale);
461 // console.log(x, y, maxX, maxY)
462 touches[0] = point;
463 switch(activeAngle) {
464 case 1: // 左上角
465 x += areaOffset.left;
466 y += areaOffset.top;
467 if(x >= 0 && y >= 0) { // 有效滑动
468 if(x > y) { // 以x轴滑动距离为缩放基准
469 if(x > maxX) x = maxX;
470 y = x * area.height / area.width;
471 } else { // 以y轴滑动距离为缩放基准
472 if(y > maxY) y = maxY;
473 x = y * area.width / area.height;
474 }
475 areaOffset.left = x;
476 areaOffset.top = y;
477 }
478 break;
479 case 2: // 右上角
480 x += areaOffset.right;
481 y += areaOffset.top;
482 if(x <= 0 && y >= 0) { // 有效滑动
483 if(-x > y) { // 以x轴滑动距离为缩放基准
484 if(-x > maxX) x = -maxX;
485 y = -x * area.height / area.width;
486 } else { // 以y轴滑动距离为缩放基准
487 if(y > maxY) y = maxY;
488 x = -y * area.width / area.height;
489 }
490 areaOffset.right = x;
491 areaOffset.top = y;
492 }
493 break;
494 case 3: // 左下角
495 x += areaOffset.left;
496 y += areaOffset.bottom;
497 if(x >= 0 && y <= 0) { // 有效滑动
498 if(x > -y) { // 以x轴滑动距离为缩放基准
499 if(x > maxX) x = maxX;
500 y = -x * area.height / area.width;
501 } else { // 以y轴滑动距离为缩放基准
502 if(-y > maxY) y = -maxY;
503 x = -y * area.width / area.height;
504 }
505 areaOffset.left = x;
506 areaOffset.bottom = y;
507 }
508 break;
509 case 4: // 右下角
510 x += areaOffset.right;
511 y += areaOffset.bottom;
512 if(x <= 0 && y <= 0) { // 有效滑动
513 if(-x > -y) { // 以x轴滑动距离为缩放基准
514 if(-x > maxX) x = -maxX;
515 y = x * area.height / area.width;
516 } else { // 以y轴滑动距离为缩放基准
517 if(-y > maxY) y = -maxY;
518 x = y * area.width / area.height;
519 }
520 areaOffset.right = x;
521 areaOffset.bottom = y;
522 }
523 break;
524 }
525 // console.log(x, y, JSON.stringify(areaOffset))
526 changeAreaRect({
527 instance: this.getInstance(),
528 });
529 // this.draw();
530 }
531 } else if (e.touches.length == 2) { // 双点触摸缩放
532 var start = getDistanceByTouches(touches);
533 var end = getDistanceByTouches(e.touches);
534 scaleImage({
535 instance: this.getInstance(),
536 check: !area.bounce,
537 scale: (end.c - start.c) / 100,
538 x: end.x,
539 y: end.y
540 });
541 touchType = 'scale';
542 } else if(touchType === 'scale') {// 从双点触摸变成单点触摸 / 从缩放变成拖动
543 touchType = 'move';
544 } else {
545 changeImageRect({
546 instance: this.getInstance(),
547 check: !area.bounce,
548 x: e.touches[0].pageX - touches[0].pageX,
549 y: e.touches[0].pageY - touches[0].pageY
550 });
551 touchType = 'move';
552 }
553 touches = e.touches;
554 },
555 /**
556 * 触摸结束
557 * @param {Object} e 事件对象
558 * @param {Object} o 组件实例对象
559 */
560 touchend: function(e, o) {
561 if(!img.src) return;
562 if(touchType === 'stretch') { // 拉伸裁剪区域的四个角缩放
563 // 裁剪区域宽度被缩放到多少
564 var left = areaOffset.left;
565 var right = areaOffset.right;
566 var top = areaOffset.top;
567 var bottom = areaOffset.bottom;
568 var w = area.width + right - left;
569 var h = area.height + bottom - top;
570 // 图像放大倍数
571 var p = scale * (area.width / w) - scale;
572 // 复原裁剪区域
573 areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
574 changeAreaRect({
575 instance: this.getInstance(),
576 });
577 scaleImage({
578 instance: this.getInstance(),
579 scale: p,
580 x: area.left + left + (1 === activeAngle || 3 === activeAngle ? w : 0),
581 y: area.top + top + (1 === activeAngle || 2 === activeAngle ? h : 0)
582 });
583 } else if (area.bounce) { // 检查边界并矫正,实现拖动到边界时有回弹效果
584 changeImageRect({
585 instance: this.getInstance(),
586 check: true
587 });
588 }
589 },
590 /**
591 * 顺时针翻转图片90°
592 * @param {Object} e 事件对象
593 * @param {Object} o 组件实例对象
594 */
595 rotateImage: function(e, o) {
596 rotate = (rotate + 90) % 360;
597
598 // 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
599 var r = rotate / 90 % 2;
600 minScale = 1;
601 if(img.width < area.height) {
602 minScale = area.height / img.oldWidth;
603 } else if(img.height < area.width) {
604 minScale = (area.width / img.oldHeight)
605 }
606 if(minScale !== 1) {
607 scaleImage({
608 instance: this.getInstance(),
609 scale: minScale - scale,
610 x: sys.windowWidth / 2,
611 y: (sys.windowHeight - sys.offsetBottom) / 2
612 });
613 }
614
615 // 由于拖动画布后会导致图片位置偏移,翻转时的旋转中心点需是图片区域+偏移区域的中心点
616 // 翻转x轴中心点 = (超出裁剪区域右侧的图片宽度 - 超出裁剪区域左侧的图片宽度) / 2
617 // 翻转y轴中心点 = (超出裁剪区域下方的图片宽度 - 超出裁剪区域上方的图片宽度) / 2
618 var ox = ((offset.x + img.width - area.right) - (area.left - offset.x)) / 2;
619 var oy = ((offset.y + img.height - area.bottom) - (area.top - offset.y)) / 2;
620 changeImageRect({
621 instance: this.getInstance(),
622 check: true,
623 x: -ox - oy,
624 y: -oy + ox
625 });
626 }
627 }
628 }
...\ No newline at end of file ...\ No newline at end of file
1 <template>
2 <view class="image-cropper" @wheel="cropper.mousewheel">
3 <canvas v-if="use2d" type="2d" id="imgCanvas" class="img-canvas" :style="{
4 width: `${canvansWidth}px`,
5 height: `${canvansHeight}px`
6 }"></canvas>
7 <canvas v-else id="imgCanvas" canvas-id="imgCanvas" class="img-canvas" :style="{
8 width: `${canvansWidth}px`,
9 height: `${canvansHeight}px`
10 }"></canvas>
11 <view class="pic-preview" :change:init="cropper.initObserver" :init="initData" @touchstart="cropper.touchstart" @touchmove="cropper.touchmove" @touchend="cropper.touchend">
12 <image v-if="imgSrc" id="crop-image" class="crop-image" :style="cropper.imageStyles" :src="imgSrc" webp></image>
13 <view v-for="(item, index) in maskList" :key="item.id" :id="item.id" class="crop-mask-block" :style="cropper.maskStylesList[index]"></view>
14 <view v-if="showBorder" id="crop-border" class="crop-border" :style="cropper.borderStyles"></view>
15 <view v-if="radius > 0" id="crop-circle-box" class="crop-circle-box" :style="cropper.circleBoxStyles">
16 <view class="crop-circle" id="crop-circle" :style="cropper.circleStyles"></view>
17 </view>
18 <block v-if="showGrid">
19 <view v-for="(item, index) in gridList" :key="item.id" :id="item.id" class="crop-grid" :style="cropper.gridStylesList[index]"></view>
20 </block>
21 <block v-if="showAngle">
22 <view v-for="(item, index) in angleList" :key="item.id" :id="item.id" class="crop-angle" :style="cropper.angleStylesList[index]">
23 <view :style="[{
24 width: `${angleSize}px`,
25 height: `${angleSize}px`
26 }]"></view>
27 </view>
28 </block>
29 </view>
30 <view class="fixed-bottom safe-area-inset-bottom">
31 <view v-if="rotatable && !!imgSrc" class="rotate-icon" @click="cropper.rotateImage"></view>
32 <view v-if="!choosable" class="choose-btn" @click="cropClick">确定</view>
33 <block v-else-if="!!imgSrc">
34 <view class="rechoose" @click="chooseImage">重选</view>
35 <button class="button" size="mini" @click="cropClick">确定</button>
36 </block>
37 <view v-else class="choose-btn" @click="chooseImage">选择图片</view>
38 </view>
39 </view>
40 </template>
41
42 <!-- #ifdef APP-VUE || H5 -->
43 <script module="cropper" lang="renderjs">
44 import cropper from './qf-image-cropper.render.js';
45 export default {
46 mixins: [ cropper ]
47 }
48 </script>
49 <!-- #endif -->
50 <!-- #ifdef MP-WEIXIN || MP-QQ -->
51 <script module="cropper" lang="wxs" src="./qf-image-cropper.wxs"></script>
52 <!-- #endif -->
53 <script>
54 /** 裁剪区域最大宽高所占屏幕宽度百分比 */
55 const AREA_SIZE = 75;
56 /** 图片默认宽高 */
57 const IMG_SIZE = 300;
58
59 export default {
60 name:"qf-image-cropper",
61 // #ifdef MP-WEIXIN
62 options: {
63 // 表示启用样式隔离,在自定义组件内外,使用 class 指定的样式将不会相互影响
64 styleIsolation: "isolated"
65 },
66 // #endif
67 props: {
68 /** 图片资源地址 */
69 src: {
70 type: String,
71 default: ''
72 },
73 /** 裁剪宽度,有些平台或设备对于canvas的尺寸有限制,过大可能会导致无法正常绘制 */
74 width: {
75 type: Number,
76 default: IMG_SIZE
77 },
78 /** 裁剪高度,有些平台或设备对于canvas的尺寸有限制,过大可能会导致无法正常绘制 */
79 height: {
80 type: Number,
81 default: IMG_SIZE
82 },
83 /** 是否绘制裁剪区域边框 */
84 showBorder: {
85 type: Boolean,
86 default: true
87 },
88 /** 是否绘制裁剪区域网格参考线 */
89 showGrid: {
90 type: Boolean,
91 default: true
92 },
93 /** 是否展示四个支持伸缩的角 */
94 showAngle: {
95 type: Boolean,
96 default: true
97 },
98 /** 裁剪区域最小缩放倍数 */
99 areaScale: {
100 type: Number,
101 default: 0.3
102 },
103 /** 图片最大缩放倍数 */
104 maxScale: {
105 type: Number,
106 default: 5
107 },
108 /** 是否有回弹效果:拖动时可以拖出边界,释放时会弹回边界 */
109 bounce: {
110 type: Boolean,
111 default: true
112 },
113 /** 是否支持翻转 */
114 rotatable: {
115 type: Boolean,
116 default: true
117 },
118 /** 是否支持从本地选择素材 */
119 choosable: {
120 type: Boolean,
121 default: true
122 },
123 /** 四个角尺寸,单位px */
124 angleSize: {
125 type: Number,
126 default: 20
127 },
128 /** 四个角边框宽度,单位px */
129 angleBorderWidth: {
130 type: Number,
131 default: 2
132 },
133 /** 裁剪图片圆角半径,单位px */
134 radius: {
135 type: Number,
136 default: 0
137 },
138 /** 生成文件的类型,只支持 'jpg' 或 'png'。默认为 'png' */
139 fileType: {
140 type: String,
141 default: 'png'
142 },
143 /**
144 * 图片从绘制到生成所需时间,单位ms
145 * 微信小程序平台使用 `Canvas 2D` 绘制时有效
146 * 如绘制大图或出现裁剪图片空白等情况应适当调大该值,因 `Canvas 2d` 采用同步绘制,需自己把控绘制完成时间
147 */
148 delay: {
149 type: Number,
150 default: 1000
151 },
152 // #ifdef H5
153 /**
154 * 页面是否是原生标题栏
155 * H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差。
156 * 注:因H5平台的窗口高度是包含标题栏的,而屏幕触摸点的坐标是不包含的
157 */
158 navigation: {
159 type: Boolean,
160 default: true
161 }
162 // #endif
163 },
164 emits: ["crop"],
165 data() {
166 return {
167 // 用不同 id 使 v-for key 不重复
168 maskList: [
169 { id: 'crop-mask-block-1' },
170 { id: 'crop-mask-block-2' },
171 { id: 'crop-mask-block-3' },
172 { id: 'crop-mask-block-4' },
173 ],
174 gridList: [
175 { id: 'crop-grid-1' },
176 { id: 'crop-grid-2' },
177 { id: 'crop-grid-3' },
178 { id: 'crop-grid-4' },
179 ],
180 angleList: [
181 { id: 'crop-angle-1' },
182 { id: 'crop-angle-2' },
183 { id: 'crop-angle-3' },
184 { id: 'crop-angle-4' },
185 ],
186 /** 本地缓存的图片路径 */
187 imgSrc: '',
188 /** 图片的裁剪宽度 */
189 imgWidth: IMG_SIZE,
190 /** 图片的裁剪高度 */
191 imgHeight: IMG_SIZE,
192 /** 裁剪区域最大宽度所占屏幕宽度百分比 */
193 widthPercent: AREA_SIZE,
194 /** 裁剪区域最大高度所占屏幕宽度百分比 */
195 heightPercent: AREA_SIZE,
196 /** 裁剪区域布局信息 */
197 area: {},
198 /** 未被缩放过的图片宽 */
199 oldWidth: 0,
200 /** 未被缩放过的图片高 */
201 oldHeight: 0,
202 /** 系统信息 */
203 sys: uni.getSystemInfoSync(),
204 scaleWidth: 0,
205 scaleHeight: 0,
206 rotate: 0,
207 offsetX: 0,
208 offsetY: 0,
209 use2d: false,
210 canvansWidth: 0,
211 canvansHeight: 0,
212 // imageStyles: {},
213 // maskStylesList: [{}, {}, {}, {}],
214 // borderStyles: {},
215 // gridStylesList: [{}, {}, {}, {}],
216 // angleStylesList: [{}, {}, {}, {}],
217 // circleBoxStyles: {},
218 // circleStyles: {},
219 }
220 },
221 computed: {
222 initData() {
223 // console.log('initData')
224 return {
225 timestamp: new Date().getTime(),
226 area: {
227 ...this.area,
228 bounce: this.bounce,
229 showBorder: this.showBorder,
230 showGrid: this.showGrid,
231 showAngle: this.showAngle,
232 angleSize: this.angleSize,
233 angleBorderWidth: this.angleBorderWidth,
234 minScale: this.areaScale,
235 widthPercent: this.widthPercent,
236 heightPercent: this.heightPercent,
237 radius: this.radius
238 },
239 sys: this.sys,
240 img: {
241 maxScale: this.maxScale,
242 src: this.imgSrc,
243 width: this.oldWidth,
244 height: this.oldHeight,
245 oldWidth: this.oldWidth,
246 oldHeight: this.oldHeight,
247 }
248 }
249 },
250 imgProps() {
251 return {
252 width: this.width,
253 height: this.height,
254 src: this.src,
255 }
256 }
257 },
258 watch: {
259 imgProps: {
260 handler(val) {
261 // console.log('imgProps', val)
262 // 自定义裁剪尺,示例如下:
263 this.imgWidth = Number(val.width) || IMG_SIZE;
264 this.imgHeight = Number(val.height) || IMG_SIZE;
265 let use2d = true;
266 // #ifndef MP-WEIXIN
267 use2d = false;
268 // #endif
269 // if(use2d && (this.imgWidth > 1365 || this.imgHeight > 1365)) {
270 // use2d = false;
271 // }
272 let canvansWidth = this.imgWidth;
273 let canvansHeight = this.imgHeight;
274 let size = Math.max(canvansWidth, canvansHeight)
275 let scalc = 1;
276 if(size > 1365) {
277 scalc = 1365 / size;
278 }
279 this.canvansWidth = canvansWidth * scalc;
280 this.canvansHeight = canvansHeight * scalc;
281 this.use2d = use2d;
282 this.initArea();
283 val.src && this.initImage(val.src);
284 },
285 immediate: true
286 },
287 },
288 methods: {
289 /** 提供给wxs调用,用来接收图片变更数据 */
290 dataChange(e) {
291 // console.log('dataChange', e)
292 this.scaleWidth = e.width;
293 this.scaleHeight = e.height;
294 this.rotate = e.rotate;
295 this.offsetX = e.x;
296 this.offsetY = e.y;
297 },
298 /** 初始化裁剪区域布局信息 */
299 initArea() {
300 // 底部操作栏高度 = 底部底部操作栏内容高度 + 设备底部安全区域高度
301 this.sys.offsetBottom = uni.upx2px(100) + this.sys.safeAreaInsets.bottom;
302 // #ifndef H5
303 this.sys.windowTop = 0;
304 this.sys.navigation = true;
305 // #endif
306 // #ifdef H5
307 // h5平台的窗口高度是包含标题栏的
308 this.sys.windowTop = this.sys.windowTop || 44;
309 this.sys.navigation = this.navigation;
310 // #endif
311 let wp = this.widthPercent;
312 let hp = this.heightPercent;
313 if (this.imgWidth > this.imgHeight) {
314 hp = hp * this.imgHeight / this.imgWidth;
315 } else if (this.imgWidth < this.imgHeight) {
316 wp = wp * this.imgWidth / this.imgHeight;
317 }
318 const size = this.sys.windowWidth > this.sys.windowHeight ? this.sys.windowHeight : this.sys.windowWidth;
319 const width = size * wp / 100;
320 const height = size * hp / 100;
321 const left = (this.sys.windowWidth - width) / 2;
322 const right = left + width;
323 const top = (this.sys.windowHeight + this.sys.windowTop - this.sys.offsetBottom - height) / 2;
324 const bottom = this.sys.windowHeight + this.sys.windowTop - this.sys.offsetBottom - top;
325 this.area = { width, height, left, right, top, bottom };
326 this.scaleWidth = width;
327 this.scaleHeight = height;
328 },
329 /** 从本地选取图片 */
330 chooseImage() {
331 // #ifdef MP-WEIXIN || MP-JD
332 if(uni.chooseMedia) {
333 uni.chooseMedia({
334 count: 1,
335 mediaType: ['image'],
336 success: (res) => {
337 this.resetData();
338 this.initImage(res.tempFiles[0].tempFilePath);
339 }
340 });
341 return;
342 }
343 // #endif
344 uni.chooseImage({
345 count: 1,
346 success: (res) => {
347 this.resetData();
348 this.initImage(res.tempFiles[0].path);
349 }
350 });
351 },
352 /** 重置数据 */
353 resetData() {
354 this.imgSrc = '';
355 this.rotate = 0;
356 this.offsetX = 0;
357 this.offsetY = 0;
358 this.initArea();
359 },
360 /**
361 * 初始化图片信息
362 * @param {String} url 图片链接
363 */
364 initImage(url) {
365 uni.getImageInfo({
366 src: url,
367 success: (res) => {
368 this.imgSrc = res.path;
369 let scale = res.width / res.height;
370 let areaScale = this.area.width / this.area.height;
371 if (scale > 1) { // 横向图片
372 if (scale >= areaScale) { // 图片宽不小于目标宽,则高固定,宽自适应
373 this.scaleWidth = (this.scaleHeight / res.height) * this.scaleWidth * (res.width / this.scaleWidth);
374 } else { // 否则宽固定、高自适应
375 this.scaleHeight = res.height * this.scaleWidth / res.width;
376 }
377 } else { // 纵向图片
378 if (scale <= areaScale) { // 图片高不小于目标高,宽固定,高自适应
379 this.scaleHeight = (this.scaleWidth / res.width) * this.scaleHeight / (this.scaleHeight / res.height);
380 } else { // 否则高固定,宽自适应
381 this.scaleWidth = res.width * this.scaleHeight / res.height;
382 }
383 }
384 // 记录原始宽高,为缩放比列做限制
385 this.oldWidth = this.scaleWidth;
386 this.oldHeight = this.scaleHeight;
387 },
388 fail: (err) => {
389 console.error(err)
390 }
391 });
392 },
393 /**
394 * 剪切图片圆角
395 * @param {Object} ctx canvas 的绘图上下文对象
396 * @param {Number} radius 圆角半径
397 * @param {Number} scale 生成图片的实际尺寸与截取区域比
398 * @param {Function} drawImage 执行剪切时所调用的绘图方法,入参为是否执行了剪切
399 */
400 drawClipImage(ctx, radius, scale, drawImage) {
401 if(radius > 0) {
402 ctx.save();
403 ctx.beginPath();
404 const w = this.canvansWidth;
405 const h = this.canvansHeight;
406 if(w === h && radius >= w / 2) { // 圆形
407 ctx.arc(w / 2, h / 2, w / 2, 0, 2 * Math.PI);
408 } else { // 圆角矩形
409 if(w !== h) { // 限制圆角半径不能超过短边的一半
410 radius = Math.min(w / 2, h / 2, radius);
411 // radius = Math.min(Math.max(w, h) / 2, radius);
412 }
413 ctx.moveTo(radius, 0);
414 ctx.arcTo(w, 0, w, h, radius);
415 ctx.arcTo(w, h, 0, h, radius);
416 ctx.arcTo(0, h, 0, 0, radius);
417 ctx.arcTo(0, 0, w, 0, radius);
418 ctx.closePath();
419 }
420 ctx.clip();
421 drawImage && drawImage(true);
422 ctx.restore();
423 } else {
424 drawImage && drawImage(false);
425 }
426 },
427 /**
428 * 旋转图片
429 * @param {Object} ctx canvas 的绘图上下文对象
430 * @param {Number} rotate 旋转角度
431 * @param {Number} scale 生成图片的实际尺寸与截取区域比
432 */
433 drawRotateImage(ctx, rotate, scale) {
434 if(rotate !== 0) {
435 // 1. 以图片中心点为旋转中心点
436 const x = this.scaleWidth * scale / 2;
437 const y = this.scaleHeight * scale / 2;
438 ctx.translate(x, y);
439 // 2. 旋转画布
440 ctx.rotate(rotate * Math.PI / 180);
441 // 3. 旋转完画布后恢复设置旋转中心时所做的偏移
442 ctx.translate(-x, -y);
443 }
444 },
445 drawImage(ctx, image, callback) {
446 // 生成图片的实际尺寸与截取区域比
447 const scale = this.canvansWidth / this.area.width;
448 this.drawClipImage(ctx, this.radius, scale, () => {
449 this.drawRotateImage(ctx, this.rotate, scale);
450 const r = this.rotate / 90;
451 ctx.drawImage(
452 image,
453 [
454 (this.offsetX - this.area.left),
455 (this.offsetY - this.area.top),
456 -(this.offsetX - this.area.left),
457 -(this.offsetY - this.area.top)
458 ][r] * scale,
459 [
460 (this.offsetY - this.area.top),
461 -(this.offsetX - this.area.left),
462 -(this.offsetY - this.area.top),
463 (this.offsetX - this.area.left)
464 ][r] * scale,
465 this.scaleWidth * scale,
466 this.scaleHeight * scale
467 );
468 });
469 },
470 /**
471 * 绘图
472 * @param {Object} canvas
473 * @param {Object} ctx canvas 的绘图上下文对象
474 * @param {String} src 图片路径
475 * @param {Function} callback 开始绘制时回调
476 */
477 draw2DImage(canvas, ctx, src, callback) {
478 // console.log('draw2DImage', canvas, ctx, src, callback)
479 if(canvas) {
480 const image = canvas.createImage();
481 image.onload = () => {
482 this.drawImage(ctx, image);
483 // 如果觉得`生成时间过长`或`出现生成图片空白`可尝试调整延迟时间
484 callback && setTimeout(callback, this.delay);
485 };
486 image.onerror = (err) => {
487 console.error(err)
488 uni.hideLoading();
489 };
490 image.src = src;
491 } else {
492 this.drawImage(ctx, src);
493 setTimeout(() => {
494 ctx.draw(false, callback);
495 }, 200);
496 }
497 },
498 /**
499 * 画布转图片到本地缓存
500 * @param {Object} canvas
501 * @param {String} canvasId
502 */
503 canvasToTempFilePath(canvas, canvasId) {
504 // console.log('canvasToTempFilePath', canvas, canvasId)
505 uni.canvasToTempFilePath({
506 canvas,
507 canvasId,
508 x: 0,
509 y: 0,
510 width: this.canvansWidth,
511 height: this.canvansHeight,
512 destWidth: this.imgWidth, // 必要,保证生成图片宽度不受设备分辨率影响
513 destHeight: this.imgHeight, // 必要,保证生成图片高度不受设备分辨率影响
514 fileType: this.fileType, // 目标文件的类型,默认png
515 success: (res) => {
516 // 生成的图片临时文件路径
517 this.handleImage(res.tempFilePath);
518 },
519 fail: (err) => {
520 uni.hideLoading();
521 uni.showToast({ title: '裁剪失败,生成图片异常!', icon: 'none' });
522 }
523 }, this);
524 },
525 /** 确认裁剪 */
526 cropClick() {
527 uni.showLoading({ title: '裁剪中...', mask: true });
528 if(!this.use2d) {
529 const ctx = uni.createCanvasContext('imgCanvas', this);
530 ctx.clearRect(0, 0, this.canvansWidth, this.canvansHeight);
531 this.draw2DImage(null, ctx, this.imgSrc, () => {
532 this.canvasToTempFilePath(null, 'imgCanvas');
533 });
534 return;
535 }
536 // #ifdef MP-WEIXIN
537 const query = uni.createSelectorQuery().in(this);
538 query.select('#imgCanvas')
539 .fields({ node: true, size: true })
540 .exec((res) => {
541 const canvas = res[0].node;
542
543 const dpr = uni.getSystemInfoSync().pixelRatio;
544 canvas.width = res[0].width * dpr;
545 canvas.height = res[0].height * dpr;
546 const ctx = canvas.getContext('2d');
547 ctx.scale(dpr, dpr);
548 ctx.clearRect(0, 0, this.canvansWidth, this.canvansHeight);
549
550 this.draw2DImage(canvas, ctx, this.imgSrc, () => {
551 this.canvasToTempFilePath(canvas);
552 });
553 });
554 // #endif
555 },
556 handleImage(tempFilePath){
557 // 在H5平台下,tempFilePath 为 base64
558 // console.log(tempFilePath)
559 uni.hideLoading();
560 this.$emit('crop', { tempFilePath });
561 }
562 }
563 }
564 </script>
565
566 <style lang="scss" scoped>
567 .image-cropper {
568 position: fixed;
569 left: 0;
570 right: 0;
571 top: 0;
572 bottom: 0;
573 overflow: hidden;
574 display: flex;
575 flex-direction: column;
576 background-color: #000;
577 .img-canvas {
578 position: absolute !important;
579 transform: translateX(-100%);
580 }
581 .pic-preview {
582 width: 100%;
583 flex: 1;
584 position: relative;
585
586 .crop-mask-block {
587 background-color: rgba(51, 51, 51, 0.8);
588 z-index: 2;
589 position: fixed;
590 box-sizing: border-box;
591 pointer-events: none;
592 }
593 .crop-circle-box {
594 position: fixed;
595 box-sizing: border-box;
596 z-index: 2;
597 pointer-events: none;
598 overflow: hidden;
599 .crop-circle {
600 width: 100%;
601 height: 100%;
602 }
603 }
604 .crop-image {
605 padding: 0 !important;
606 margin: 0 !important;
607 border-radius: 0 !important;
608 display: block !important;
609 }
610 .crop-border {
611 position: fixed;
612 border: 1px solid #fff;
613 box-sizing: border-box;
614 z-index: 3;
615 pointer-events: none;
616 }
617 .crop-grid {
618 position: fixed;
619 z-index: 3;
620 border-style: dashed;
621 border-color: #fff;
622 pointer-events: none;
623 opacity: 0.5;
624 }
625 .crop-angle {
626 position: fixed;
627 z-index: 3;
628 border-style: solid;
629 border-color: #fff;
630 pointer-events: none;
631 }
632 }
633
634 .fixed-bottom {
635 position: fixed;
636 left: 0;
637 right: 0;
638 bottom: 0;
639 z-index: 99;
640 display: flex;
641 flex-direction: row;
642 background-color: $uni-bg-color-grey;
643
644 .rotate-icon {
645 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=');
646 background-size: 60% 60%;
647 background-repeat: no-repeat;
648 background-position:center;
649 width: 80rpx;
650 height: 80rpx;
651 position: absolute;
652 top: -90rpx;
653 left: 10rpx;
654 transform: rotateY(180deg);
655 }
656
657 .rechoose {
658 color: $uni-color-primary;
659 padding: 0 $uni-spacing-row-lg;
660 line-height: 100rpx;
661 }
662
663 .choose-btn {
664 color: $uni-color-primary;
665 text-align: center;
666 line-height: 100rpx;
667 flex: 1;
668 }
669
670 .button {
671 margin: auto $uni-spacing-row-lg auto auto;
672 background-color: $uni-color-primary;
673 color: #fff;
674 }
675 }
676
677 .safe-area-inset-bottom {
678 padding-bottom: 0;
679 padding-bottom: constant(safe-area-inset-bottom); // 兼容 IOS<11.2
680 padding-bottom: env(safe-area-inset-bottom); // 兼容 IOS>=11.2
681 }
682
683 }
684 </style>
...\ No newline at end of file ...\ No newline at end of file
1 /**
2 * 图片编辑器-手势监听
3 * 1. wxs 暂不支持 es6 语法
4 * 2. 支持编译到微信小程序、QQ小程序、app-vue、H5上(uni-app 2.2.5及以上版本)
5 */
6 /** 图片偏移量 */
7 var offset = { x: 0, y: 0 };
8 /** 图片缩放比例 */
9 var scale = 1;
10 /** 图片最小缩放比例 */
11 var minScale = 1;
12 /** 图片旋转角度 */
13 var rotate = 0;
14 /** 触摸点 */
15 var touches = [];
16 /** 图片布局信息 */
17 var img = {};
18 /** 系统信息 */
19 var sys = {};
20 /** 裁剪区域布局信息 */
21 var area = {};
22 /** 触摸行为类型 */
23 var touchType = '';
24 /** 操作角的位置 */
25 var activeAngle = 0;
26 /** 裁剪区域布局信息偏移量 */
27 var areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
28 /**
29 * 计算两点间距
30 * @param {Object} touches 触摸点信息
31 */
32 function getDistanceByTouches(touches) {
33 // 根据勾股定理求两点间距离
34 var a = touches[1].pageX - touches[0].pageX;
35 var b = touches[1].pageY - touches[0].pageY;
36 var c = Math.sqrt(Math.pow(a, 2) + Math.pow(b, 2));
37 // 求两点间的中点坐标
38 // 1. a、b可能为负值
39 // 2. 在求a、b时,如用touches[1]减touches[0],则求中点坐标也得用touches[1]减a/2、b/2
40 // 3. 同理,在求a、b时,也可用touches[0]减touches[1],则求中点坐标也得用touches[0]减a/2、b/2
41 var x = touches[1].pageX - a / 2;
42 var y = touches[1].pageY - b / 2;
43 return { c, x, y };
44 };
45 /**
46 * 检查边界:限制 x、y 拖动范围,禁止滑出边界
47 * @param {Object} e 点坐标
48 */
49 function checkRange(e) {
50 var r = rotate / 90 % 2;
51 if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
52 var o = (img.height - img.width) / 2; // 宽高差值一半
53 return {
54 x: Math.min(Math.max(e.x, -img.height + o + area.width + area.left), area.left + o),
55 y: Math.min(Math.max(e.y, -img.width - o + area.height + area.top), area.top - o)
56 }
57 }
58 return {
59 x: Math.min(Math.max(e.x, -img.width + area.width + area.left), area.left),
60 y: Math.min(Math.max(e.y, -img.height + area.height + area.top), area.top)
61 }
62 };
63 /**
64 * 变更图片布局信息
65 * @param {Object} e 布局信息
66 */
67 function changeImageRect(e) {
68 offset.x += e.x || 0;
69 offset.y += e.y || 0;
70 var image = e.instance.selectComponent('.crop-image');
71 if(e.check) { // 检查边界
72 var point = checkRange(offset);
73 if(offset.x !== point.x || offset.y !== point.y) {
74 offset = point;
75 }
76 }
77 // image.setStyle({
78 // width: img.width + 'px',
79 // height: img.height + 'px',
80 // transform: 'translate(' + offset.x + 'px, ' + offset.y + 'px) rotate(' + rotate +'deg)'
81 // });
82 var ox = (img.width - img.oldWidth) / 2;
83 var oy = (img.height - img.oldHeight) / 2;
84 image.setStyle({
85 width: img.oldWidth + 'px',
86 height: img.oldHeight + 'px',
87 transform: 'translate(' + (offset.x + ox) + 'px, ' + (offset.y + oy) + 'px) rotate(' + rotate +'deg) scale(' + scale + ')'
88 });
89
90 e.instance.callMethod('dataChange', {
91 width: img.width,
92 height: img.height,
93 x: offset.x,
94 y: offset.y,
95 rotate: rotate
96 });
97 };
98 /**
99 * 变更裁剪区域布局信息
100 * @param {Object} e 布局信息
101 */
102 function changeAreaRect(e) {
103 // 变更蒙版样式
104 var masks = e.instance.selectAllComponents('.crop-mask-block');
105 var maskStyles = [
106 {
107 left: 0,
108 width: (area.left + areaOffset.left) + 'px',
109 top: 0,
110 bottom: 0,
111 },
112 {
113 left: (area.right + areaOffset.right) + 'px',
114 right: 0,
115 top: 0,
116 bottom: 0,
117 },
118 {
119 left: (area.left + areaOffset.left) + 'px',
120 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
121 top: 0,
122 height: (area.top + areaOffset.top) + 'px',
123 },
124 {
125 left: (area.left + areaOffset.left) + 'px',
126 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
127 top: (area.bottom + areaOffset.bottom) + 'px',
128 // height: (area.top - areaOffset.bottom + sys.offsetBottom) + 'px',
129 bottom: 0,
130 }
131 ];
132 var len = masks.length;
133 for (var i = 0; i < len; i++) {
134 masks[i].setStyle(maskStyles[i]);
135 }
136
137 // 变更边框样式
138 if(area.showBorder) {
139 var border = e.instance.selectComponent('.crop-border');
140 border.setStyle({
141 left: (area.left + areaOffset.left) + 'px',
142 top: (area.top + areaOffset.top) + 'px',
143 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
144 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px',
145 });
146 }
147
148 // 变更参考线样式
149 if(area.showGrid) {
150 var grids = e.instance.selectAllComponents('.crop-grid');
151 var gridStyles = [
152 {
153 'border-width': '1px 0 0 0',
154 left: (area.left + areaOffset.left) + 'px',
155 right: (area.right + areaOffset.right) + 'px',
156 top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) / 3 - 0.5) + 'px',
157 width: (area.width + areaOffset.right - areaOffset.left) + 'px'
158 },
159 {
160 'border-width': '1px 0 0 0',
161 left: (area.left + areaOffset.left) + 'px',
162 right: (area.right + areaOffset.right) + 'px',
163 top: (area.top + areaOffset.top + (area.height + areaOffset.bottom - areaOffset.top) * 2 / 3 - 0.5) + 'px',
164 width: (area.width + areaOffset.right - areaOffset.left) + 'px'
165 },
166 {
167 'border-width': '0 1px 0 0',
168 top: (area.top + areaOffset.top) + 'px',
169 bottom: (area.bottom + areaOffset.bottom) + 'px',
170 left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) / 3 - 0.5) + 'px',
171 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
172 },
173 {
174 'border-width': '0 1px 0 0',
175 top: (area.top + areaOffset.top) + 'px',
176 bottom: (area.bottom + areaOffset.bottom) + 'px',
177 left: (area.left + areaOffset.left + (area.width + areaOffset.right - areaOffset.left) * 2 / 3 - 0.5) + 'px',
178 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
179 }
180 ];
181 var len = grids.length;
182 for (var i = 0; i < len; i++) {
183 grids[i].setStyle(gridStyles[i]);
184 }
185 }
186
187 // 变更四个伸缩角样式
188 if(area.showAngle) {
189 var angles = e.instance.selectAllComponents('.crop-angle');
190 var angleStyles = [
191 {
192 'border-width': area.angleBorderWidth + 'px 0 0 ' + area.angleBorderWidth + 'px',
193 left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
194 top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
195 },
196 {
197 'border-width': area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0 0',
198 left: (area.right + areaOffset.right - area.angleSize) + 'px',
199 top: (area.top + areaOffset.top - area.angleBorderWidth) + 'px',
200 },
201 {
202 'border-width': '0 0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px',
203 left: (area.left + areaOffset.left - area.angleBorderWidth) + 'px',
204 top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
205 },
206 {
207 'border-width': '0 ' + area.angleBorderWidth + 'px ' + area.angleBorderWidth + 'px 0',
208 left: (area.right + areaOffset.right - area.angleSize) + 'px',
209 top: (area.bottom + areaOffset.bottom - area.angleSize) + 'px',
210 }
211 ];
212 var len = angles.length;
213 for (var i = 0; i < len; i++) {
214 angles[i].setStyle(angleStyles[i]);
215 }
216 }
217
218 // 变更圆角样式
219 if(area.radius > 0) {
220 var circleBox = e.instance.selectComponent('.crop-circle-box');
221 var circle = e.instance.selectComponent('.crop-circle');
222 var radius = area.radius;
223 if(area.width === area.height && area.radius >= area.width / 2) { // 圆形
224 radius = (area.width / 2);
225 } else { // 圆角矩形
226 if(area.width !== area.height) { // 限制圆角半径不能超过短边的一半
227 radius = Math.min(area.width / 2, area.height / 2, radius);
228 }
229 }
230 circleBox.setStyle({
231 left: (area.left + areaOffset.left) + 'px',
232 top: (area.top + areaOffset.top) + 'px',
233 width: (area.width + areaOffset.right - areaOffset.left) + 'px',
234 height: (area.height + areaOffset.bottom - areaOffset.top) + 'px'
235 });
236 circle.setStyle({
237 'box-shadow': '0 0 0 ' + Math.max(area.width, area.height) + 'px rgba(51, 51, 51, 0.8)',
238 'border-radius': radius + 'px'
239 });
240 }
241 };
242 /**
243 * 缩放图片
244 * @param {Object} e 布局信息
245 */
246 function scaleImage(e) {
247 var last = scale;
248 scale = Math.min(Math.max(e.scale + scale, minScale), img.maxScale);
249 if(last !== scale) {
250 img.width = img.oldWidth * scale;
251 img.height = img.oldHeight * scale;
252 // 参考问题:有一个长4000px、宽4000px的四方形ABCD,A点的坐标固定在(-2000,-2000),
253 // 该四边形上有一个点E,坐标为(-100,-300),将该四方形复制一份并缩小到90%后,
254 // 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合?
255 // 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
256 // 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
257 e.x = (e.x - offset.x) * (1 - scale / last);
258 e.y = (e.y - offset.y) * (1 - scale / last);
259 changeImageRect(e);
260 return true;
261 }
262 return false;
263 };
264 /**
265 * 获取触摸点在哪个角
266 * @param {number} x 触摸点x轴坐标
267 * @param {number} y 触摸点y轴坐标
268 * @return {number} 角的位置:0=无;1=左上;2=右上;3=左下;4=右下;
269 */
270 function getToucheAngle(x, y) {
271 // console.log('getToucheAngle', x, y, JSON.stringify(area))
272 var o = area.angleBorderWidth; // 需扩大触发范围则把 o 值加大即可
273 if(y >= area.top - o && y <= area.top + area.angleSize + o) {
274 if(x >= area.left - o && x <= area.left + area.angleSize + o) {
275 return 1; // 左上角
276 } else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
277 return 2; // 右上角
278 }
279 } else if(y >= area.bottom - area.angleSize - o && y <= area.bottom + o) {
280 if(x >= area.left - o && x <= area.left + area.angleSize + o) {
281 return 3; // 左下角
282 } else if(x >= area.right - area.angleSize - o && x <= area.right + o) {
283 return 4; // 右下角
284 }
285 }
286 return 0; // 无触摸到角
287 };
288 /**
289 * 重置数据
290 */
291 function resetData() {
292 offset = { x: 0, y: 0 };
293 scale = 1;
294 minScale = 1;
295 rotate = 0;
296 };
297 module.exports = {
298 /**
299 * 初始化:观察数据变更
300 * @param {Object} newVal 新数据
301 * @param {Object} oldVal 旧数据
302 * @param {Object} o 组件实例对象
303 */
304 initObserver: function(newVal, oldVal, o, i) {
305 if(newVal) {
306 img = newVal.img;
307 sys = newVal.sys;
308 area = newVal.area;
309 resetData();
310 img.src && changeImageRect({
311 instance: o,
312 x: (sys.windowWidth - img.width) / 2,
313 y: (sys.windowHeight - sys.offsetBottom - img.height) / 2
314 });
315 changeAreaRect({
316 instance: o
317 });
318 // console.log('initRect', JSON.stringify(newVal))
319 }
320 },
321 /**
322 * 鼠标滚轮滚动
323 * @param {Object} e 事件对象
324 * @param {Object} o 组件实例对象
325 */
326 mousewheel: function(e, o) {
327 if(!img.src) return;
328 scaleImage({
329 instance: o,
330 check: true,
331 // 鼠标向上滚动时,deltaY 固定 -100,鼠标向下滚动时,deltaY 固定 100
332 scale: e.detail.deltaY > 0 ? -0.05 : 0.05,
333 x: e.touches[0].pageX,
334 y: e.touches[0].pageY
335 });
336 },
337 /**
338 * 触摸开始
339 * @param {Object} e 事件对象
340 * @param {Object} o 组件实例对象
341 */
342 touchstart: function(e, o) {
343 if(!img.src) return;
344 touches = e.touches;
345 activeAngle = area.showAngle ? getToucheAngle(touches[0].pageX, touches[0].pageY) : 0;
346 if(touches.length === 1 && activeAngle !== 0) {
347 touchType = 'stretch'; // 伸缩裁剪区域
348 } else {
349 touchType = '';
350 }
351 // console.log('touchstart', JSON.stringify(e), activeAngle)
352 },
353 /**
354 * 触摸移动
355 * @param {Object} e 事件对象
356 * @param {Object} o 组件实例对象
357 */
358 touchmove: function(e, o) {
359 if(!img.src) return;
360 // console.log('touchmove', JSON.stringify(e), JSON.stringify(o))
361 if(touchType === 'stretch') { // 触摸四个角进行拉伸
362 var point = e.touches[0];
363 var start = touches[0];
364 var x = point.pageX - start.pageX;
365 var y = point.pageY - start.pageY;
366 if(x !== 0 || y !== 0) {
367 var maxX = area.width * (1 - area.minScale);
368 var maxY = area.height * (1 - area.minScale);
369 // console.log(x, y, maxX, maxY)
370 touches[0] = point;
371 switch(activeAngle) {
372 case 1: // 左上角
373 x += areaOffset.left;
374 y += areaOffset.top;
375 if(x >= 0 && y >= 0) { // 有效滑动
376 if(x > y) { // 以x轴滑动距离为缩放基准
377 if(x > maxX) x = maxX;
378 y = x * area.height / area.width;
379 } else { // 以y轴滑动距离为缩放基准
380 if(y > maxY) y = maxY;
381 x = y * area.width / area.height;
382 }
383 areaOffset.left = x;
384 areaOffset.top = y;
385 }
386 break;
387 case 2: // 右上角
388 x += areaOffset.right;
389 y += areaOffset.top;
390 if(x <= 0 && y >= 0) { // 有效滑动
391 if(-x > y) { // 以x轴滑动距离为缩放基准
392 if(-x > maxX) x = -maxX;
393 y = -x * area.height / area.width;
394 } else { // 以y轴滑动距离为缩放基准
395 if(y > maxY) y = maxY;
396 x = -y * area.width / area.height;
397 }
398 areaOffset.right = x;
399 areaOffset.top = y;
400 }
401 break;
402 case 3: // 左下角
403 x += areaOffset.left;
404 y += areaOffset.bottom;
405 if(x >= 0 && y <= 0) { // 有效滑动
406 if(x > -y) { // 以x轴滑动距离为缩放基准
407 if(x > maxX) x = maxX;
408 y = -x * area.height / area.width;
409 } else { // 以y轴滑动距离为缩放基准
410 if(-y > maxY) y = -maxY;
411 x = -y * area.width / area.height;
412 }
413 areaOffset.left = x;
414 areaOffset.bottom = y;
415 }
416 break;
417 case 4: // 右下角
418 x += areaOffset.right;
419 y += areaOffset.bottom;
420 if(x <= 0 && y <= 0) { // 有效滑动
421 if(-x > -y) { // 以x轴滑动距离为缩放基准
422 if(-x > maxX) x = -maxX;
423 y = x * area.height / area.width;
424 } else { // 以y轴滑动距离为缩放基准
425 if(-y > maxY) y = -maxY;
426 x = y * area.width / area.height;
427 }
428 areaOffset.right = x;
429 areaOffset.bottom = y;
430 }
431 break;
432 }
433 // console.log(x, y, JSON.stringify(areaOffset))
434 changeAreaRect({
435 instance: o,
436 });
437 // this.draw();
438 }
439 } else if (e.touches.length == 2) { // 双点触摸缩放
440 var start = getDistanceByTouches(touches);
441 var end = getDistanceByTouches(e.touches);
442 scaleImage({
443 instance: o,
444 check: !area.bounce,
445 scale: (end.c - start.c) / 100,
446 x: end.x,
447 y: end.y
448 });
449 touchType = 'scale';
450 } else if(touchType === 'scale') {// 从双点触摸变成单点触摸 / 从缩放变成拖动
451 touchType = 'move';
452 } else {
453 changeImageRect({
454 instance: o,
455 check: !area.bounce,
456 x: e.touches[0].pageX - touches[0].pageX,
457 y: e.touches[0].pageY - touches[0].pageY
458 });
459 touchType = 'move';
460 }
461 touches = e.touches;
462 },
463 /**
464 * 触摸结束
465 * @param {Object} e 事件对象
466 * @param {Object} o 组件实例对象
467 */
468 touchend: function(e, o) {
469 if(!img.src) return;
470 if(touchType === 'stretch') { // 拉伸裁剪区域的四个角缩放
471 // 裁剪区域宽度被缩放到多少
472 var left = areaOffset.left;
473 var right = areaOffset.right;
474 var top = areaOffset.top;
475 var bottom = areaOffset.bottom;
476 var w = area.width + right - left;
477 var h = area.height + bottom - top;
478 // 图像放大倍数
479 var p = scale * (area.width / w) - scale;
480 // 复原裁剪区域
481 areaOffset = { left: 0, right: 0, top: 0, bottom: 0 };
482 changeAreaRect({
483 instance: o,
484 });
485 scaleImage({
486 instance: o,
487 scale: p,
488 x: area.left + left + (1 === activeAngle || 3 === activeAngle ? w : 0),
489 y: area.top + top + (1 === activeAngle || 2 === activeAngle ? h : 0)
490 });
491 } else if (area.bounce) { // 检查边界并矫正,实现拖动到边界时有回弹效果
492 changeImageRect({
493 instance: o,
494 check: true
495 });
496 }
497 },
498 /**
499 * 顺时针翻转图片90°
500 * @param {Object} e 事件对象
501 * @param {Object} o 组件实例对象
502 */
503 rotateImage: function(e, o) {
504 rotate = (rotate + 90) % 360;
505
506 // 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
507 var r = rotate / 90 % 2;
508 minScale = 1;
509 if(img.width < area.height) {
510 minScale = area.height / img.oldWidth;
511 } else if(img.height < area.width) {
512 minScale = (area.width / img.oldHeight)
513 }
514 if(minScale !== 1) {
515 scaleImage({
516 instance: o,
517 scale: minScale - scale,
518 x: sys.windowWidth / 2,
519 y: (sys.windowHeight - sys.offsetBottom) / 2
520 });
521 }
522
523 // 由于拖动画布后会导致图片位置偏移,翻转时的旋转中心点需是图片区域+偏移区域的中心点
524 // 翻转x轴中心点 = (超出裁剪区域右侧的图片宽度 - 超出裁剪区域左侧的图片宽度) / 2
525 // 翻转y轴中心点 = (超出裁剪区域下方的图片宽度 - 超出裁剪区域上方的图片宽度) / 2
526 var ox = ((offset.x + img.width - area.right) - (area.left - offset.x)) / 2;
527 var oy = ((offset.y + img.height - area.bottom) - (area.top - offset.y)) / 2;
528 changeImageRect({
529 instance: o,
530 check: true,
531 x: -ox - oy,
532 y: -oy + ox
533 });
534 },
535 // 此处只用于对齐其他平台端的样式参数,防止异常,无作用
536 imageStyles: {},
537 maskStylesList: [{}, {}, {}, {}],
538 borderStyles: {},
539 gridStylesList: [{}, {}, {}, {}],
540 angleStylesList: [{}, {}, {}, {}],
541 circleBoxStyles: {},
542 circleStyles: {},
543 }
...\ No newline at end of file ...\ No newline at end of file
1 {
2 "id": "qf-image-cropper",
3 "displayName": "图片裁剪插件",
4 "version": "2.1.6",
5 "description": "图片裁剪插件,支持自定义尺寸、定点等比例缩放、拖动、图片翻转、剪切圆形/圆角图片、定制样式,功能多性能高体验好注释全。",
6 "keywords": [
7 "qf-image-cropper",
8 "图片裁剪",
9 "图片编辑",
10 "头像裁剪",
11 "小程序"
12 ],
13 "repository": "",
14 "engines": {
15 "HBuilderX": "^3.1.0"
16 },
17 "dcloudext": {
18 "type": "component-vue",
19 "sale": {
20 "regular": {
21 "price": "0.00"
22 },
23 "sourcecode": {
24 "price": "0.00"
25 }
26 },
27 "contact": {
28 "qq": ""
29 },
30 "declaration": {
31 "ads": "无",
32 "data": "插件不采集任何数据",
33 "permissions": "无"
34 },
35 "npmurl": ""
36 },
37 "uni_modules": {
38 "dependencies": [],
39 "encrypt": [],
40 "platforms": {
41 "client": {
42 "Vue": {
43 "vue2": "y",
44 "vue3": "y"
45 },
46 "App": {
47 "app-vue": "y",
48 "app-nvue": "n"
49 },
50 "H5-mobile": {
51 "Safari": "y",
52 "Android Browser": "y",
53 "微信浏览器(Android)": "y",
54 "QQ浏览器(Android)": "u"
55 },
56 "H5-pc": {
57 "Chrome": "u",
58 "IE": "u",
59 "Edge": "u",
60 "Firefox": "u",
61 "Safari": "u"
62 },
63 "小程序": {
64 "微信": "y",
65 "阿里": "n",
66 "百度": "n",
67 "字节跳动": "n",
68 "QQ": "u",
69 "钉钉": "n",
70 "快手": "n",
71 "飞书": "n",
72 "京东": "n"
73 },
74 "快应用": {
75 "华为": "n",
76 "联盟": "n"
77 }
78 }
79 }
80 }
81 }
...\ No newline at end of file ...\ No newline at end of file
1 # qf-image-cropper
2 ## 图片裁剪插件
3 uniapp微信小程序图片裁剪插件,支持自定义尺寸、定点等比例缩放、拖动、图片翻转、剪切圆形/圆角图片、定制样式,功能多性能高体验好注释全。
4
5 ### 平台支持:
6 1. 支持微信小程序:移动端、PC端、开发者工具
7 2. 支持H5平台(2.1.0版本起)
8 3. 支持APP平台(2.1.5版本起):Android、IOS
9 4. 其他平台暂未测试兼容性未知
10
11 ### 支持功能:
12 1. 自定义裁剪尺寸
13 2. 定点等比例缩放:移动端以双指触摸中心点为缩放中心点,PC端以鼠标所在点为缩放中心点
14 3. 自由拖动:支持限制滑出边界,也支持回弹效果(滑动时可滑出边界,释放时回弹到边界)
15 4. 图片翻转:在裁剪尺寸非 1:1 的情况下,翻转时宽高无法铺满裁剪区域时,图片会自动放大到合适尺寸
16 5. 裁剪生成新图片
17 6. 本地选择图片
18 7. 可定制样式:可自由选择是否渲染裁剪边框、可伸缩裁剪顶角、参考线
19 8. 裁剪圆角图片:圆形、圆角矩形
20
21 ### 属性说明
22 | 属性名 | 类型 | 默认值 | 说明 |
23 |:---|:---|:---|:---|
24 | src | String | | 图片资源地址 |
25 | width | Number | 300 | 裁剪宽度 |
26 | height | Number | 300 | 裁剪高度 |
27 | showBorder | Boolean | true | 是否绘制裁剪区域边框 |
28 | showGrid | Boolean | true | 是否绘制裁剪区域网格参考线 |
29 | showAngle | Boolean | true | 是否展示四个支持伸缩的角 |
30 | areaScale | Number | 0.3 | 裁剪区域最小缩放倍数 |
31 | maxScale | Number | 5 | 图片最大缩放倍数 |
32 | bounce | Boolean | true | 是否有回弹效果:拖动时可以拖出边界,释放时会弹回边界 |
33 | rotatable | Boolean | true | 是否支持翻转 |
34 | choosable | Boolean | true | 是否支持从本地选择素材 |
35 | angleSize | Number | 20 | 四个角尺寸,单位px |
36 | angleBorderWidth | Number | 2 | 四个角边框宽度,单位px |
37 | radius | Number | | 裁剪图片圆角半径,单位px |
38 | fileType | String | png | 生成文件的类型,只支持 'jpg' 或 'png'。默认为 'png' |
39 | delay | Number | 1000 | 图片从绘制到生成所需时间,单位ms<br>微信小程序平台使用 `Canvas 2D` 绘制时有效<br>如绘制大图或出现裁剪图片空白等情况应适当调大该值,因 `Canvas 2d` 采用同步绘制,需自己把控绘制完成时间 |
40 | navigation | Boolean | true | 页面是否是原生标题栏:<br>H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 `"navigationStyle": "custom"` 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差。<br>注:因H5平台的窗口高度是包含标题栏的,而屏幕触摸点的坐标是不包含的 |
41 | @crop | EventHandle | | 剪裁完成后触发,event = { tempFilePath }。在H5平台下,tempFilePath 为 base64 |
42
43 ### 基本用法
44 ```
45 <template>
46 <div>
47 <qf-image-cropper :width="500" :height="500" :radius="30" @crop="handleCrop"></qf-image-cropper>
48 </div>
49 </template>
50
51 <script>
52 import QfImageCropper from '@/components/qf-image-cropper/qf-image-cropper.vue';
53 export default {
54 components: {
55 QfImageCropper
56 },
57 methods: {
58 handleCrop(e) {
59 uni.previewImage({
60 urls: [e.tempFilePath],
61 current: 0
62 });
63 }
64 }
65 }
66 </script>
67 ```
68 ### 使用说明
69 1.建议在`pages.json`中将引用插件的页面添加一下配置禁止下拉刷新和禁止页面滑动,防止出现性能或页面抖动等问题。
70 ```
71 {
72 "enablePullDownRefresh": false,
73 "disableScroll": true
74 }
75 ```
76 2.建议使用本插件不要设置过大宽高的目标图片尺寸,建议1365x1365以内,否则可能会导致如下问题:
77 ```
78 1.界面卡顿,内存占用过高
79 2.生成图片失真(模糊)
80 3.确定裁剪后一直显示 `裁剪中...`,该问题是由 `uni.canvasToTempFilePath` 无法回调导致,不同平台不同设备限制可能有所不同。
81 ```
82 3.如裁剪后的图片存在偏移的问题,请检查是否受自己项目中父组件或全局样式影响。
83 4.src属性设置网络图片时,图片资源必须是能触发 `getImageInfo` API 的 success 回调才可用于插件裁剪。因此小程序平台获取网络图片信息需先配置download域名白名单才能生效。
...\ No newline at end of file ...\ 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!