9.5下午提交

This commit is contained in:
zoujiandong 2025-09-05 17:38:14 +08:00
parent ad39805b83
commit c711c53959
17 changed files with 4343 additions and 275 deletions

View File

@ -401,6 +401,27 @@ const api = {
polularScienceArticleListIndexNew(data){
return request('/expertAPI/polularScienceArticleListIndexNew', data, 'post', false);
},
meetingListBySearch(data){
return request('/expertAPI/meetingListBySearch', data, 'post', false);
},
getExpertByUuid(data){
return request('/expertAPI/getExpertByUuid', data, 'post', false);
},
getUserIcon(data){
return request('/expertAPI/getUserIcon', data, 'post', false);
},
addUserIcon(data){
return request('/expertAPI/addUserIcon', data, 'post', false);
},
getDiseaseList(data){
return request('/expertAPI/disease', data, 'post', false);
},
getPositionList(data){
return request('/expertAPI/positionList', data, 'post', false);
},
getOfficeList(data){
return request('/expertAPI/officeList', data, 'post', false);
},
}
export default api

View File

@ -208,6 +208,28 @@
}
}
},
{
"path": "writeInfo/writeInfo",
"style": {
"navigationStyle": "custom",
"navigationBarRightButton":{ "hide": true},
"navigationBarTitleText": "uni-app分页",
"app": {
"bounce": "none"
}
}
},
{
"path": "myApplication/myApplication",
"style": {
"navigationStyle": "custom",
"navigationBarRightButton":{ "hide": true},
"navigationBarTitleText": "uni-app分页",
"app": {
"bounce": "none"
}
}
},
{
"path": "searchVideo/searchVideo",
"style": {

View File

@ -112,7 +112,7 @@
switch(courseType) {
case 'course':
navTo({
url: '/pages_course/index/index'
url: '/pages_course/course/course'
})
break;
case 'video':

View File

@ -18,7 +18,7 @@
placeholderColor="#999999"
></uni-search-bar>
</view>
<view class="header-right">
<view class="header-right" @click="goMessage">
<view class="message-icon">
<uni-icons type="email" size="32" color="#fff"></uni-icons>
<view v-if="hasUnread" class="red-dot"></view>
@ -142,8 +142,9 @@
<view class="replay-section">
<view class="section-header">
<text class="section-title-text">精彩回放</text>
<view class="more-link">
<text class="more-text">更多<uni-icons type="right" size="32rpx" color="#999"></uni-icons></text>
<view class="more-link" @click="onReplayMore">
<text class="more-text">更多</text>
<uni-icons type="right" size="32rpx" color="#999"></uni-icons>
</view>
</view>
<view class="replay-grid">
@ -280,6 +281,11 @@
url:'/pages_app/search/search'
})
}
const onReplayMore=()=>{
navTo({
url:'/pages_app/video/video'
})
}
//
const headerBgColor = computed(() => {
// rpxpx
@ -439,28 +445,9 @@
//
const onReplayClick = (item) => {
console.log('点击精彩回放:', item);
uni.showToast({
title: `查看${item.content}`,
icon: 'none'
});
//
switch(item.type) {
case 'case_discussion':
uni.navigateTo({
url: '/pages/discussion/detail?id=' + item.id
});
break;
case 'new_topic':
uni.navigateTo({
url: '/pages/topic/detail?id=' + item.id
});
break;
default:
uni.navigateTo({
url: '/pages/replay/detail?id=' + item.id
});
}
navTo({
url: '/pages_course/course_detail/course_detail?id=' + item.id
})
};
//
@ -480,9 +467,9 @@
}else if(index==5){
url='/pages_app/ppt/ppt'
}else if(index==6){
url='/pages_course/index/index'
url='/pages_course/course/course'
}else{
url='/pages_app/search/search'
url='/pages_app/myApplication/myApplication'
}
navTo({
url:url
@ -512,10 +499,15 @@
//
const onGuideMore = () => {
console.log('查看更多实用指南');
uni.navigateTo({
url: '/pages/guide/list'
});
if(currentGuideTab.value==0){
navTo({
url: '/pages_app/zhinan/zhinan'
});
}else{
navTo({
url: '/pages_app/ppt/ppt'
});
}
};
const goNews=()=>{
let url=docUrl+signInfo.news.path;
@ -538,7 +530,9 @@
const onBannerClick = (item,index) => {
if (!item) return;
if(index==0){
navTo({
url:'/pages_app/personInfo/personInfo'
})
}else{
// path
if (item.path) {
@ -600,6 +594,11 @@
})
// #endif
};
const goMessage=()=>{
navTo({
url: '/pages_app/msg/msg'
})
}
//
const onGuideClick = (item) => {

View File

@ -33,49 +33,54 @@
:enable-back-to-top="true"
>
<!-- 时间标题 -->
<view class="time-header">2025年08月</view>
<!-- 会议列表 -->
<view class="meeting-list" v-if="meetingList.length > 0">
<view class="meeting-item" v-for="(item, index) in meetingList" :key="item.id || index">
<!-- 左侧日期标识 -->
<view class="date-tag" :style="{backgroundColor: item.tagColor}">
<text class="date-text">{{ item.date }}</text>
</view>
<template v-for="(group, groupIndex) in groupedMeetings" :key="groupIndex">
<view class="time-header">{{ group.monthYear }}</view>
<view class="meetcell" v-for="(item, index) in group.items" :key="item.id || index">
<view class="meeting-item">
<!-- 左侧日期标识 -->
<view class="date-tag" :style="{backgroundColor: item.tagColor}">
<text class="date-text">{{ formatDay(item.begin_date) }}</text>
</view>
<!-- 会议内容 -->
<view class="meeting-content">
<view class="meeting-title">{{ item.title }}</view>
<view class="meeting-poster" @click="playVideo(item)">
<image
:src="docUrl+item.liveimg"
class="poster-image"
mode="aspectFill"
@error="onImageError"
@load="onImageLoad"
:data-item-id="item.id"
></image>
<view class="play-btn">
<up-image :src="playImg" width="108rpx" height="108rpx" ></up-image>
</view>
<view class="preview-tag" v-if="item.status === 'upcoming'">预告</view>
<view class="live-tag" v-else-if="item.status === 'live'">直播中</view>
<view class="replay-tag" v-else-if="item.status === 'replay'">回放</view>
</view>
<view class="meeting-info">
<view class="info-item">
<view class="timebox">
<up-image :src="timeImg" width="24rpx" height="24rpx" ></up-image>
<!-- 会议内容 -->
<view class="meeting-content">
<view class="meeting-title">{{ item.title }}</view>
<view class="meeting-poster" @click="playVideo(item)">
<image
:src="docUrl+item.liveimg"
class="poster-image"
mode="aspectFill"
@error="onImageError"
@load="onImageLoad"
:data-item-id="item.id"
></image>
<view class="play-btn">
<up-image :src="playImg" width="108rpx" height="108rpx" ></up-image>
</view>
<view class="status-tag" :class="getStatusClass(item.status_code)">
{{ item.status }}
</view>
</view>
<view class="meeting-info">
<view class="info-item">
<view class="timebox">
<up-image :src="timeImg" width="24rpx" height="24rpx" ></up-image>
</view>
<text class="info-text">{{ formatDate(item.begin_date,'YYYY.MM.DD')+'-'+formatDate(item.end_date,'YYYY.MM.DD') }}</text>
</view>
<view class="info-item">
<uni-icons type="location" size="14" color="#999"></uni-icons>
<text class="info-text">{{ item.location }}</text>
</view>
</view>
<text class="info-text">{{ item.time }}</text>
</view>
<view class="info-item">
<uni-icons type="location" size="14" color="#999"></uni-icons>
<text class="info-text">{{ item.location }}</text>
</view>
</view>
</view>
</view>
</template>
</view>
<!-- 空状态 -->
@ -84,7 +89,6 @@
<uni-icons type="calendar" size="80" color="#ccc"></uni-icons>
</view>
<text class="empty-text">暂无会议数据</text>
<text class="empty-subtext">请稍后再试或调整筛选条件</text>
</view>
<!-- 加载更多提示 -->
@ -93,10 +97,10 @@
<uni-icons type="spinner-cycle" size="20" color="#999"></uni-icons>
<text class="load-more-text">加载中...</text>
</view>
<view class="load-more-content" v-else-if="hasMoreData">
<view class="load-more-content" v-else-if="hasMoreData && meetingList.length > 0">
<text class="load-more-text">上拉加载更多</text>
</view>
<view class="load-more-content" v-else>
<view class="load-more-content" v-else-if="meetingList.length > 0 && !isLoadingMore">
<text class="load-more-text">没有更多数据了</text>
</view>
</view>
@ -153,7 +157,7 @@
</template>
<script setup>
import { ref,nextTick} from 'vue';
import { ref, nextTick, computed } from 'vue';
import { onShow } from "@dcloudio/uni-app";
import CustomTabbar from '@/components/tabBar/tabBar.vue';
import api from '@/api/api.js';
@ -162,11 +166,12 @@
import playImg from "@/static/bofang.png"
import timeImg from "@/static/play_long.png"
import docUrl from "@/utils/docUrl"
import dayjs from 'dayjs'
//
const isTimePopupShow = ref(false);
const isLocationPopupShow = ref(false);
const selectedMonth = ref('all');
const selectedMonth = ref('');
const selectedProvince = ref('');
//
@ -179,81 +184,241 @@
const scrollTop = ref(0);
//
const monthList = ref([
{ value: 'all', label: '所有' },
{ value: '8', label: '8月' },
{ value: '9', label: '9月' },
{ value: '10', label: '10月' },
{ value: '11', label: '11月' },
{ value: '12', label: '12月' },
{ value: '1', label: '1月' },
{ value: '2', label: '2月' },
{ value: '3', label: '3月' },
{ value: '4', label: '4月' },
{ value: '5', label: '5月' },
{ value: '6', label: '6月' },
{ value: '7', label: '7月' }
]);
//
const monthList = ref([]);
const generateMonthList = () => {
const months = [];
let index=-1;
const currentDate = new Date();
const currentMonth = currentDate.getMonth() + 1; // (1-12)
const currentYear = currentDate.getFullYear();
// ""
months.push({ value: '', label: '所有' });
// 12
for (let month = currentMonth; month <= 12; month++) {
index++;
let monthLabel = '';
if (month === currentMonth) {
monthLabel = `${month}`;
} else if (month === currentMonth + 1) {
monthLabel = `${month}`;
} else {
monthLabel = `${month}`;
}
months.push({
value: index,
label: monthLabel,
year: currentYear,
month: month
});
}
// 1
for (let month = 1; month < currentMonth; month++) {
index++;
months.push({
value: index,
label: `${month}`,
year: currentYear + 1, //
month: month
});
}
monthList.value = months;
console.log('生成的月份列表:', monthList.value);
};
//
const provinceList = ref([
{ code: 'all', name: '全国' },
{ code: 'beijing', name: '北京市' },
{ code: 'tianjin', name: '天津市' },
{ code: 'hebei', name: '河北省' },
{ code: 'shanxi', name: '山西省' },
{ code: 'neimenggu', name: '内蒙古...' },
{ code: 'liaoning', name: '辽宁省' },
{ code: 'jilin', name: '吉林省' },
{ code: 'heilongjiang', name: '黑龙江省' },
{ code: 'shanghai', name: '上海市' },
{ code: 'jiangsu', name: '江苏省' },
{ code: 'zhejiang', name: '浙江省' },
{ code: 'anhui', name: '安徽省' },
{ code: 'fujian', name: '福建省' },
{ code: 'jiangxi', name: '江西省' },
{ code: 'shandong', name: '山东省' },
{ code: 'henan', name: '河南省' },
{ code: 'hubei', name: '湖北省' },
{ code: 'hunan', name: '湖南省' },
{ code: 'guangdong', name: '广东省' },
{ code: 'guangxi', name: '广西壮...' },
{ code: 'hainan', name: '海南省' },
{ code: 'chongqing', name: '重庆市' },
{ code: 'sichuan', name: '四川省' },
{ code: 'guizhou', name: '贵州省' },
{ code: 'yunnan', name: '云南省' },
{ code: 'xizang', name: '西藏自...' },
{ code: 'shaanxi', name: '陕西省' },
{ code: 'gansu', name: '甘肃省' },
{ code: 'qinghai', name: '青海省' },
{ code: 'ningxia', name: '宁夏回...' },
{ code: 'xinjiang', name: '新疆维...' },
{ code: 'taiwan', name: '台湾省' },
{ code: 'hongkong', name: '香港特...' },
{ code: 'macao', name: '澳门特...' }
{ code: '', name: '全国' },
{ code: '北京', name: '北京市' },
{ code: '天津', name: '天津市' },
{ code: '河北', name: '河北省' },
{ code: '山西', name: '山西省' },
{ code: '内蒙古', name: '内蒙古自治区' },
{ code: '辽宁', name: '辽宁省' },
{ code: '吉林', name: '吉林省' },
{ code: '黑龙江', name: '黑龙江省' },
{ code: '上海', name: '上海市' },
{ code: '江苏', name: '江苏省' },
{ code: '浙江', name: '浙江省' },
{ code: '安徽', name: '安徽省' },
{ code: '福建', name: '福建省' },
{ code: '江西', name: '江西省' },
{ code: '山东', name: '山东省' },
{ code: '河南', name: '河南省' },
{ code: '湖北', name: '湖北省' },
{ code: '湖南', name: '湖南省' },
{ code: '广东', name: '广东省' },
{ code: '广西', name: '广西壮族自治区' },
{ code: '海南', name: '海南省' },
{ code: '重庆', name: '重庆市' },
{ code: '四川', name: '四川省' },
{ code: '贵州', name: '贵州省' },
{ code: '云南', name: '云南省' },
{ code: '西藏', name: '西藏自治区' },
{ code: '陕西', name: '陕西省' },
{ code: '甘肃', name: '甘肃省' },
{ code: '青海', name: '青海省' },
{ code: '宁夏', name: '宁夏回族自治区' },
{ code: '新疆', name: '新疆维吾尔自治区' },
{ code: '台湾', name: '台湾省' },
{ code: '香港', name: '香港特别行政区' },
{ code: '澳门', name: '澳门特别行政区' }
]);
//
const meetingList = ref([]);
//
const groupedMeetings = computed(() => {
if (!meetingList.value || meetingList.value.length === 0) {
return [];
}
const groups = {};
meetingList.value.forEach(item => {
const monthYear = formatDate(item.begin_date, 'YYYY年MM月');
if (!groups[monthYear]) {
groups[monthYear] = {
monthYear: monthYear,
items: []
};
}
groups[monthYear].items.push(item);
});
//
return Object.values(groups).sort((a, b) => {
//
const dateA = dayjs(a.items[0].begin_date);
const dateB = dayjs(b.items[0].begin_date);
return dateA.isBefore(dateB) ? -1 : 1;
});
});
//
onShow(() => {
generateMonthList();
getMeetingList(true);
});
//
const getMeetingList = async (isRefresh = false) => {
const meetingListBySearch = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1;
hasMoreData.value = true;
meetingList.value = [];
}
const params = {
page: currentPage.value,
pageSize: pageSize.value,
month: selectedMonth.value !== 'all' ? selectedMonth.value : '',
province: selectedProvince.value !== 'all' ? selectedProvince.value : ''
month: selectedMonth.value,
location: selectedProvince.value,
status:''
};
try {
console.log('获取会议列表参数:', params);
const response = await api.meetingListBySearch(params);
console.log('会议列表API响应:', response);
if (response && response.code ==1 && response.data) {
const { list, totalPage, pageNumber, totalRow} = response.data;
console.log('解析后的数据:', { list, totalPage, pageNumber, totalRow});
if (Array.isArray(list) && list.length > 0) {
// API
const processedItems = list.map(item => {
//
const beginDate = dayjs(item.begin_date);
const day = beginDate.format('DD');
//
const tagColor = getTagColor(item.status);
//
const timeStr = formatDate(item.begin_date, 'YYYY.MM.DD');
return {
id: item.id,
begin_date: day, //
tagColor: tagColor,
title: item.title,
liveimg: item.liveimg,
poster: item.liveimg, // 使 liveimg
time: timeStr,
location: item.location || '线上',
status: getStatusText(item.status),
//
originalData: item,
begin_date_timestamp: item.begin_date_timestamp,
end_date_timestamp: item.end_date_timestamp,
begin_date: item.begin_date,
end_date: item.end_date,
liveurl: item.liveurl,
spareurl: item.spareurl,
path: item.path,
status_code: item.status
};
});
if (isRefresh) {
meetingList.value = processedItems;
} else {
meetingList.value.push(...processedItems);
}
//
hasMoreData.value = totalPage>pageNumber;
showLoadMore.value = totalRow > 0;
console.log('会议列表更新成功,当前总数:', meetingList.value.length);
console.log('分页信息:', { currentPage: currentPage.value, totalPage, pageNumber, totalRow, hasMore: hasMoreData.value });
} else {
console.log('API返回的数据为空');
if (isRefresh) {
meetingList.value = [];
}
}
} else {
console.log('API响应格式不正确:', response);
if (isRefresh) {
meetingList.value = [];
}
}
} catch (error) {
console.error('获取会议列表失败:', error);
if (isRefresh) {
meetingList.value = [];
}
uni.showToast({
title: '获取会议列表失败',
icon: 'error',
duration: 2000
});
}
};
//
const getMeetingList = async (isRefresh = false) => {
if (isRefresh) {
currentPage.value = 1;
hasMoreData.value = true;
meetingList.value = [];
}
const params = {
page: currentPage.value,
pageSize: pageSize.value,
month: selectedMonth.value,
location: selectedProvince.value
};
try {
@ -262,46 +427,45 @@
console.log('会议列表API响应:', response);
if (response && response.code === 200 && response.data) {
let newItems = [];
let totalCount = 0;
const { list, total, pageNum, pages, isLastPage } = response.data;
//
if (response.data.list && Array.isArray(response.data.list)) {
newItems = response.data.list;
totalCount = response.data.total || response.data.totalRow || 0;
console.log('使用 res.data.list 结构');
} else if (response.data && Array.isArray(response.data)) {
newItems = response.data;
totalCount = response.total || response.totalRow || newItems.length;
console.log('使用 res.data 结构');
} else if (Array.isArray(response)) {
newItems = response;
totalCount = newItems.length;
console.log('使用 res 数组结构');
}
console.log('解析后的数据:', { list, total, pageNum, pages, isLastPage });
console.log('解析后的数据:', { newItems, totalCount });
console.log('图片字段映射示例:', newItems.slice(0, 2).map(item => ({
liveimg: item.liveimg || item.live_image || item.live_img,
poster: item.poster || item.cover_image || item.image
})));
if (Array.isArray(list) && list.length > 0) {
// API
const processedItems = list.map(item => {
//
const beginDate = dayjs(item.begin_date);
const day = beginDate.format('DD');
if (Array.isArray(newItems) && newItems.length > 0) {
//
const processedItems = newItems.map(item => ({
id: item.id || item.meeting_id || Math.random().toString(36).substr(2, 9),
date: item.date || item.meeting_date || item.start_time || '13',
tagColor: getTagColor(item.status || item.meeting_status || 'upcoming'),
title: item.title || item.meeting_title || item.name || '会议标题',
liveimg: item.liveimg || item.live_image || item.live_img || '',
poster: item.poster || item.cover_image || item.image || '/static/meeting-poster-1.jpg',
time: formatMeetingTime(item.start_time || item.meeting_time || item.time),
location: item.location || item.address || item.venue || '线上',
status: item.status || item.meeting_status || 'upcoming',
description: item.description || item.content || '',
organizer: item.organizer || item.host || '',
speakers: item.speakers || item.experts || []
}));
//
const tagColor = getTagColor(item.status);
//
const timeStr = formatDate(item.begin_date, 'YYYY.MM.DD');
return {
id: item.id,
begin_date: day, //
tagColor: tagColor,
title: item.title,
liveimg: item.liveimg,
poster: item.liveimg, // 使 liveimg
time: timeStr,
location: item.location || '线上',
status: getStatusText(item.status),
//
originalData: item,
begin_date_timestamp: item.begin_date_timestamp,
end_date_timestamp: item.end_date_timestamp,
begin_date: item.begin_date,
end_date: item.end_date,
liveurl: item.liveurl,
spareurl: item.spareurl,
path: item.path,
status_code: item.status
};
});
if (isRefresh) {
meetingList.value = processedItems;
@ -309,18 +473,12 @@
meetingList.value.push(...processedItems);
}
//
if (meetingList.value.length >= totalCount) {
hasMoreData.value = false;
}
//
hasMoreData.value = !isLastPage;
showLoadMore.value = total > 0;
console.log('会议列表更新成功,当前总数:', meetingList.value.length);
console.log('图片字段详情:', processedItems.slice(0, 2).map(item => ({
id: item.id,
liveimg: item.liveimg,
poster: item.poster,
finalImage: item.liveimg || item.poster
})));
console.log('分页信息:', { currentPage: currentPage.value, total, pages, hasMore: hasMoreData.value });
} else {
console.log('API返回的数据为空');
if (isRefresh) {
@ -349,40 +507,105 @@
//
const getTagColor = (status) => {
const colorMap = {
'upcoming': '#FF4444', //
'live': '#00BCD4', //
'replay': '#9C27B0', //
'finished': '#4CAF50', //
'cancelled': '#FF9800' //
1: '#FF4444', //
2: '#00BCD4', //
3: '#9C27B0', // /
4: '#4CAF50', //
5: '#FF9800' //
};
return colorMap[status] || '#FF4444';
};
//
const formatMeetingTime = (timeStr) => {
if (!timeStr) return '2025.08.13';
//
const getStatusText = (status) => {
const statusMap = {
1: '预告',
2: '直播中',
3: '已结束',
4: '已完成',
5: '已取消'
};
return statusMap[status] || '预告';
};
//
const getStatusClass = (status) => {
const classMap = {
1: 'status-preview',
2: 'status-live',
3: 'status-ended',
4: 'status-completed',
5: 'status-cancelled'
};
return classMap[status] || 'status-preview';
};
// 使 dayjs
const formatDate = (date, format = 'YYYY-MM') => {
if (!date) return '';
try {
//
if (typeof timeStr === 'number') {
const date = new Date(timeStr);
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
// 使 dayjs
const dayjsDate = dayjs(date);
//
if (!dayjsDate.isValid()) {
console.error('无效的日期:', date);
return '';
}
//
const date = new Date(timeStr);
if (!isNaN(date.getTime())) {
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
//
switch (format) {
case 'YYYY-MM':
return dayjsDate.format('YYYY-MM');
case 'YYYY-MM-DD':
return dayjsDate.format('YYYY-MM-DD');
case 'YYYY-MM-DD HH:mm':
return dayjsDate.format('YYYY-MM-DD HH:mm');
case 'YYYY-MM-DD HH:mm:ss':
return dayjsDate.format('YYYY-MM-DD HH:mm:ss');
case 'YYYY年MM月':
return dayjsDate.format('YYYY年MM月');
case 'YYYY年MM月DD日':
return dayjsDate.format('YYYY年MM月DD日');
case 'MM月':
return dayjsDate.format('MM月');
case 'MM月DD日':
return dayjsDate.format('MM月DD日');
case 'YYYY.MM.DD':
return dayjsDate.format('YYYY.MM.DD');
case 'MM-DD':
return dayjsDate.format('MM-DD');
case 'MM/DD':
return dayjsDate.format('MM/DD');
case 'YYYY/MM/DD':
return dayjsDate.format('YYYY/MM/DD');
default:
return dayjsDate.format(format);
}
//
return timeStr;
} catch (error) {
console.error('时间格式化失败:', error);
return timeStr;
console.error('日期格式化失败:', error);
return '';
}
};
//
const formatMeetingTime = (timeStr) => {
if (!timeStr) return '';
try {
// 使 dayjs
const formattedDate = formatDate(timeStr, 'YYYY年MM月');
return formattedDate || '';
} catch (error) {
console.error('时间格式化失败:', error);
return '';
}
};
const formatDay=(timeStr)=>{
if (!timeStr) return '';
return formatDate(timeStr, 'DD');
}
//
const showTimePopup = () => {
isTimePopupShow.value = !isTimePopupShow.value;
@ -398,7 +621,7 @@
selectedMonth.value = month.value;
console.log('选择月份:', month.label);
//
getMeetingList(true);
meetingListBySearch(true);
hideTimePopup();
};
@ -417,7 +640,7 @@
selectedProvince.value = province.code;
console.log('选择省份:', province.name);
//
getMeetingList(true);
meetingListBySearch(true);
hideLocationPopup();
};
@ -803,37 +1026,37 @@ $shadow: 0 2px 8px rgba(0,0,0,0.1);
justify-content: center;
}
.preview-tag {
.status-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
background-color: #FF4444;
color: $white;
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
color: $white;
font-weight: 500;
border: 2rpx solid #fff;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.2);
.live-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
background-color: #00BCD4;
color: $white;
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
&.status-preview {
background-color: #FF4444;
}
.replay-tag {
position: absolute;
top: 16rpx;
right: 16rpx;
background-color: #9C27B0;
color: $white;
font-size: 24rpx;
padding: 4rpx 16rpx;
border-radius: 20rpx;
&.status-live {
background-color: #00BCD4;
}
&.status-ended {
background-color: #9C27B0;
}
&.status-completed {
background-color: #4CAF50;
}
&.status-cancelled {
background-color: #FF9800;
}
}
}

View File

@ -168,30 +168,37 @@
const generateMonthList = () => {
const months = [];
const currentDate = new Date();
const currentMonth = currentDate.getMonth() + 1; // (1-12)
const currentYear = currentDate.getFullYear();
// ""
months.push({ value: 'all', label: '所有' });
// 12
for (let i = 0; i < 12; i++) {
const targetDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + i, 1);
const month = targetDate.getMonth() + 1;
const year = targetDate.getFullYear();
//
// 12
for (let month = currentMonth; month <= 12; month++) {
let monthLabel = '';
if (i === 0) {
if (month === currentMonth) {
monthLabel = `${month}月(本月)`;
} else if (i === 1) {
} else if (month === currentMonth + 1) {
monthLabel = `${month}月(下月)`;
} else {
monthLabel = `${month}`;
}
months.push({
value: month.toString(), //
value: month.toString(),
label: monthLabel,
year: year,
year: currentYear,
month: month
});
}
// 1
for (let month = 1; month < currentMonth; month++) {
months.push({
value: month.toString(),
label: `${month}`,
year: currentYear + 1, //
month: month
});
}
@ -754,7 +761,50 @@
}
};
onMounted(() => {
//
const formatDate = (date, format = 'YYYY-MM') => {
if (!date) return '';
// Date
const dateObj = typeof date === 'string' ? new Date(date) : date;
//
if (isNaN(dateObj.getTime())) {
console.error('无效的日期:', date);
return '';
}
const year = dateObj.getFullYear();
const month = (dateObj.getMonth() + 1).toString().padStart(2, '0');
const day = dateObj.getDate().toString().padStart(2, '0');
const hours = dateObj.getHours().toString().padStart(2, '0');
const minutes = dateObj.getMinutes().toString().padStart(2, '0');
const seconds = dateObj.getSeconds().toString().padStart(2, '0');
//
switch (format) {
case 'YYYY-MM':
return `${year}-${month}`;
case 'YYYY-MM-DD':
return `${year}-${month}-${day}`;
case 'YYYY-MM-DD HH:mm':
return `${year}-${month}-${day} ${hours}:${minutes}`;
case 'YYYY-MM-DD HH:mm:ss':
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
case 'YYYY年MM月':
return `${year}${month}`;
case 'YYYY年MM月DD日':
return `${year}${month}${day}`;
case 'MM月':
return `${month}`;
case 'MM月DD日':
return `${month}${day}`;
default:
return `${year}-${month}`;
}
};
onShow(() => {
//
generateMonthList();
//

View File

@ -0,0 +1,422 @@
<template>
<view class="my-application-page">
<!-- 顶部导航栏 -->
<uni-nav-bar
left-icon="left"
title="我的应用"
@clickLeft="goBack"
fixed
color="#8B2316"
height="140rpx"
:border="false"
backgroundColor="#eeeeee"
>
<template #right>
<text class="edit-btn" @click="toggleEdit">{{
isEditMode ? "完成" : "编辑"
}}</text>
</template>
</uni-nav-bar>
<scroll-view class="content" scroll-y>
<!-- 我的应用部分 -->
<view class="section">
<view class="section-title">我的应用</view>
<view class="app-grid">
<view
class="app-item"
v-for="(app, index) in myApps"
:key="app.id"
@click="openApp(app)"
>
<view
class="iconbox"
v-if="isEditMode"
@click.stop="removeFromMyApps(index)"
>
<up-icon
name="minus-circle"
color="#fe0001"
size="40rpx"
></up-icon>
</view>
<view class="app-icon">
<image :src="app.icon" mode="aspectFit" class="icon-image" />
</view>
<text class="app-name">{{ app.name }}</text>
</view>
</view>
</view>
<!-- 常用功能部分 -->
<view class="section">
<view class="section-title">常用功能</view>
<view class="app-grid">
<view
class="app-item"
v-for="(app, index) in commonApps"
:key="app.id"
@click="openApp(app)"
>
<view
class="iconbox"
@click.stop="addToMyApps(app)"
v-if="isEditMode"
>
<up-icon
name="plus-circle"
color="#067fe6"
size="40rpx"
></up-icon>
</view>
<view class="app-icon">
<image :src="app.icon" mode="aspectFit" class="icon-image" />
</view>
<text class="app-name">{{ app.name }}</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from "@dcloudio/uni-app";
import api from "@/api/api";
import docUrl from '@/utils/docUrl';
//
const isEditMode = ref(false);
//
const myApps = ref([]);
//
const commonApps = ref([]);
const blackList=['临床研究','精品课程','名医名科','肝病学院','病例分享','科研项目','肝病检测','我的下载','病例讨论','临床研究']
//
//
const addUserIcon = () => {
api.addUserIcon({
idStr:myApps.value.map(item => item.id).join(',')
}).then(res => {
console.log('addUserIcon API响应:', res);
if(res.code === '200'){
uni.showToast({
title: '保存成功',
icon: 'none',
duration: 1500
});
}
});
};
const getUserIcon = () => {
api.getUserIcon({}).then(res => {
console.log('getUserIcon API响应:', res);
if (res.code === '200' && res.data && Array.isArray(res.data)) {
// API
const sourceList = res.data.filter(item => !blackList.includes(item.name));
const allApps = sourceList.map(item => ({
id: item.id,
name: item.name,
icon: docUrl + item.img, // 使docUrl
bgColor: bgColorMap[item.id] || '#999999', // 使
url: urlMap[item.id] || '', // 使
selected: item.selected
}));
// selected
myApps.value = allApps.filter(app =>
app.selected !== 'false' && app.selected !== false
);
commonApps.value = allApps.filter(app =>
app.selected === 'false' || app.selected === false
);
console.log('我的应用:', myApps.value);
console.log('常用功能:', commonApps.value);
} else {
console.error('获取应用图标失败:', res.message || '未知错误');
uni.showToast({
title: res.message || '获取应用列表失败',
icon: 'error',
duration: 2000
});
}
}).catch(error => {
console.error('获取应用图标异常:', error);
uni.showToast({
title: '网络请求失败',
icon: 'error',
duration: 2000
});
});
};
//
const goBack = () => {
uni.navigateBack();
};
//
const toggleEdit = () => {
isEditMode.value = !isEditMode.value;
if(!isEditMode.value){
addUserIcon();
}
};
//
const openApp = (app) => {
if (isEditMode.value) return;
let url='';
if(app.name=="肝胆课件"){
url='/pages_app/ppt/ppt'
navTo({
url:url
})
}else if(app.name=="精品课"){
url='/pages_course/course/course'
navTo({
url:url
})
}else if(app.name=="积分商城"){
url='/pages_goods/pointMall/pointMall'
navTo({
url:url
})
}else if(app.name=="肝病新闻"){
url='/pages_app/news/news'
navTo({
url:url
})
}else if(app.name=="我的福利"){
url='/pages_app/myWelfare/myWelfare'
navTo({
url:url
})
}else if(app.name=="专题e站"){
url='https://wx.igandan.com/Esite/index.htm#/home?fromtype=doctor'
// H5
// #ifdef H5
window.open(url, '_blank');
// #endif
// App
// #ifdef APP-PLUS
plus.runtime.openURL(url);
// #endif
// 使 webview
// #ifdef MP
const encoded = encodeURIComponent(url)
uni.navigateTo({
url: `/pages_app/webview/webview?url=${encoded}`
})
// #endif
}
};
//
const addToMyApps = (app) => {
//
const exists = myApps.value.some(item => item.id === app.id);
if (exists) {
uni.showToast({
title: '已在我的应用中',
icon: 'none',
duration: 1500
});
return;
}
//
if (myApps.value.length >= 3) {
uni.showToast({
title: '最多添加3个应用',
icon: 'none',
duration: 1500
});
return;
}
//
app.selected = '1';
//
const commonIndex = commonApps.value.findIndex(item => item.id === app.id);
if (commonIndex > -1) {
commonApps.value.splice(commonIndex, 1);
}
//
myApps.value.push(app);
};
//
const removeFromMyApps = (index) => {
const app = myApps.value[index];
//
app.selected = 'false';
//
myApps.value.splice(index, 1);
//
commonApps.value.push(app);
};
onShow(() => {
//
getUserIcon();
});
</script>
<style lang="scss" scoped>
.my-application-page {
background-color: #ffffff;
min-height: 100vh;
}
.content {
position: fixed;
top: calc(var(--status-bar-height) + 64px);
bottom: 0;
left: 0;
width: 100%;
right: 0;
box-sizing: border-box;
background-color: #fff;
}
.edit-btn {
color: #8b2316;
font-size: 28rpx;
font-weight: 500;
}
.section {
margin-bottom: 40rpx;
}
.section-title {
color: #666666;
font-size: 28rpx;
margin-bottom: 20rpx;
padding: 0 30rpx;
}
.app-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
border: 1rpx solid #e0e0e0;
border-left: none;
border-right: none;
}
.app-item {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 10rpx;
border-right: 1rpx solid #e0e0e0;
border-bottom: 1rpx solid #e0e0e0;
transition: all 0.3s ease;
.iconbox {
position: absolute;
top: 8rpx;
right: 8rpx;
}
&:active {
transform: scale(0.95);
}
//
&:nth-child(3n) {
border-right: none;
}
//
&:nth-last-child(-n + 3) {
border-bottom: none;
}
}
.app-icon {
width: 100rpx;
height: 100rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.icon-image {
width: 60rpx;
height: 60rpx;
}
.app-name {
font-size: 24rpx;
color: #000;
text-align: center;
line-height: 1.2;
font-weight: 500;
}
.delete-btn {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 32rpx;
height: 32rpx;
background-color: #ff4757;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(255, 71, 87, 0.3);
}
.add-btn {
position: absolute;
top: -8rpx;
right: -8rpx;
width: 32rpx;
height: 32rpx;
background-color: #ffffff;
border: 2rpx solid #8b2316;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2rpx 8rpx rgba(139, 35, 22, 0.2);
}
//
.app-item:has(.delete-btn) {
border-right-color: #ff4757;
border-bottom-color: #ff4757;
}
.app-item:has(.add-btn) {
border-right-color: #8b2316;
border-bottom-color: #8b2316;
}
</style>

View File

@ -20,7 +20,7 @@
<view class="row" @click="onChooseAvatar">
<view class="label"><text>头像</text><text class="req">*</text></view>
<view class="value value-avatar">
<image :src="form.avatar" mode="aspectFill" class="avatar" />
<image :src="docUrl+form.photo" mode="aspectFill" class="avatar" />
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -28,14 +28,14 @@
<!-- 姓名 -->
<view class="row">
<view class="label"><text>姓名</text><text class="req">*</text></view>
<view class="value">{{ form.name }}</view>
<view class="value">{{ form.realName }}</view>
</view>
<!-- 性别 -->
<view class="row" @click="onPickGender">
<view class="label"><text>性别</text><text class="req">*</text></view>
<view class="value with-arrow">
<text>{{ form.gender }}</text>
<text>{{ getGenderText(form.sex) }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -44,7 +44,7 @@
<view class="row" @click="onPickBirthday">
<view class="label"><text>出生日期</text></view>
<view class="value with-arrow">
<text>{{ form.birthday || '' }}</text>
<text>{{ form.birthDate || '' }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -70,11 +70,12 @@
<!-- 专业资料分组 -->
<view class="section-header">专业资料</view>
<!-- 医院 -->
<view class="row" @click="onPickHospital">
<!-- 医院地址 -->
<view class="row" @click="openAreaPicker">
<view class="label"><text>医院</text><text class="req">*</text></view>
<view class="value with-arrow">
<text>{{ form.hospital }}</text>
<text v-if="!regionText" class="placeholder">请选择医院</text>
<text v-else>{{ regionText }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -83,7 +84,7 @@
<view class="row" @click="onPickDept">
<view class="label"><text>科室</text><text class="req">*</text></view>
<view class="value with-arrow">
<text>{{ form.department }}</text>
<text>{{ form.officeName }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -92,7 +93,7 @@
<view class="row" @click="onEditDeptPhone">
<view class="label"><text>科室电话</text><text class="req">*</text></view>
<view class="value with-arrow">
<text>{{ form.departmentPhone }}</text>
<text>{{ form.officePhone }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -101,7 +102,7 @@
<view class="row" @click="onPickTitle">
<view class="label"><text>职称</text></view>
<view class="value with-arrow">
<text>{{ form.title }}</text>
<text>{{ form.positionName }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -110,7 +111,7 @@
<view class="row" @click="onEditLicenseNo">
<view class="label"><text>执业医师证编号</text><text class="req">*</text></view>
<view class="value with-arrow">
<text>{{ form.licenseNo }}</text>
<text>{{ form.certificate }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -119,7 +120,7 @@
<view class="row" @click="onChooseLicenseImg">
<view class="label"><text>执业医师证图片或胸牌</text><text class="req">*</text></view>
<view class="value value-license">
<image :src="form.licenseImg" mode="aspectFill" class="license-thumb" />
<image :src="docUrl+form.certificateImg" mode="aspectFill" class="license-thumb" />
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -128,7 +129,7 @@
<view class="row" @click="onEditSpecialty">
<view class="label"><text>专长</text></view>
<view class="value with-arrow">
<text>{{ form.specialty }}</text>
<text>{{ getSpecialtyText() }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
@ -137,35 +138,250 @@
<view class="row" @click="onEditIntro">
<view class="label"><text>个人简介</text></view>
<view class="value with-arrow">
<text>{{ form.intro || '暂无简介' }}</text>
<uni-icons type="right" size="18" color="#bbb" />
</view>
</view>
</scroll-view>
<!-- 省市区选择器 -->
<view v-if="showAreaPicker" class="picker-mask" @click="closeAreaPicker"></view>
<view v-if="showAreaPicker" class="picker-panel">
<view class="picker-header">
<text class="picker-btn" @click="closeAreaPicker">取消</text>
<text class="picker-title">选择地区</text>
<text class="picker-btn ok" @click="confirmArea">确定</text>
</view>
<picker-view v-if="provinces.length && cities.length && areas.length" class="picker-view" :indicator-style="indicatorStyle" :value="pickerIndex" @change="onAreaChange">
<picker-view-column>
<view v-for="(p,pi) in provinces" :key="pi" class="picker-item">{{ p.name }}</view>
</picker-view-column>
<picker-view-column>
<view v-for="(c,ci) in cities" :key="ci" class="picker-item">{{ c.name }}</view>
</picker-view-column>
<picker-view-column>
<view v-for="(a,ai) in areas" :key="ai" class="picker-item">{{ a.name }}</view>
</picker-view-column>
</picker-view>
<view v-else class="picker-empty">地区数据加载中...</view>
</view>
<!-- 日期选择器 -->
<view v-if="showDatePicker" class="picker-mask" @click="closeDatePicker"></view>
<view v-if="showDatePicker" class="picker-panel">
<view class="picker-header">
<text class="picker-btn" @click="closeDatePicker">取消</text>
<text class="picker-title">选择出生日期</text>
<text class="picker-btn ok" @click="confirmDate">确定</text>
</view>
<picker-view class="picker-view" :indicator-style="indicatorStyle" :value="datePickerIndex" @change="onDateChange">
<picker-view-column>
<view v-for="(year, index) in years" :key="index" class="picker-item">{{ year }}</view>
</picker-view-column>
<picker-view-column>
<view v-for="(month, index) in months" :key="index" class="picker-item">{{ month }}</view>
</picker-view-column>
<picker-view-column>
<view v-for="(day, index) in days" :key="index" class="picker-item">{{ day }}</view>
</picker-view-column>
</picker-view>
</view>
<!-- 科室选择器 -->
<view v-if="showDeptPicker" class="picker-mask" @click="closeDeptPicker"></view>
<view v-if="showDeptPicker" class="picker-panel">
<view class="picker-header">
<text class="picker-btn" @click="closeDeptPicker">取消</text>
<text class="picker-title">选择科室</text>
<text class="picker-btn ok" @click="confirmDept">确定</text>
</view>
<picker-view class="picker-view" :indicator-style="indicatorStyle" :value="deptPickerIndex" @change="onDeptChange">
<picker-view-column>
<view v-for="(dept, index) in deptList" :key="index" class="picker-item">{{ dept }}</view>
</picker-view-column>
</picker-view>
</view>
<!-- 职称选择器 -->
<view v-if="showTitlePicker" class="picker-mask" @click="closeTitlePicker"></view>
<view v-if="showTitlePicker" class="picker-panel">
<view class="picker-header">
<text class="picker-btn" @click="closeTitlePicker">取消</text>
<text class="picker-title">选择职称</text>
<text class="picker-btn ok" @click="confirmTitle">确定</text>
</view>
<picker-view class="picker-view" :indicator-style="indicatorStyle" :value="titlePickerIndex" @change="onTitleChange">
<picker-view-column>
<view v-for="(title, index) in titleList" :key="index" class="picker-item">{{ title }}</view>
</picker-view-column>
</picker-view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onShow } from "@dcloudio/uni-app";
import api from '@/api/api';
import areaList from '@/utils/areaList.js';
import docUrl from '@/utils/docUrl';
const form = ref({
avatar: '/static/big_background_my.png',
name: '邱建东',
gender: '男',
birthday: '',
mobile: '17600668628',
//
photo: '',
realName: '',
sex: 0,
birthDate: '',
mobile: '',
email: '',
hospital: '北京博爱医院',
department: '肝病科',
departmentPhone: '1234567890',
title: '主任中医师',
licenseNo: '12345678',
licenseImg: '/static/big_background_my.png',
specialty: '肝炎、肝硬化'
//
hospitalName: '',
officeName: '',
officePhone: '',
positionName: '',
certificate: '',
certificateImg: '',
intro: '',
//
provId: 0,
cityId: 0,
countyId: 0,
//
specialityList: []
});
//
const regionText = ref('');
const provinces = ref([]);
const cities = ref([]);
const areas = ref([]);
const pickerIndex = ref([0,0,0]);
const showAreaPicker = ref(false);
const indicatorStyle = `height: 80rpx;`;
//
const showDatePicker = ref(false);
const years = ref([]);
const months = ref([]);
const days = ref([]);
const datePickerIndex = ref([0, 0, 0]);
//
const showDeptPicker = ref(false);
const deptList = ref([
'内科', '外科', '妇产科', '儿科', '眼科', '耳鼻喉科', '口腔科', '皮肤科',
'神经科', '精神科', '肿瘤科', '康复科', '急诊科', '麻醉科', '病理科',
'检验科', '影像科', '药剂科', '护理部', '肝病科', '消化科', '心内科',
'呼吸科', '肾内科', '内分泌科', '血液科', '风湿科', '感染科', 'ICU',
'手术室', '门诊部', '住院部', '体检中心', '其他'
]);
const deptPickerIndex = ref([0]);
//
const showTitlePicker = ref(false);
const titleList = ref([
'住院医师', '主治医师', '副主任医师', '主任医师',
'住院中医师', '主治中医师', '副主任中医师', '主任中医师',
'住院药师', '主管药师', '副主任药师', '主任药师',
'住院护师', '主管护师', '副主任护师', '主任护师',
'住院技师', '主管技师', '副主任技师', '主任技师',
'助理医师', '执业医师', '执业助理医师',
'医士', '医师', '主管医师', '副主任医师', '主任医师',
'护士', '护师', '主管护师', '副主任护师', '主任护师',
'技师', '主管技师', '副主任技师', '主任技师',
'其他'
]);
const titlePickerIndex = ref([0]);
const goBack = () => uni.navigateBack();
//
const getGenderText = (sex) => {
const genderMap = { 0: '女', 1: '男' };
return genderMap[sex] || '';
};
const getDiseaseList = async () => {
const res = await api.getDiseaseList({});
console.log('疾病列表API响应:', res);
if (res.code === 200) {
deptList.value = res.data.diseaseList.map(item => item.diseaseName);
}
};
const getPositionList = async () => {
const res = await api.getPositionList({});
console.log('职称列表API响应:', res);
if (res.code === 200 ) {
titleList.value = res.data.positionList.map(item => item.positionName);
}
};
const getOfficeList = async () => {
const res = await api.getOfficeList({});
console.log('科室列表API响应:', res);
if (res.code === 200 ) {
deptList.value = res.data.officeList.map(item => item.officeName);
}
};
//
const getSpecialtyText = () => {
if (!form.value.specialityList || form.value.specialityList.length === 0) {
return '暂无专长';
}
return form.value.specialityList.map(item => item.diseaseName).join('、');
};
const getExpertByUuid = async () => {
try {
const res = await api.getExpertByUuid({});
console.log('专家信息API响应:', res);
if (res.code === 200 && res.data && res.data.expert) {
const expert = res.data.expert;
const specialityList = res.data.specialityList || [];
// 使API
form.value = {
photo: expert.photo || '',
realName: expert.realName || '',
sex: expert.sex || 0,
birthDate: expert.birthDate || '',
mobile: expert.mobile || '',
email: expert.email || '',
hospitalName: expert.hospitalName || '',
officeName: expert.officeName || '',
officePhone: expert.officePhone || '',
positionName: expert.positionName || '',
certificate: expert.certificate || '',
certificateImg: expert.certificateImg || '',
intro: expert.intro || '',
provId: expert.provId || 0,
cityId: expert.cityId || 0,
countyId: expert.countyId || 0,
specialityList: specialityList
};
// ID
if (expert.provId && expert.cityId && expert.countyId) {
// ID
regionText.value = expert.hospitalName || '';
}
console.log('解析后的表单数据:', form.value);
} else {
console.error('获取专家信息失败:', res.msg || '未知错误');
uni.showToast({
title: res.msg || '获取个人信息失败',
icon: 'error',
duration: 2000
});
}
} catch (error) {
console.error('获取专家信息异常:', error);
uni.showToast({
title: '网络请求失败',
icon: 'error',
duration: 2000
});
}
};
//
const openCropper = (callback, opts = {}) => {
const { destWidth = 600, rectWidth = 300, fileType = 'jpg' } = opts;
@ -192,20 +408,242 @@
}, { destWidth: 1000, rectWidth: 500 });
};
//
const normalizeNode = (node) => ({
code: node?.code || '',
name: node?.label || node?.value || node?.name || '',
children: Array.isArray(node?.children) ? node.children : []
});
const getAreaTree = () => {
const raw = areaList && (areaList.default || areaList);
return Array.isArray(raw) ? raw.map(normalizeNode) : [];
};
const buildData = () => {
const tree = getAreaTree();
provinces.value = tree.length ? tree : [{ code: '', name: '', children: [] }];
const pIdx = Math.min(pickerIndex.value[0], Math.max(provinces.value.length - 1, 0));
const pNode = provinces.value[pIdx];
cities.value = (pNode?.children || []).map(normalizeNode);
if (!cities.value.length) cities.value = [{ code: '', name: '', children: [] }];
const cIdx = Math.min(pickerIndex.value[1], Math.max(cities.value.length - 1, 0));
const cNode = cities.value[cIdx];
areas.value = (cNode?.children || []).map(normalizeNode);
if (!areas.value.length) areas.value = [{ code: '', name: '' }];
};
const openAreaPicker = () => {
showAreaPicker.value = true;
pickerIndex.value = [0,0,0];
buildData();
};
const closeAreaPicker = () => {
showAreaPicker.value = false;
};
const onAreaChange = (e) => {
const val = (e && e.detail && e.detail.value) ? e.detail.value : [0,0,0];
const [pi, ci, ai] = val;
if (pi !== pickerIndex.value[0]) {
pickerIndex.value = [pi, 0, 0];
buildData();
return;
}
if (ci !== pickerIndex.value[1]) {
pickerIndex.value = [pi, ci, 0];
buildData();
return;
}
pickerIndex.value = [pi, ci, ai];
};
const confirmArea = () => {
const p = provinces.value[pickerIndex.value[0]];
const c = cities.value[pickerIndex.value[1]];
const a = areas.value[pickerIndex.value[2]];
regionText.value = [p?.name, c?.name, a?.name].filter(Boolean).join(' ');
//
form.value.hospitalName = regionText.value;
closeAreaPicker();
};
//
const initDatePicker = () => {
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth() + 1;
const currentDay = currentDate.getDate();
// 1900
years.value = [];
for (let year = currentYear; year >= 1900; year--) {
years.value.push(year);
}
//
months.value = [];
for (let month = 1; month <= 12; month++) {
months.value.push(month);
}
//
updateDays(currentYear, currentMonth);
//
datePickerIndex.value = [0, currentMonth - 1, currentDay - 1];
};
const updateDays = (year, month) => {
const daysInMonth = new Date(year, month, 0).getDate();
days.value = [];
for (let day = 1; day <= daysInMonth; day++) {
days.value.push(day);
}
};
const openDatePicker = () => {
showDatePicker.value = true;
initDatePicker();
};
const closeDatePicker = () => {
showDatePicker.value = false;
};
const onDateChange = (e) => {
const val = e.detail.value;
const [yearIndex, monthIndex, dayIndex] = val;
const selectedYear = years.value[yearIndex];
const selectedMonth = months.value[monthIndex];
//
updateDays(selectedYear, selectedMonth);
//
const selectedDate = new Date(selectedYear, selectedMonth - 1, days.value[dayIndex] || 1);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (selectedDate > today) {
//
const currentDate = new Date();
const currentYear = currentDate.getFullYear();
const currentMonth = currentDate.getMonth() + 1;
const currentDay = currentDate.getDate();
const yearIdx = years.value.findIndex(y => y === currentYear);
const monthIdx = months.value.findIndex(m => m === currentMonth);
const dayIdx = days.value.findIndex(d => d === currentDay);
if (yearIdx !== -1 && monthIdx !== -1 && dayIdx !== -1) {
datePickerIndex.value = [yearIdx, monthIdx, dayIdx];
}
} else {
datePickerIndex.value = val;
}
};
const confirmDate = () => {
const [yearIndex, monthIndex, dayIndex] = datePickerIndex.value;
const selectedYear = years.value[yearIndex];
const selectedMonth = months.value[monthIndex];
const selectedDay = days.value[dayIndex];
//
const formattedDate = `${selectedYear}-${String(selectedMonth).padStart(2, '0')}-${String(selectedDay).padStart(2, '0')}`;
//
form.value.birthDate = formattedDate;
closeDatePicker();
};
//
const openDeptPicker = () => {
showDeptPicker.value = true;
//
if (form.value.officeName) {
const index = deptList.value.findIndex(dept => dept === form.value.officeName);
if (index !== -1) {
deptPickerIndex.value = [index];
}
}
};
const closeDeptPicker = () => {
showDeptPicker.value = false;
};
const onDeptChange = (e) => {
const val = e.detail.value;
deptPickerIndex.value = val;
};
const confirmDept = () => {
const selectedIndex = deptPickerIndex.value[0];
const selectedDept = deptList.value[selectedIndex];
//
form.value.officeName = selectedDept;
closeDeptPicker();
};
//
const openTitlePicker = () => {
showTitlePicker.value = true;
//
if (form.value.positionName) {
const index = titleList.value.findIndex(title => title === form.value.positionName);
if (index !== -1) {
titlePickerIndex.value = [index];
}
}
};
const closeTitlePicker = () => {
showTitlePicker.value = false;
};
const onTitleChange = (e) => {
const val = e.detail.value;
titlePickerIndex.value = val;
};
const confirmTitle = () => {
const selectedIndex = titlePickerIndex.value[0];
const selectedTitle = titleList.value[selectedIndex];
//
form.value.positionName = selectedTitle;
closeTitlePicker();
};
const onPickGender = () => {};
const onPickBirthday = () => {};
const onPickBirthday = () => {
openDatePicker();
};
const onEditPhone = () => {};
const onEditEmail = () => {};
const onPickHospital = () => {};
const onPickDept = () => {};
const onPickDept = () => {
openDeptPicker();
};
const onEditDeptPhone = () => {};
const onPickTitle = () => {};
const onPickTitle = () => {
openTitlePicker();
};
const onEditLicenseNo = () => {};
const onEditSpecialty = () => {};
const onEditIntro = () => {};
onShow(() => {
//
getExpertByUuid();
getPositionList();
});
</script>
@ -277,4 +715,67 @@
border-radius: 8rpx;
background-color: #f5f5f5;
}
.placeholder {
color: #bbb;
}
/* 地区选择器样式 */
.picker-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0,0,0,0.4);
z-index: 1000;
}
.picker-panel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #fff;
z-index: 1001;
}
.picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 24rpx;
border-bottom: 1rpx solid #eee;
}
.picker-title {
font-size: 30rpx;
color: #333;
}
.picker-btn {
color: #666;
font-size: 28rpx;
}
.picker-btn.ok {
color: #38c1b1;
}
.picker-view {
height: 480rpx;
}
.picker-item {
height: 80rpx;
line-height: 80rpx;
text-align: center;
color: #333;
}
.picker-empty {
padding: 40rpx;
text-align: center;
color: #999;
}
</style>

View File

@ -165,7 +165,7 @@
})
}else if(type.value=='course'){
uni.navigateTo({
url: `/pages_course/index/index?keywords=${searchKeyword.value}`
url: `/pages_course/course/course?keywords=${searchKeyword.value}`
})
}else if(type.value=='ppt'){
uni.navigateTo({
@ -173,7 +173,7 @@
})
}else if(type.value=='mall'){
uni.navigateTo({
url: `/pages_app/pointMall/pointMall?keywords=${searchKeyword.value}`
url: `/pages_goods/pointMall/pointMall?keywords=${searchKeyword.value}`
})
}
}

View File

@ -0,0 +1,123 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<uni-nav-bar
left-icon="left"
title="修改邮箱"
@clickLeft="goBack"
fixed
color="#8B2316"
height="140rpx"
:border="false"
backgroundColor="#eeeeee"
/>
<view class="content">
<!-- 邮箱输入框 -->
<view class="input-wrapper">
<input
class="input"
type="text"
placeholder="请输入您的邮箱"
placeholder-class="ph"
v-model.trim="email"
/>
</view>
<!-- 提示文案 -->
<view class="hint">请填写您的真实邮箱以便联系上您</view>
</view>
<!-- 底部保存按钮 -->
<view class="footer">
<button class="save-btn" @click="onSave">保存</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const email = ref('')
const goBack = () => {
uni.navigateBack()
}
const isValidEmail = (val) => {
if (!val) return false
const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
return re.test(val)
}
const onSave = () => {
if (!isValidEmail(email.value)) {
uni.showToast({ title: '请输入有效的邮箱', icon: 'none' })
return
}
//
// await api.updateEmail({ email: email.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => goBack(), 800)
}
</script>
<style lang="scss" scoped>
.page {
background-color: #fff;
min-height: 100vh;
}
.content {
padding: 24rpx 30rpx;
}
.input-wrapper {
background: #fff;
border: 2rpx solid #e6e6e6;
border-radius: 16rpx;
padding: 16rpx 20rpx;
}
.input {
height: 72rpx;
line-height: 72rpx;
font-size: 28rpx;
color: #333;
}
.ph {
color: #c7c7c7;
}
.hint {
margin-top: 24rpx;
font-size: 26rpx;
color: #666;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: env(safe-area-inset-bottom);
padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom));
background-color: #fff;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
color: #8b2316;
font-size: 30rpx;
border: 2rpx solid #8b2316;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<view class="page">
<!-- 顶部导航栏 -->
<uni-nav-bar
left-icon="left"
title="修改邮箱"
@clickLeft="goBack"
fixed
color="#8B2316"
height="140rpx"
:border="false"
backgroundColor="#eeeeee"
/>
<view class="content">
{{ eitType === 'email' }}
<!-- 邮箱输入框 -->
<view class="input-wrapper">
<input
v-if="eitType === 'email'"
class="input"
type="text"
:placeholder="placeholder"
placeholder-class="ph"
v-model.trim="content"
>
</input>
<textarea v-model.trim="content" placeholder="请输入您的个人简介(500字以内)" placeholder-class="ph" auto-height v-else > </textarea>
</view>
<!-- 提示文案 -->
<view class="hint">请填写您的真实邮箱以便联系上您</view>
</view>
<!-- 底部保存按钮 -->
<view class="footer">
<button class="save-btn" @click="onSave">保存</button>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue'
const content = ref('')
const eitType = ref('1ww');
const placeholder = ref('请输入您的邮箱');
const goBack = () => {
uni.navigateBack()
}
const isValidEmail = (val) => {
if (!val) return false
const re = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
return re.test(val)
}
const onSave = () => {
if (!isValidEmail(email.value)) {
uni.showToast({ title: '请输入有效的邮箱', icon: 'none' })
return
}
//
// await api.updateEmail({ email: email.value })
uni.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => goBack(), 800)
}
</script>
<style lang="scss" scoped>
.page {
background-color: #fff;
min-height: 100vh;
}
.content {
padding: 24rpx 30rpx;
}
.input-wrapper {
background: #fff;
border: 2rpx solid #e6e6e6;
border-radius: 16rpx;
padding: 16rpx 20rpx;
}
.input {
height: 60rpx;
line-height:60rpx;
font-size: 28rpx;
color: #333;
}
.ph {
color: #c7c7c7;
}
.hint {
margin-top: 24rpx;
font-size: 26rpx;
color: #333;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: env(safe-area-inset-bottom);
padding: 24rpx 30rpx calc(24rpx + env(safe-area-inset-bottom));
background-color: #fff;
}
.save-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
text-align: center;
color: #8b2316;
font-size: 30rpx;
border: 2rpx solid #8b2316;
border-radius: 16rpx;
background: #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
}
</style>

View File

@ -0,0 +1,72 @@
## 2.2.52024-07-30
* 修复 当 checkRange=true 时,拖动四个伸缩角放大图片时还可能会超出或未到边界的问题
* 修复 当 checkRange=false 时,图片旋转时会放大图片适应裁剪尺寸的问题
* 修复 当 checkRange=true 时,图片旋转 90° 或 270° 进行缩放可能会无法拖动图片的问题
## 2.2.42024-06-21
* 新增 reverseRotatable 属性,是否支持逆向翻转
* 修复 `2.1.7` 版本导致旋转后图片没有自动适配裁剪框的问题
## 2.2.32024-06-21
* 新增 gpu 属性,是否开启硬件加速,图片缩放过程中如果出现元素的“留影”或“重影”效果,可通过该方式解决或减轻这一问题
* 修复 组件使用 `v-if` 并设置 `src` 属性时可能会出现图片渲染位置存在偏差的问题
## 2.2.22024-06-21
* 优化 组件实例 chooseImage 方法支持传参
* 修复 组件使用 `v-if` 时组件无非正常渲染的问题
## 2.2.12024-06-15
* 修复 H5平台不支持手势拖动图片的问题
## 2.2.02024-05-31
* 修复 APP平台 `vue2` 项目因 `2.1.9` 版本修复 `vue3` 项目bug而引发的问题
## 2.1.92024-05-29
* 修复 APP平台 `vue3` 项目因 uniapp `renderjs` 中未支持条件编译导致运行了H5平台代码报错的问题
## 2.1.82024-05-29
* 新增 zIndex 属性,调整组件层级
* 新增 组件内容插槽
* 优化 微信小程序平台动态修改元素style时的多余内容
## 2.1.72024-05-28
* 新增 checkRange 属性,当 checkRange=false 时允许图片位置超出裁剪边界
* 新增 minScale 属性,图片最小缩放倍数,当 minScale<0 时可使图片宽高不再受裁剪区域宽高限制
* 新增 backgroundColor 属性,生成图片背景色,如果裁剪区域没有完全包含在图片中时,不设置该属性生成图片存在一定的透明块
* 优化 动态修改图片宽高但没有传入src时尺寸适应问题
* 修复 APP平台通过 `this.$ownerInstance` 获取组件实例时机过早,其值为 `undefined` 导致报错界面没有正常渲染的问题
## 2.1.62023-04-16
* 修复 组件使用 v-show 指令会导致选择图片后初始位置严重偏位的问题
## 2.1.52023-04-15
* 新增 兼容APP平台
## 2.1.42023-03-13
* 新增 fileType 属性,用于指定生成文件的类型,只支持 'jpg' 或 'png',默认为 'png'
* 新增 delay 属性,微信小程序平台使用 `Canvas 2D` 绘制时控制图片从绘制到生成所需时间
* 优化 当生成图片的尺寸宽/高超过 Canvas 2D 最大限制1365*1365则将画布尺寸缩放在限制范围内绘制完成后输出目标尺寸
* 优化 旋转图标指示方向与实际旋转方向不符
## 2.1.32023-02-06
* 优化 vue3支持
## 2.1.22023-02-03
* 新增 navigation 属性H5平台当 showAngle 为 true 时,使用插件的页面在 `page.json` 中配置了 "navigationStyle": "custom" 时,必须将此值设为 false ,否则四个可拉伸角的触发位置会有偏差
* 修复 H5平台部分设备已知iPhone11以下机型拍照的图片缩放时会闪动的问题
## 2.1.12022-12-06
* 修复 横屏适配问题
## 2.1.02022-12-06
* 新增 兼容H5平台使用 renderjs 响应手势事件
## 2.0.02022-12-05
* 重构 插件,使用 WXS 响应手势事件
* 新增 图片翻转
* 新增 拉伸裁剪框放大图片
* 新增 监听PC鼠标滚轮触发缩放
* 新增 圆形、圆角矩形的图片裁剪
* 优化 图片缩放移动端以双指触摸中心点为缩放中心点PC端以鼠标所在点为缩放中心点
* 优化 裁剪框样式
* 优化 图片位置拖动 支持边界回弹效果(滑动时可滑出边界,释放时回弹到边界)
* 优化 生成图片使用新版 Canvas 2D 接口

View File

@ -0,0 +1,855 @@
/**
* 图片编辑器-手势监听
* 1. 支持编译到app-vueuni-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;
/** vue3 renderjs 条件编译无效,以此方式区别 APP 和 H5 */
// #ifdef H5
var platform = 'H5';
// #endif
// #ifdef APP
var platform = 'APP';
// #endif
/** 容错值 */
var fault = 0.000001;
/**
* 获取ab两数中的最小正数
* @param a
* @param b
*/
function minimum(a, b) {
if (a > 0 && b < 0) return a;
if (a < 0 && b > 0) return b;
if (a > 0 && b > 0) return Math.min(a, b);
return 0;
}
/**
* 在容错访问内获取n近似值
* @param n
*/
function num(n) {
var m = parseFloat((n).toFixed(6));
return m === fault || m === -fault ? 0 : m;
}
/**
* 比较a值在容错值范围内是否等于b值
* @param a
* @param b
*/
function equalsByFault(a, b) {
return Math.abs(a - b) <= fault;
}
/**
* 比较a值在容错值范围内是否小于b值
* @param a
* @param b
*/
function lessThanByFault(a, b) {
var c = a - b;
return c < 0 ? c < -fault : c < fault;
}
/**
* 验证并获取有效最大值
* @param v
* @param max
* @param isInclude
* @param x
* @param y
* @param rate
* @returns
*/
function validMax(v, max, isInclude, x, y, rate) {
if(typeof max === 'number') {
if(isInclude && equalsByFault(max, y)) { // 宽高不等时x轴用y轴值要做等比例转换
var n = num(max * rate);
if (n <= x) return n; // 转化后值在x轴最大值范围内
return x; // 转化后值超出x轴最大值范围则用最大值
}
return max;
}
return v;
}
/**
* 样式对象转字符串
* @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(platform === 'APP') {
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
if(platform === 'H5') instance[key] = style;
// #endif
}
/**
* 触发页面实例指定方法
* @param {Object} instance 页面实例对象
* @param {Object} name 方法名称
* @param {Object} obj 传递参数
*/
function callMethod(instance, name, obj) {
// #ifdef APP-PLUS
if(platform === 'APP') instance.callMethod(name, obj);
// #endif
// #ifdef H5
if(platform === '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 };
};
/**
* 修正取值
* @param {Object} a
* @param {Object} b
* @param {Object} c
* @param {Object} reverse 是否反向
*/
function correctValue(a, b, c, reverse) {
return num(reverse ? Math.max(Math.min(a, b), c) : Math.min(Math.max(a, b), c));
}
/**
* 旋转90°或270°时检查边界限制 xy 拖动范围禁止滑出边界
* @param {Object} e 点坐标
* @param {Object} xReverse x是否反向
* @param {Object} yReverse y是否反向
*/
function checkRotateRange(e, xReverse, yReverse) {
var o = num((img.height - img.width) / 2); // 宽高差值一半
return {
x: correctValue(e.x, -img.height + o + area.width + area.left, area.left + o, xReverse),
y: correctValue(e.y, -img.width - o + area.height + area.top, area.top - o, yReverse)
};
}
/**
* 检查边界限制 xy 拖动范围禁止滑出边界
* @param {Object} e 点坐标
*/
function checkRange(e) {
var r = rotate / 90 % 2;
if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
if (area.width === area.height) {
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
var isInclude = img.height < area.width && img.width < area.height; // 图片是否包含在裁剪区域内
if (img.width < area.height || img.height < area.width) {
if (area.width < area.height && img.width < img.height) {
return isInclude
? checkRotateRange(e, area.width < area.height, area.width < area.height)
: checkRotateRange(e, false, true);
}
if (area.height < area.width && img.height < img.width) {
return isInclude
? checkRotateRange(e, area.height < area.width, area.height < area.width)
: checkRotateRange(e, true, false);
}
}
if (img.height >= area.width && img.width >= area.height) {
return checkRotateRange(e, false, false);
}
if (isInclude) {
return area.height < area.width
? checkRotateRange(e, true, true)
: checkRotateRange(e, area.width < area.height, area.width < area.height);
}
if (img.height < area.width && !img.width < area.height) {
return checkRotateRange(e, true, false);
}
if (!img.height < area.width && img.width < area.height) {
return checkRotateRange(e, false, true);
}
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
return {
x: correctValue(e.x, -img.width + area.width + area.left, area.left, img.width < area.width),
y: correctValue(e.y, -img.height + area.height + area.top, area.top, img.height < area.height)
}
};
/**
* 变更图片布局信息
* @param {Object} e 布局信息
*/
function changeImageRect(e) {
// console.log('changeImageRect', e)
offset.x += e.x || 0;
offset.y += e.y || 0;
if(e.check && area.checkRange) { // 检查边界
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: (img.gpu ? 'translateZ(0) ' : '') + '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,
'z-index': area.zIndex + 2
},
{
left: (area.right + areaOffset.right) + 'px',
right: 0,
top: 0,
bottom: 0,
'z-index': area.zIndex + 2
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: 0,
height: (area.top + areaOffset.top) + 'px',
'z-index': area.zIndex + 2
},
{
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,
'z-index': area.zIndex + 2
}
]);
// 变更边框样式
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',
'z-index': area.zIndex + 3
});
}
// 变更参考线样式
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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
}
]);
}
// 变更四个伸缩角样式
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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
}
]);
}
// 变更圆角样式
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',
'z-index': area.zIndex + 2
});
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 = num(img.oldWidth * scale);
img.height = num(img.oldHeight * scale);
// 参考问题有一个长4000px、宽4000px的四方形ABCDA点的坐标固定在(-2000,-2000)
// 该四边形上有一个点E坐标为(-100,-300)将该四方形复制一份并缩小到90%后,
// 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合
// 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
// 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
e.x = num((e.x - offset.x) * (1 - scale / last));
e.y = num((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 = img.minScale;
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;
};
var mouseEvent = false;
export default {
data() {
return {
imageStyles: {},
maskStylesList: [{}, {}, {}, {}],
borderStyles: {},
gridStylesList: [{}, {}, {}, {}],
angleStylesList: [{}, {}, {}, {}],
circleBoxStyles: {},
circleStyles: {}
}
},
created() {
// 监听 PC 端鼠标滚轮
// #ifdef H5
platform === 'H5' && window.addEventListener('mousewheel', async (e) => {
var touchs = getTouchs([e])
img.src && scaleImage({
instance: await 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
},
// #ifdef H5
mounted() {
platform === 'H5' && this.initH5Events();
},
// #endif
setPlatform(p) {
platform = p;
},
methods: {
// #ifdef H5
getTouchEvent(e) {
e.touches = [
{ pageX: e.pageX, pageY: e.pageY }
];
return e;
},
initH5Events() {
const preview = document.getElementById('pic-preview');
preview?.addEventListener('mousedown', (e, ev) => {
mouseEvent = true;
this.touchstart(this.getTouchEvent(e));
});
preview?.addEventListener('mousemove', (e) => {
if (!mouseEvent) return;
this.touchmove(this.getTouchEvent(e));
});
preview?.addEventListener('mouseup', (e) => {
mouseEvent = false;
this.touchend(this.getTouchEvent(e))
});
preview?.addEventListener('mouseleave', (e) => {
mouseEvent = false;
this.touchend(this.getTouchEvent(e))
});
},
// #endif
async getInstance() {
// #ifdef APP-PLUS
if(platform === 'APP')
return this.$ownerInstance
? Promise.resolve(this.$ownerInstance)
: new Promise((resolve) => {
setTimeout(async () => {
resolve(await this.getInstance());
});
});
// #endif
// #ifdef H5
if(platform === 'H5')
return Promise.resolve(this);
// #endif
},
/**
* 初始化观察数据变更
* @param {Object} newVal 新数据
* @param {Object} oldVal 旧数据
* @param {Object} o 组件实例对象
*/
initObserver: async 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;
minScale = img.minScale;
resetData();
const instance = await this.getInstance()
img.src && changeImageRect({
instance,
x: (sys.windowWidth - img.width) / 2,
y: (sys.windowHeight + sys.windowTop - sys.offsetBottom - img.height) / 2
});
changeAreaRect({
instance
});
}
},
/**
* 鼠标滚轮滚动
* @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: async 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 = num(area.width * (1 - area.minScale));
var maxY = num(area.height * (1 - area.minScale));
// console.log(x, y, maxX, maxY, offset, area)
touches[0] = point;
var r = rotate / 90 % 2;
var m = r === 1 ? num((img.height - img.width) / 2) : 0; // 宽高差值一半
var xCompare = r === 1 ? lessThanByFault(img.height, area.width) : lessThanByFault(img.width, area.width);
var yCompare = r === 1 ? lessThanByFault(img.width, area.height) : lessThanByFault(img.height, area.height)
var isInclude = xCompare && yCompare;
var isIntersect = area.checkRange && (xCompare || yCompare); // 图片是否包含在裁剪区域内
var isReverse = !isInclude || num((offset.x - area.left) / area.width) <= num((offset.y - area.top) / area.height) || (area.width > area.height && img.width < img.height && r === 1);
switch(activeAngle) {
case 1: // 左上角
x = num(x + areaOffset.left);
y = num(y + areaOffset.top);
if(x >= 0 && y >= 0) { // 有效滑动
var t = num(offset.y + m - area.top);
var l = num(offset.x - m - area.left);
// && (offset.x + img.width < area.right || offset.y + img.height < area.bottom)
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.top = y;
}
break;
case 2: // 右上角
x = num(x + areaOffset.right);
y = num(y + areaOffset.top);
if(x <= 0 && y >= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var t = num(offset.y + m - area.top);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((t >= 0) || (l >= 0))
? minimum(t, l)
: false;
if(-x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(-y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.top = y;
}
break;
case 3: // 左下角
x += num(x + areaOffset.left);
y += num(y + areaOffset.bottom);
if(x >= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.width : img.height);
var t = num(area.bottom - m - offset.y - w);
var l = num(offset.x - m - area.left);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(-y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.bottom = y;
}
break;
case 4: // 右下角
x = num(x + areaOffset.right);
y = num(y + areaOffset.bottom);
if(x <= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var h = (r === 1 ? img.width : img.height);
var t = num(area.bottom - offset.y - h - m);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(-x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.bottom = y;
}
break;
}
// console.log(x, y, JSON.stringify(areaOffset))
changeAreaRect({
instance: await this.getInstance(),
});
// this.draw();
}
} else if (e.touches.length == 2) { // 双点触摸缩放
var start = getDistanceByTouches(touches);
var end = getDistanceByTouches(e.touches);
scaleImage({
instance: await 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: await 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: async 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: await this.getInstance(),
});
scaleImage({
instance: await 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: await this.getInstance(),
check: true
});
}
},
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
rotateImage: async function(r) {
rotate = (rotate + (r || 90)) % 360;
if(img.minScale >= 1 && area.checkRange) {
// 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
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: await 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: await this.getInstance(),
check: true,
x: -ox - oy,
y: -oy + ox
});
},
rotateImage90: function() {
this.rotateImage(90)
},
rotateImage270: function() {
this.rotateImage(270)
},
}
}

View File

@ -0,0 +1,743 @@
<template>
<view class="image-cropper" :style="{ zIndex }" @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 id="pic-preview" 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>
<slot />
<view class="fixed-bottom safe-area-inset-bottom" :style="{ zIndex: initData.area.zIndex + 99 }">
<view v-if="(rotatable || reverseRotatable) && !!imgSrc" class="action-bar">
<view v-if="reverseRotatable" class="rotate-icon" @click="cropper.rotateImage270"></view>
<view v-if="rotatable" class="rotate-icon is-reverse" @click="cropper.rotateImage90"></view>
</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 -->
<script module="cropper" lang="renderjs">
import cropper from './qf-image-cropper.render.js';
// vue3 app renderjs
cropper.setPlatform('APP');
export default {
mixins: [ cropper ]
}
</script>
<!-- #endif -->
<!-- #ifdef 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
},
/** 图片最小缩放倍数 */
minScale: {
type: Number,
default: 1
},
/** 图片最大缩放倍数 */
maxScale: {
type: Number,
default: 5
},
/** 检查图片位置是否超出裁剪边界,如果超出则会矫正位置 */
checkRange: {
type: Boolean,
default: true
},
/** 生成图片背景色:如果裁剪区域没有完全包含在图片中时,不设置该属性生成图片存在一定的透明块 */
backgroundColor: {
type: String
},
/** 是否有回弹效果:当 checkRange 为 true 时有效,拖动时可以拖出边界,释放时会弹回边界 */
bounce: {
type: Boolean,
default: true
},
/** 是否支持翻转 */
rotatable: {
type: Boolean,
default: true
},
/** 是否支持逆向翻转 */
reverseRotatable: {
type: Boolean,
default: false
},
/** 是否支持从本地选择素材 */
choosable: {
type: Boolean,
default: true
},
/** 是否开启硬件加速,图片缩放过程中如果出现元素的“留影”或“重影”效果,可通过该方式解决或减轻这一问题 */
gpu: {
type: Boolean,
default: false
},
/** 四个角尺寸单位px */
angleSize: {
type: Number,
default: 20
},
/** 四个角边框宽度单位px */
angleBorderWidth: {
type: Number,
default: 2
},
zIndex: {
type: [Number, String]
},
/** 裁剪图片圆角半径单位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,
checkRange: this.checkRange,
zIndex: +this.zIndex || 0,
},
sys: this.sys,
img: {
minScale: this.minScale,
maxScale: this.maxScale,
src: this.imgSrc,
width: this.oldWidth,
height: this.oldHeight,
oldWidth: this.oldWidth,
oldHeight: this.oldHeight,
gpu: this.gpu,
}
}
},
imgProps() {
return {
width: this.width,
height: this.height,
src: this.src,
}
}
},
watch: {
imgProps: {
handler(val, oldVal) {
//
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();
const src = val.src || this.imgSrc;
src && this.initImage(src, oldVal === undefined);
},
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(options) {
// #ifdef MP-WEIXIN || MP-JD
if(uni.chooseMedia) {
uni.chooseMedia({
...options,
count: 1,
mediaType: ['image'],
success: (res) => {
this.resetData();
this.initImage(res.tempFiles[0].tempFilePath);
}
});
return;
}
// #endif
uni.chooseImage({
...options,
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, isFirst) {
uni.getImageInfo({
src: url,
success: async (res) => {
if (isFirst && this.src === url) await (new Promise((resolve) => setTimeout(resolve, 50)));
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.toFixed(2);
this.oldHeight = +this.scaleHeight.toFixed(2);
},
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;
if(this.backgroundColor) {
if(ctx.setFillStyle) ctx.setFillStyle(this.backgroundColor);
else ctx.fillStyle = this.backgroundColor;
ctx.fillRect(0, 0, this.canvansWidth, this.canvansHeight);
}
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){
// H5tempFilePath 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;
backface-visibility: hidden;
}
.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;
.action-bar {
position: absolute;
top: -90rpx;
left: 10rpx;
display: flex;
.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;
&.is-reverse {
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>

View File

@ -0,0 +1,727 @@
/**
* 图片编辑器-手势监听
* 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 };
/** 容错值 */
var fault = 0.000001;
/**
* 获取a、b两数中的最小正数
* @param a
* @param b
*/
function minimum(a, b) {
if (a > 0 && b < 0) return a;
if (a < 0 && b > 0) return b;
if (a > 0 && b > 0) return Math.min(a, b);
return 0;
}
/**
* 在容错访问内获取n近似值
* @param n
*/
function num(n) {
var m = parseFloat((n).toFixed(6));
return m === fault || m === -fault ? 0 : m;
}
/**
* 比较a值在容错值范围内是否等于b值
* @param a
* @param b
*/
function equalsByFault(a, b) {
return Math.abs(a - b) <= fault;
}
/**
* 比较a值在容错值范围内是否小于b值
* @param a
* @param b
*/
function lessThanByFault(a, b) {
var c = a - b;
return c < 0 ? c < -fault : c < fault;
}
/**
* 验证并获取有效最大值
* @param v
* @param max
* @param isInclude
* @param x
* @param y
* @param rate
* @returns
*/
function validMax(v, max, isInclude, x, y, rate) {
if(typeof max === 'number') {
if(isInclude && equalsByFault(max, y)) { // 宽高不等时x轴用y轴值要做等比例转换
var n = num(max * rate);
if (n <= x) return n; // 转化后值在x轴最大值范围内
return x; // 转化后值超出x轴最大值范围则用最大值
}
return max;
}
return v;
}
/**
* 计算两点间距
* @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 };
};
/**
* 修正取值
* @param {Object} a
* @param {Object} b
* @param {Object} c
* @param {Object} reverse 是否反向
*/
function correctValue(a, b, c, reverse) {
return num(reverse ? Math.max(Math.min(a, b), c) : Math.min(Math.max(a, b), c));
}
/**
* 旋转90°或270°时检查边界限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
* @param {Object} xReverse x是否反向
* @param {Object} yReverse y是否反向
*/
function checkRotateRange(e, xReverse, yReverse) {
var o = num((img.height - img.width) / 2); // 宽高差值一半
return {
x: correctValue(e.x, -img.height + o + area.width + area.left, area.left + o, xReverse),
y: correctValue(e.y, -img.width - o + area.height + area.top, area.top - o, yReverse)
};
}
/**
* 检查边界:限制 x、y 拖动范围,禁止滑出边界
* @param {Object} e 点坐标
*/
function checkRange(e) {
var r = rotate / 90 % 2;
if(r === 1) { // 因图片宽高可能不等,翻转 90° 或 270° 后图片宽高需反着计算,且左右和上下边界要根据差值做偏移
if (area.width === area.height) {
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
var isInclude = img.height < area.width && img.width < area.height; // 图片是否包含在裁剪区域内
if (img.width < area.height || img.height < area.width) {
if (area.width < area.height && img.width < img.height) {
return isInclude
? checkRotateRange(e, area.width < area.height, area.width < area.height)
: checkRotateRange(e, false, true);
}
if (area.height < area.width && img.height < img.width) {
return isInclude
? checkRotateRange(e, area.height < area.width, area.height < area.width)
: checkRotateRange(e, true, false);
}
}
if (img.height >= area.width && img.width >= area.height) {
return checkRotateRange(e, false, false);
}
if (isInclude) {
return area.height < area.width
? checkRotateRange(e, true, true)
: checkRotateRange(e, area.width < area.height, area.width < area.height);
}
if (img.height < area.width && !img.width < area.height) {
return checkRotateRange(e, true, false);
}
if (!img.height < area.width && img.width < area.height) {
return checkRotateRange(e, false, true);
}
return checkRotateRange(e, img.height < area.height, img.width < area.width);
}
return {
x: correctValue(e.x, -img.width + area.width + area.left, area.left, img.width < area.width),
y: correctValue(e.y, -img.height + area.height + area.top, area.top, img.height < area.height)
};
};
/**
* 变更图片布局信息
* @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 && area.checkRange) { // 检查边界
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: (img.gpu ? 'translateZ(0) ' : '') + '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,
'z-index': area.zIndex + 2
},
{
left: (area.right + areaOffset.right) + 'px',
right: 0,
top: 0,
bottom: 0,
'z-index': area.zIndex + 2
},
{
left: (area.left + areaOffset.left) + 'px',
width: (area.width + areaOffset.right - areaOffset.left) + 'px',
top: 0,
height: (area.top + areaOffset.top) + 'px',
'z-index': area.zIndex + 2
},
{
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,
'z-index': area.zIndex + 2
}
];
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',
'z-index': area.zIndex + 3
});
}
// 变更参考线样式
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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
}
];
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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
},
{
'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',
'z-index': area.zIndex + 3
}
];
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',
'z-index': area.zIndex + 2
});
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 = num(img.oldWidth * scale);
img.height = num(img.oldHeight * scale);
// 参考问题有一个长4000px、宽4000px的四方形ABCDA点的坐标固定在(-2000,-2000)
// 该四边形上有一个点E坐标为(-100,-300)将该四方形复制一份并缩小到90%后,
// 新四边形的A点坐标为多少时可使新四边形的E点与原四边形的E点重合
// 预期效果:从图中选取某点(参照物)为中心点进行缩放,缩放时无论图像怎么变化,该点位置始终固定不变
// 计算方法:以相同起点先计算缩放前后两点间的距离,再加上原图像偏移量即可
e.x = num((e.x - offset.x) * (1 - scale / last));
e.y = num((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 = img.minScale;
rotate = 0;
};
/**
* 顺时针翻转图片90°
* @param {Object} e 事件对象
* @param {Object} o 组件实例对象
*/
function rotateImage(e, o, r) {
rotate = (rotate + r) % 360;
if(img.minScale >= 1 && area.checkRange) {
// 因图片宽高可能不等,翻转后图片宽高需足够填满裁剪区域
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
});
};
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;
minScale = img.minScale;
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 = num(area.width * (1 - area.minScale));
var maxY = num(area.height * (1 - area.minScale));
// console.log(x, y, maxX, maxY, offset, area)
touches[0] = point;
var r = rotate / 90 % 2;
var m = r === 1 ? num((img.height - img.width) / 2) : 0; // 宽高差值一半
var xCompare = r === 1 ? lessThanByFault(img.height, area.width) : lessThanByFault(img.width, area.width);
var yCompare = r === 1 ? lessThanByFault(img.width, area.height) : lessThanByFault(img.height, area.height)
var isInclude = xCompare && yCompare;
var isIntersect = area.checkRange && (xCompare || yCompare); // 图片是否包含在裁剪区域内
var isReverse = !isInclude || num((offset.x - area.left) / area.width) <= num((offset.y - area.top) / area.height) || (area.width > area.height && img.width < img.height && r === 1);
switch(activeAngle) {
case 1: // 左上角
x = num(x + areaOffset.left);
y = num(y + areaOffset.top);
if(x >= 0 && y >= 0) { // 有效滑动
var t = num(offset.y + m - area.top);
var l = num(offset.x - m - area.left);
// && (offset.x + img.width < area.right || offset.y + img.height < area.bottom)
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.top = y;
}
break;
case 2: // 右上角
x = num(x + areaOffset.right);
y = num(y + areaOffset.top);
if(x <= 0 && y >= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var t = num(offset.y + m - area.top);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((t >= 0) || (l >= 0))
? minimum(t, l)
: false;
// var max = isInclude && ((offset.x > 0 && offset.x + img.width <= area.right) || (offset.y > 0 && offset.y >= area.top))
// ? minimum(offset.y - area.top, area.right - offset.x - img.width)
// : false;
// console.log(offset.x, offset.y, img.width, img.height, area.top, area.right, m, max)
// console.log(offset.y + m - area.top, area.right + m - offset.x - w)
if(-x > y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(y > maxY) y = maxY;
x = num(-y * area.width / area.height);
}
areaOffset.right = x;
areaOffset.top = y;
}
break;
case 3: // 左下角
x += num(x + areaOffset.left);
y += num(y + areaOffset.bottom);
if(x >= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.width : img.height);
var t = num(area.bottom - m - offset.y - w);
var l = num(offset.x - m - area.left);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(x > maxX) x = maxX;
y = num(-x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(-y * area.width / area.height);
}
areaOffset.left = x;
areaOffset.bottom = y;
}
break;
case 4: // 右下角
x = num(x + areaOffset.right);
y = num(y + areaOffset.bottom);
if(x <= 0 && y <= 0) { // 有效滑动
var w = (r === 1 ? img.height : img.width);
var h = (r === 1 ? img.width : img.height);
var t = num(area.bottom - offset.y - h - m);
var l = num(area.right + m - offset.x - w);
var max = isIntersect && ((l >= 0) || (t >= 0))
? minimum(t, l)
: false;
if(-x > -y && isReverse) { // 以x轴滑动距离为缩放基准
maxX = validMax(maxX, max, isInclude, l, t, area.width / area.height);
if(-x > maxX) x = -maxX;
y = num(x * area.height / area.width);
} else { // 以y轴滑动距离为缩放基准
maxY = validMax(maxY, max, isInclude, t, l, area.height / area.width);
if(-y > maxY) y = -maxY;
x = num(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) {
rotateImage(e, o, 90);
},
rotateImage90: function(e, o) {
rotateImage(e, o, 90)
},
rotateImage270: function(e, o) {
rotateImage(e, o, 270)
},
// 此处只用于对齐其他平台端的样式参数,防止异常,无作用
imageStyles: '',
maskStylesList: ['', '', '', ''],
borderStyles: '',
gridStylesList: ['', '', '', ''],
angleStylesList: ['', '', '', ''],
circleBoxStyles: '',
circleStyles: '',
}

View File

@ -0,0 +1,81 @@
{
"id": "qf-image-cropper",
"displayName": "图片裁剪插件",
"version": "2.2.5",
"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"
}
}
}
}
}

View File

@ -0,0 +1,97 @@
# 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 | 裁剪区域最小缩放倍数 |
| minScale | Number | 1 | 图片最小缩放倍数 |
| maxScale | Number | 5 | 图片最大缩放倍数 |
| checkRange | Boolean | true | 检查图片位置是否超出裁剪边界,如果超出则会矫正位置 |
| backgroundColor | String | | 生成图片背景色:如果裁剪区域没有完全包含在图片中时,不设置该属性则生成图片存在一定的透明块 |
| bounce | Boolean | true | 是否有回弹效果:当 checkRange 为 true 时有效,拖动时可以拖出边界,释放时会弹回边界 |
| rotatable | Boolean | true | 是否支持翻转 |
| reverseRotatable | Boolean | false | 是否支持逆向翻转 |
| choosable | Boolean | true | 是否支持从本地选择素材 |
| gpu | Boolean | false | 是否开启硬件加速,图片缩放过程中如果出现元素的“留影”或“重影”效果,可通过该方式解决或减轻这一问题 |
| angleSize | Number | 20 | 四个角尺寸单位px |
| angleBorderWidth | Number | 2 | 四个角边框宽度单位px |
| zIndex | Number/String | | 调整组件层级 |
| 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>
```
通过ref组件实例可在进入页面后直接打开相册选择图片
```
mounted() {
this.$refs.qfImageCropper.chooseImage({ sourceType: ['album'] });
}
```
### 使用说明
1.建议在`pages.json`中将引用插件的页面添加一下配置禁止下拉刷新和禁止页面滑动,防止出现性能或页面抖动等问题。
```
{
"enablePullDownRefresh": false,
"disableScroll": true
}
```
2.建议使用本插件不要设置过大宽高的目标图片尺寸建议1365x1365以内否则可能会导致如下问题
```
1.界面卡顿,内存占用过高
2.生成图片失真(模糊)
3.确定裁剪后一直显示 `裁剪中...`,该问题是由 `uni.canvasToTempFilePath` 无法回调导致,不同平台不同设备限制可能有所不同。
```
3.如裁剪后的图片存在偏移的问题,请检查是否受自己项目中父组件或全局样式影响。
4.src属性设置网络图片时图片资源必须是能触发 `getImageInfo` API 的 success 回调才可用于插件裁剪。因此小程序平台获取网络图片信息需先配置download域名白名单才能生效。
5.如果组件无法正常渲染且使用了 `v-if` 时,可尝试将 `v-if` 替换为 `v-show`
6.如果App端导入组件后无法正常渲染请尝试重新运行