Compare commits

...

72 Commits

Author SHA1 Message Date
zoujiandong
de35597c69 8.18打包更新 2025-08-18 17:51:35 +08:00
zoujiandong
434fa24723 8.18提交 2025-08-18 15:23:45 +08:00
zoujiandong
3904393163 8.18 2025-08-18 13:04:39 +08:00
zoujiandong
b852fa62e4 8.15提交 2025-08-15 09:19:53 +08:00
zoujiandong
c7eb7ed879 8.15提交 2025-08-15 09:18:09 +08:00
zoujiandong
61584a5d20 详情 2025-08-05 15:37:48 +08:00
zoujiandong
006223b01d 详情 2025-08-05 15:34:03 +08:00
zoujiandong
09e47df0f2 request 2025-08-05 15:19:04 +08:00
zoujiandong
7673b32f96 request 2025-08-05 14:11:00 +08:00
zoujiandong
9e01fa0032 命令行提交3 2025-08-05 14:09:12 +08:00
zoujiandong
86eb7d47e0 命令行提交3 2025-08-05 13:55:52 +08:00
zoujiandong
e83260c6ff 命令行提交3 2025-08-05 13:50:21 +08:00
zoujiandong
11f1a6d727 命令行提交1 2025-08-05 11:33:53 +08:00
zoujiandong
8dadc2be09 命令行提交 2025-08-05 11:27:40 +08:00
zoujiandong
430a4a1491 命令行提交 2025-08-04 16:52:42 +08:00
zoujiandong
ba4bc25785 命令行提交 2025-08-04 09:10:14 +08:00
zoujiandong
5397dc9d8a 8.3 2025-08-03 18:24:45 +08:00
zoujiandong
e5979f605e 2222 2025-08-03 17:46:42 +08:00
zoujiandong
6e320c3944 8.3 2025-08-03 16:29:54 +08:00
zoujiandong
43abd1f3d6 222 2025-07-30 13:58:39 +08:00
zoujiandong
afc79a0f26 1111 2025-07-30 13:43:14 +08:00
zoujiandong
4ca1b9cf42 111117.29 2025-07-29 15:39:33 +08:00
zoujiandong
071ab8de73 111 2025-07-28 08:44:46 +08:00
zoujiandong
607ff57b2f 111 2025-07-25 17:21:39 +08:00
zoujiandong
05ef454ac8 111 2025-07-25 16:50:23 +08:00
zoujiandong
4544b678d0 111 2025-07-25 13:15:43 +08:00
zoujiandong
53d8455890 111 2025-07-25 08:41:38 +08:00
zoujiandong
0b7edc39d4 111 2025-07-24 13:03:08 +08:00
zoujiandong
464761e3f9 11 2025-07-24 10:55:36 +08:00
zoujiandong
c758477084 7.24 2025-07-24 08:57:22 +08:00
zoujiandong
9b00f8a04b 11 2025-07-22 17:15:39 +08:00
zoujiandong
c44d30294c 11 2025-07-22 17:15:07 +08:00
zoujiandong
e40f72db16 11 2025-07-22 15:57:13 +08:00
zoujiandong
1d598d149b 11111 2025-07-22 15:56:22 +08:00
zoujiandong
89336f5621 7.22 2025-07-22 09:40:54 +08:00
zoujiandong
89d55643f6 7.21 2025-07-21 09:45:24 +08:00
zoujiandong
9b4734acc5 111 2025-07-18 13:30:14 +08:00
zoujiandong
293b3e6a35 111 2025-07-18 09:29:32 +08:00
zoujiandong
07e0630824 111 2025-07-18 09:29:19 +08:00
zoujiandong
2f79feb17e 1111 2025-07-18 08:56:16 +08:00
zoujiandong
a0d99cad11 111 2025-07-17 17:26:51 +08:00
zoujiandong
c1608eef5a 1111 2025-07-17 17:26:30 +08:00
zoujiandong
75d515fbc6 7.17 2025-07-17 17:15:28 +08:00
zoujiandong
dc39275e6e 111 2025-07-17 09:05:31 +08:00
zoujiandong
6e84da988c 111 2025-07-16 17:39:56 +08:00
zoujiandong
b09524e012 111 2025-07-16 17:16:16 +08:00
zoujiandong
9a45c7aa03 111 2025-07-16 16:52:00 +08:00
zoujiandong
c0c1f0716c 111 2025-07-16 16:51:06 +08:00
zoujiandong
7c31a17146 111 2025-07-16 16:37:05 +08:00
zoujiandong
0a894696a7 11 2025-07-16 16:22:51 +08:00
zoujiandong
15dd107326 111 2025-07-16 16:22:15 +08:00
zoujiandong
6097c2b495 11 2025-07-16 15:53:15 +08:00
zoujiandong
8b3f81f8da 222 2025-07-16 15:44:56 +08:00
zoujiandong
0b24b01054 111 2025-07-16 15:43:57 +08:00
zoujiandong
f002705479 111 2025-07-16 14:30:33 +08:00
zoujiandong
288772c534 11 2025-07-16 14:30:03 +08:00
zoujiandong
96859c920f 222 2025-07-16 13:05:58 +08:00
zoujiandong
eb358d594b 111 2025-07-16 10:51:19 +08:00
zoujiandong
b0f1a37ece 111 2025-07-15 17:48:57 +08:00
zoujiandong
30b08f1933 6.18 2025-06-18 18:23:35 +08:00
zoujiandong
debbf34b73 1111 2025-06-17 16:27:34 +08:00
zoujiandong
8b7b27d4ff 11 2025-06-17 16:22:27 +08:00
zoujiandong
6717bd10e7 11 2025-06-17 16:22:21 +08:00
zoujiandong
956d5b5e3a d11 2025-06-17 16:08:57 +08:00
zoujiandong
2b187418e5 打包 2025-06-17 16:01:45 +08:00
zoujiandong
632cec0873 打包 2025-06-17 16:01:31 +08:00
zoujiandong
86de361ff8 打包 2025-06-17 15:09:59 +08:00
zoujiandong
a6dbc4692c 打包 2025-06-17 14:47:08 +08:00
zoujiandong
42d8814157 打包 2025-06-17 14:46:33 +08:00
zoujiandong
54a2da7d22 打包 2025-06-17 14:38:01 +08:00
zoujiandong
b0eb163c21 h5登录 2025-06-17 14:11:33 +08:00
zoujiandong
f0e9803e5d 打包 2025-06-17 13:56:35 +08:00
683 changed files with 38962 additions and 36886 deletions

3
.gitignore vendored
View File

@ -8,8 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
yarn.lock
uni_modules
mp-weixin
node_modules
dist-ssr
*.local

90
App.vue
View File

@ -1,11 +1,62 @@
<script setup>
import { onLaunch,onShow,onHide } from '@dcloudio/uni-app'
onLaunch(()=>{
console.log('App Launch')
console.log('App Launch');
// #ifdef MP-WEIXIN
//
const updateManager = wx.getUpdateManager();
//
updateManager.onCheckForUpdate(function (res) {
//
console.log(res.hasUpdate); //
});
//
updateManager.onUpdateReady(function () {
wx.showModal({
title: '更新提示',
showCancel:false,
content: '小程序版本已经更新,请重新进入!',
success(res) {
if (res.confirm) {
// applyUpdate
updateManager.applyUpdate();
}
}
});
});
updateManager.onUpdateFailed(function () {
wx.showModal({
title: '更新失败',
content:'已经有新版本了,请您删除当前小程序,重新搜索打开',
});
//
});
// #endif
});
onShow(()=>{
console.log('App Show')
// #ifdef MP-WEIXIN
wx.getSystemInfo({
success: (res) => {
// windows | macpc
// android | ios
console.log('getSystemInfo,', res.platform);
if( res.platform=="windows" || res.platform=="mac"){
wx.showModal({
title: '提示',
showCancel:false,
content: '请使用手机端登录',
complete: (res) => {
wx.exitMiniProgram()
}
})
}
}
});
// #endif
});
onHide(()=>{
console.log('App Hide')
@ -33,4 +84,39 @@
-webkit-line-clamp: 2;
overflow: hidden;
}
.back{
/* margin-top: -8rpx; */
padding:0 20rpx 0rpx 20rpx;
}
.ellipsis-one-lines {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* #ifdef H5 */
.zp-container{
z-index: 0!important;
}
/* #endif */
.u-image{
background: none!important;
}
.u-steps-item__line{
height:36px!important;
}
.casesdown.active{
color:#3CC7C0!important;
}
.u-dropdown__menu__item__arrow--rotate .uicon-arrow-down{
color: #3cc7c0 !important;
}
.tagbox{
flex-wrap: wrap;
}
.tagbox .tag{
margin:5rpx 0 ;
}
.zp-paging-container{
background-color: #fff!important;
}
</style>

View File

@ -9,7 +9,7 @@ const api = {
return request('/login/mobile', data, 'post', true);
},
getCode(data) {
return request('/code/phone', data, 'post', true);
return request('/expertAPI/smsSend', data, 'post', true);
},
getHomeData(data) { //首页数据
@ -36,9 +36,15 @@ const api = {
},
cancelCollectVideo(id){
return request('/clinical/video/collect/'+id,{}, 'delete',false);
},
collectExchange(id){
return request('/exchange/collect/'+id, {}, 'post',false);
},
cancelCollectExchange(id){
return request('/exchange/collect/'+id,{}, 'delete',false);
},
addVideoComment(id,data){
return request('/clinical/video/comment/'+id,data, 'post',false,'application/json');
return request('/clinical/video/comment/'+id,data, 'post',true,'application/json');
},
delVideoComment(id){
return request('/clinical/video/comment/'+id, {}, 'delete',false);
@ -51,7 +57,7 @@ const api = {
return request('/clinical/video/comment/top/'+id, {}, 'delete',false);
},
addArticleComment(id,data){
return request('/clinical/article/comment/'+id, data, 'post',false,'application/json');
return request('/clinical/article/comment/'+id, data, 'post',true,'application/json');
},
delArticleComment(id){
return request('/clinical/article/comment/'+id, {}, 'delete',false);
@ -81,7 +87,7 @@ const api = {
return request('/clinical/hospital/search', data, 'post',true,'application/json');
},
getStaticDoctor(id){
return request('/clinical/stats/doctor/'+id,{}, 'get',false);
return request('/clinical/stats/doctor/'+id,{}, 'get',true);
},
getStaticSick(data) { //首页统计数据
return request('/clinical/stats/label', data, 'get', false);
@ -117,7 +123,7 @@ const api = {
return request('/exchange/collect/'+id, {}, 'delete',false);
},
addExchangeComment(id,data){
return request('/exchange/comment/'+id, data, 'post',false);
return request('/exchange/comment/'+id, data, 'post',true,'application/json');
},
delExchangeComment(id){
return request('/exchange/comment/'+id, {}, 'delete',false);
@ -141,7 +147,7 @@ const api = {
return request('/exchange/draft/search', data, 'post',false,'application/json');
},
saveDraft(data){
return request('/exchange/draft', data, 'post',false,'application/json');
return request('/exchange/draft', data, 'post',true,'application/json');
},
delDraft(id){
return request('/exchange/draft/'+id, {}, 'delete',false);
@ -179,12 +185,18 @@ const api = {
givePoint(data){
return request('/reward',data,'post',true);
},
h5Login(data){
return request('/login/hcp',data,'post');
h5Login(data){
return request('/login/hcp',data,'post',true);
},
readRecord(data){
return request('/user/case/read',data,'post',false,'application/json');
}
},
updateExchange(id,data){
return request('/exchange/'+id, data, 'put',true,'application/json');
},
delExchange(id){
return request('/exchange/'+id, {}, 'delete',true);
},
}

View File

@ -0,0 +1,183 @@
<template>
<view class="navbox">
<view class="bg"></view>
<view class="namebox">
<view class="back" @click="goBack">
<u-icon name="arrow-left" color="#000" size="24"></u-icon>
</view>
<view class="logo" @click="goHome">
<up--image
:src="logoImg"
width="62rpx"
height="62rpx"
radius="50%"
></up--image>
</view>
<view class="name" @click="goHome">
{{ navName }}
<view class="navbg">
<up--image
:src="navbg"
width="100rpx"
height="31rpx"
></up--image>
</view>
</view>
<view class="user" @click="goMy">
<up--image
:src="useImg?useImg:headImg"
width="62rpx"
height="62rpx"
radius="50%"
></up--image>
</view>
</view>
</view>
</template>
<script setup>
import navTo from "@/utils/navTo.js";
import logoImg from "@/static/logo.png";
import headImg from "@/static/headImg.png";
import navbg from "@/static/navbg.png"
import { onMounted,ref} from 'vue';
const props = defineProps({
navName: {
type: String,
default: "我的",
}
});
const useImg=ref('')
const goBack = () => {
uni.navigateBack({
delta: 1,
fail(){
uni.redirectTo({
url:'/pages/index/index'
})
}
});
};
const goHome=()=>{
console.log(props.navName);
if(props.navName=="肝胆相照病例交流园地"){
uni.reLaunch({
url:'/pages/caseTalk/caseTalk'
})
}else{
uni.reLaunch({
url:'/pages/index/index'
})
};
}
const goMy=()=>{
navTo({
url:'/pages/my/my'
})
};
onMounted(()=>{
let userInfo=uni.getStorageSync('userInfo');
if(userInfo && userInfo.avatar){
useImg.value=userInfo.avatar;
}
})
</script>
<style lang="scss" scoped>
.navbox {
padding-bottom: 20rpx;
background-color: #fff;
position: relative;
/* #ifdef H5 */
height:120rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
height:200rpx;
/* #endif */
background: radial-gradient(
60% 90% at 4% 2%,
#43c9c3 0%,
#fff 100%
);
}
.bg {
z-index: 0;
top: 0;
bottom: 0;
width: 100%;
position: absolute;
// background: radial-gradient(
// 43% 90% at 84% 6%,
// #ffd6c9 0%,
// rgba(255, 255, 255, 0) 100%
// );
}
.namebox {
/* #ifdef H5 */
padding-top: 51rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
padding-top: 102rpx;
/* #endif */
margin: 0rpx 30rpx 0rpx;
display: flex;
align-items: center;
display: flex;
align-items: center;
.logo{
margin-left: 35rpx;
}
.back{
position: absolute;
left: 0;
}
.user{
margin-left:55rpx;
}
.name {
margin-left: 16rpx;
font-size: 30rpx;
color: #111827;
position: relative;
.navbg{
position: absolute;
z-index:-1;
top:10rpx;
left:18rpx;
}
}
}
.search {
margin: 40rpx 30rpx 0rpx;
display: flex;
align-items: center;
justify-content: space-between;
.searchwrap {
display: flex;
align-items: center;
flex: 1;
padding-left: 28rpx;
margin-right: 23rpx;
height: 80rpx;
background: #fbfbfb;
box-shadow: 0px 4rpx 10rpx 0px rgba(153, 153, 153, 0.5);
border-radius: 40rpx;
.ipt {
margin-left: 15rpx;
font-size: 28rpx;
}
}
}
</style>

View File

@ -14,7 +14,16 @@
></up--image>
</view>
<view class="name">{{ navName }}</view>
<view class="name">
{{ navName }}
<view class="navbg">
<up--image
:src="navbg"
width="100rpx"
height="31rpx"
></up--image>
</view>
</view>
</view>
</view>
@ -22,6 +31,7 @@
<script setup>
import logoImg from "@/static/logo.png";
import navbg from "@/static/navbg.png"
const props = defineProps({
navName: {
type: String,
@ -32,6 +42,11 @@ const props = defineProps({
const goBack = () => {
uni.navigateBack({
delta: 1,
fail(){
uni.redirectTo({
url:'/pages/index/index'
})
}
});
};
</script>
@ -61,7 +76,13 @@ const goBack = () => {
);
}
.namebox {
padding-top: 102rpx;
/* #ifdef H5 */
padding-top: 51rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
padding-top: 102rpx;
/* #endif */
margin: 0rpx 30rpx 0rpx;
display: flex;
align-items: center;
@ -78,6 +99,15 @@ const goBack = () => {
margin-left: 16rpx;
font-size: 30rpx;
color: #111827;
position: relative;
.navbg{
position: absolute;
z-index:-1;
top:10rpx;
left:18rpx;
}
}
}
.search {

View File

@ -6,7 +6,9 @@
<u-icon name="arrow-left" color="#000" size="24"></u-icon>
</view>
<!-- <view class="logo">logo</view> -->
<view class="name">{{ navName }}</view>
<view class="name">
{{ navName }}
</view>
</view>
</view>
@ -23,6 +25,11 @@ const props = defineProps({
const goBack = () => {
uni.navigateBack({
delta: 1,
fail(){
uni.redirectTo({
url:'/pages/index/index'
})
}
});
};
</script>
@ -51,7 +58,13 @@ const goBack = () => {
);
}
.namebox {
padding-top: 102rpx;
/* #ifdef H5 */
padding-top: 51rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
padding-top: 102rpx;
/* #endif */
justify-content: center;
margin: 0rpx 30rpx 0rpx;
position: relative;

View File

@ -3,8 +3,8 @@
</template>
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
const playJs=ref("//player.polyv.net/resp/live-h5-player/latest/liveplayer.min.js");
import { onMounted, onUnmounted, ref,watch} from 'vue';
const playJs=ref("//player.polyv.net/script/player.js");
const player=ref(null);
const props=defineProps({
vid:{
@ -12,6 +12,10 @@ const props=defineProps({
default:''
}
})
const user_id=ref('');
watch(()=>props.vid,()=>{
loadPlayerScript(loadPlayer);
})
const loadPlayerScript=(callback)=> {
if (!window.polyvLivePlayer) {
const myScript = document.createElement('script');
@ -23,17 +27,22 @@ const props=defineProps({
}
};
const loadPlayer=()=> {
let userInfo=uni.getStorageSync('userInfo');
if(userInfo.user_id){
user_id.value=userInfo.user_id;
}
const polyvLivePlayer = window.polyvLivePlayer;
player.value = polyvLivePlayer({
player.value = polyvPlayer({
wrap: '#player',
width: '100%',
height: 250,
forceH5:true,
df:3,
uid:user_id.vlaue,
vid: props.vid,
});
}
onMounted(()=>{
loadPlayerScript(loadPlayer);
})
onUnmounted(()=>{
player.value.destroy();
})

View File

@ -11,7 +11,10 @@
></up--image>
</view>
<view class="name">{{ navName }}</view>
<view class="name">
{{ navName }}
</view>
</view>
</view>
@ -46,7 +49,13 @@
);
}
.namebox {
padding-top: 102rpx;
/* #ifdef H5 */
padding-top: 51rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
padding-top: 102rpx;
/* #endif */
margin: 0rpx 30rpx 0rpx;
display: flex;
align-items: center;
@ -54,6 +63,15 @@
margin-left: 16rpx;
font-size: 30rpx;
color: #111827;
position: relative;
.navbg{
position: absolute;
z-index:-1;
top:10rpx;
left:18rpx;
}
}
}
.search {

View File

@ -11,7 +11,16 @@
></up--image>
</view>
<view class="name">{{ navName }}</view>
<view class="name">
{{ navName }}
<view class="navbg">
<up--image
:src="navbg"
width="100rpx"
height="31rpx"
></up--image>
</view>
</view>
</view>
<view class="search">
<view class="searchwrap">
@ -21,7 +30,7 @@
<up--image
:src="userHeadImg"
@click="goMy"
mode="widthFix"
width="62rpx"
height="62rpx"
radius="50%"
@ -34,6 +43,7 @@
import { ref, watch} from "vue";
import headImg from "@/static/headImg.png";
import logoImg from "@/static/logo.png";
import navbg from "@/static/navbg.png"
import navTo from "@/utils/navTo.js";
import { onShow,onLoad } from "@dcloudio/uni-app";
const userHeadImg = ref('');
@ -74,11 +84,11 @@ const goMy=()=>{
const search=()=>{
if(props.type=='home'){
navTo({
url: `/pages/search/search?keyWord=${keyWord.value}`,
url: `/pages/search/search?keyWord=${keyWord.value}&from=home`,
});
}else if(props.type=='caseTalk'){
navTo({
url: `/pages/specialList/specialList?keyWord=${keyWord.value}`,
url: `/pages/specialList/specialList?keyWord=${keyWord.value}&from=talkHome`,
});
}
// if (!keyWord.value) {
@ -104,6 +114,7 @@ onLoad(()=>{
<style lang="scss" scoped>
.navbox {
padding-bottom: 20rpx;
background-color: #f9fafb;
position: relative;
@ -127,7 +138,13 @@ onLoad(()=>{
);
}
.namebox {
padding-top: 102rpx;
/* #ifdef H5 */
padding-top: 51rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
padding-top: 102rpx;
/* #endif */
margin: 0rpx 30rpx 0rpx;
display: flex;
align-items: center;
@ -135,6 +152,15 @@ onLoad(()=>{
margin-left: 16rpx;
font-size: 30rpx;
color: #111827;
position: relative;
.navbg{
position: absolute;
z-index:-1;
top:10rpx;
left:18rpx;
}
}
}
.search {

View File

@ -14,7 +14,16 @@
></up--image>
</view>
<view class="name">{{ navName }}</view>
<view class="name">
{{ navName }}
<view class="navbg">
<up--image
:src="navbg"
width="100rpx"
height="31rpx"
></up--image>
</view>
</view>
</view>
<view class="search">
<view class="searchwrap">
@ -22,9 +31,9 @@
<up-icon name="search" size="26" color="#999" @click="search"></up-icon>
</view>
<up--image
:src="headImg"
:src="userHeadImg"
@click="goMy"
mode="widthFix"
width="62rpx"
height="62rpx"
radius="50%"
@ -37,6 +46,7 @@
import { ref, watch,defineEmits} from "vue";
import headImg from "@/static/headImg.png";
import logoImg from "@/static/logo.png";
import navbg from "@/static/navbg.png"
import { onShow,onLoad } from "@dcloudio/uni-app";
import navTo from "@/utils/navTo.js";
const keyWord = ref('');
@ -56,14 +66,23 @@ const props=defineProps({
}
})
const placeholder = ref("输入疾病名称、标题、作者搜索");
watch(()=>props.type,(newVal)=>{
if(newVal==='doctor'){
placeholder.value='输入医生姓名'
}else if(newVal==='hospital'){
placeholder.value='输入医院名称'
}else if(newVal==='video' || newVal==='article' || newVal==='case' ){
placeholder.value='搜索疾病、症状、医院'
}else if(newVal==='video'){
placeholder.value='输入疾病名称、标题、作者搜索'
}else if(newVal==='article' ){
placeholder.value='搜索疾病、症状、医院'
}else if(newVal==='case'){
placeholder.value='输入疾病名称'
}else if(newVal==='myCase'){
placeholder.value='输入疾病名称、标题搜索'
}else if(newVal==='cert'){
placeholder.value='输入疾病名称、标题搜索'
}
},{immediate: true })
@ -80,6 +99,11 @@ const goMy=()=>{
const goBack = () => {
uni.navigateBack({
delta: 1,
fail(){
uni.redirectTo({
url:'/pages/index/index'
})
}
});
};
onLoad(()=>{
@ -130,8 +154,17 @@ const search=()=>{
rgba(255, 255, 255, 0) 100%
);
}
:deep(.u-image){
background: none!important;
}
.namebox {
padding-top: 102rpx;
/* #ifdef H5 */
padding-top: 51rpx;
/* #endif */
/* #ifdef MP-WEIXIN */
padding-top: 102rpx;
/* #endif */
margin: 0rpx 30rpx 0rpx;
display: flex;
align-items: center;
@ -141,7 +174,17 @@ const search=()=>{
.name {
margin-left: 16rpx;
font-size: 30rpx;
margin-right: 200rpx;
color: #111827;
position: relative;
.navbg{
position: absolute;
z-index:-1;
top:10rpx;
left:18rpx;
}
}
}
.search {

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
:zIndex="99"
:placeholder="false"
activeColor="#3CC7C0"
:safeAreaInsetBottom="false"
:safeAreaInsetBottom="true"
>
<up-tabbar-item text="临床病例库" @click="handleClick" :activeIcon="ku_on" :inactiveIcon="ku">
<!-- <view class="aa" slot="active-icon">222</view> -->
@ -86,7 +86,7 @@ const handleClick = (e) => {
};
</script>
<style >
<style lang='scss'>
.u-page__item__slot-icon{
width: 47rpx;
height: 47rpx;
@ -94,13 +94,12 @@ const handleClick = (e) => {
.u-tabbar__content__item-wrapper{
border-top: 2rpx solid #f3f4f6!important;
}
/* .page{
:deep(.u-tabbar){
border-top: 2rpx solid #f3f4f6!important;
}
:deep(.u-tabbar--fixed){
border-top: 2rpx solid #f3f4f6!important;
}
} */
.u-tabbar-item__text{
margin-bottom: 20rpx!important;
}
</style>

44
main.js
View File

@ -1,10 +1,9 @@
import App from './App'
import navTo from './utils/navTo'
// #ifdef h5
import api from './api/api'
import cookie from './utils/cookie'
// #endif
import uviewPlus, { setConfig } from 'uview-plus'
// #ifdef H5
//import VConsole from 'vconsole';
// const vConsole = new VConsole();
// #endif
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
@ -20,39 +19,7 @@ import { createSSRApp } from 'vue'
export function createApp() {
const app = createSSRApp(App)
app.use(uviewPlus, async() => {
// if(process.env.UNI_PLATFORM =="h5"){
// let token = '';
// if(window.location.href.indexOf('//casedata.igandan.com')>-1){
// token = uni.getStorageSync('AUTH_TOKEN_CASEDATA');
// }else{
// token = uni.getStorageSync('DEV_AUTH_TOKEN_CASEDATA');
// }
// if(!token){
// let video_token = cookie.readCookie('video_token');
// alert(video_token)
// const res = await api.h5Login({
// token: video_token
// });
// let result = res.data;
// if (window.location.href.indexOf('//casedata.igandan.com')>-1) {
// uni.setStorageSync("AUTH_TOKEN_CASEDATA",result.token);
// } else {
// uni.setStorageSync("DEV_AUTH_TOKEN_CASEDATA",result.token);
// };
// uni.setStorageSync("userInfo",{
// avatar:result.avatar,
// user_id:result.user_id,
// status:result.status,
// user_name:result.user_name,
// doctor_id:result.doctor_id,
// });
// }
// }
app.use(uviewPlus, async() => {
return {
options: {
// 修改$u.config对象的属性
@ -64,7 +31,6 @@ export function createApp() {
}
})
//app.config.globalProperties.$navTo = navTo
return {
app
}

View File

@ -61,13 +61,12 @@
"minified" : true,
"postcss" : true
},
"plugins": {
"polyv-player": {
"version": "1.9.0",
"provider": "wx4a350a258a6f7876"
"plugins" : {
"polyv-player" : {
"version" : "1.9.1",
"provider" : "wx4a350a258a6f7876"
}
},
"usingComponents" : true
},
"mp-alipay" : {
@ -86,10 +85,11 @@
"h5" : {
"router" : {
"mode" : "history",
"base" : ""
"base" : "/web/"
},
"devServer" : {
"https" : false
}
},
"title" : "肝胆相照临床病例库"
}
}

View File

@ -11,6 +11,7 @@
"license": "ISC",
"dependencies": {
"dayjs": "^1.11.13",
"uview-plus": "^3.4.4"
"uview-plus": "^3.4.4",
"vconsole": "^3.15.1"
}
}

View File

@ -116,6 +116,13 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/web/web",
"style": {
"navigationBarTitleText": "肝胆相照临床病例库",
"navigationStyle": "custom"
}
},
{
"path": "pages/searchList/searchList",
"style": {

View File

@ -7,7 +7,7 @@
<view class="name">病例数</view>
</view>
<view class="cell">
<view class="num">{{numInfo.exchange_collect_num}}</view>
<view class="num">{{numInfo.exchange_comment_num}}</view>
<view class="name">互动数</view>
</view>
<view class="cell">
@ -28,7 +28,7 @@
<view class="list">
<view class="cell" v-for="item in most_read_articles" :key="item.exchange_id" @click="goDetail(item.exchange_id)">
<view class="circle"></view>
<view class="title ellipsis-two-lines">{{ item.exchange_title }}</view>
<view class="title ellipsis-one-lines">{{ item.exchange_title }}</view>
</view>
</view>
</view>
@ -62,7 +62,8 @@
>{{ item.user_name }}{{ item.hospital_name }}</view
>
</view>
<view class="content" @click="goDetail(item.exchange_id)"> {{ item.exchange_content }} </view>
<view class="content ellipsis-two-lines" @click="goDetail(item.exchange_id)"> {{ htmlToText(item.exchange_content) }} </view>
<view
class="imgbox"
@click="goDetail(item.exchange_id)"
@ -73,7 +74,8 @@
>
<view
class="imgcell"
v-for="unit in item.exchange_content_image"
v-for="unit in item.exchange_content_image.slice(0,3)"
:key="unit"
>
<up--image
:src="unit"
@ -85,28 +87,32 @@
</up--image>
</view>
</view>
<view class="videocon">
<view class="videocon" @click="goDetail(item.exchange_id)">
<view
class="imgbox"
@click="goDetail(item.exchange_id)"
v-if="
item.exchange_content_video &&
item.exchange_content_video.length > 0
"
>
<view class="videomask"></view>
<video
:key="index"
v-for="(videoCell, index) in item.exchange_content_video"
:key="index"
v-for="(videoCell, index) in item.exchange_content_video.slice(0,1)"
class="myVideo"
:src="videoCell"
@error="videoErrorCallback"
controls
></video>
</view>
</view>
<view class="tagbox" @click="goDetail(item.exchange_id)" v-if="item.label && item.label.length > 0">
<view class="tagbox" @click="goDetail(item.exchange_id)" v-if="item.exchange_label">
<view
class="tag"
v-for="cell in item.label"
v-for="cell in item.exchange_label"
:key="cell.exchange_label_id"
>{{ cell.label_name }}</view
>
@ -117,7 +123,7 @@
<up-icon name="eye" color="#4B5563" size="28rpx"></up-icon>
<view class="num">{{ item.read_num }}</view>
</view>
<view class="collect item">
<view class="collect item" v-if="item.collect_num>0">
<up-icon
name="heart"
color="#4B5563"
@ -125,7 +131,7 @@
></up-icon>
<view class="num">{{ item.collect_num }}</view>
</view>
<view class="chat item">
<view class="chat item" v-if="item.comment_num>0">
<up-icon name="chat" color="#4B5563" size="28rpx"></up-icon>
<view class="num">{{ item.comment_num }}</view>
</view>
@ -142,6 +148,9 @@
</view>
<tabBar :value="2"></tabBar>
<view class="publish" @click="goPublish">
<up-icon name="plus" color="#fff" size="18"></up-icon>
</view>
</template>
<script setup>
@ -152,12 +161,12 @@
import navTo from "@/utils/navTo.js";
import api from "@/api/api";
import { reactive,ref } from 'vue';
import { onShow } from "@dcloudio/uni-app";
import { onShow,onLoad } from "@dcloudio/uni-app";
const numInfo=reactive({})
const most_read_articles=ref([]);
const new_articles=ref([]);
const hasUse=ref(false);
const formatdate = (date) => {
return dayjs(date).format('YYYY-MM-DD')
};
@ -190,15 +199,18 @@ const goDetail = (id) => {
});
};
const videoErrorCallback = (e) => {
uni.showModal({
content: e.target.errMsg,
showCancel: false
})
// uni.showModal({
// content: e.target.errMsg,
// showCancel: false
// })
};
const searchList = async () => {
let searchForm = {
page: 1,
page_size: 10
page_size: 20,
order:{
push_date:'desc'
}
};
api.searchExchage({
@ -211,10 +223,20 @@ const goDetail = (id) => {
});
};
const htmlToText=(html)=>{
return html
.replace(/<[^>]*>/g, '') // HTML
.replace(/&nbsp;/gi, ' ') // HTML
.replace(/<br\s*\/?>/gi, '\n').replace(/<img\s[^>]*>/gi, '') // <img>
.replace(/<video[^>]*>[\s\S]*?/gi, ''); //
}
const goodList=()=>{
api.searchExchageGood({
is_selected:1,
limit:5
order:{
push_date:'desc'
},
limit:3
}).then(res=>{
most_read_articles.value=res.data.data
})
@ -224,10 +246,31 @@ const hotList=()=>{
is_selected:0,
limit:5,
page:1,
page_size:5
}).then(res=>{
})
}
const goPublish=()=>{
navTo({
url:'/pages/publish/publish'
})
}
onLoad((options)=>{
console.log(options.from && !hasUse.value)
if(options.from && !hasUse.value){
setTimeout(()=>{
uni.showToast({
duration:2000,
title: "提交成功,请耐心等待审核",
icon: "none",
})
hasUse.value=true;
},300)
}
})
onShow(()=>{
getStatic();
goodList();
@ -236,18 +279,39 @@ onShow(()=>{
</script>
<style lang='scss' scoped>
.publish{
width: 92rpx;
height: 92rpx;
background: #3CC7C0;
border-radius: 50%;
position: fixed;
z-index:9;
right:30rpx;
bottom:180rpx;
display: flex;
justify-content: center;
align-items: center;
}
.upage {
display: flex;
flex-direction: column;
height: calc(100vh - 100rpx);
height: calc(100vh - 120rpx);
}
.mostread{
.title{
font-weight: bold;
}
.cell{
padding: 22rpx 0rpx!important;
margin:0 30rpx;
display: flex;
.title{
font-weight: normal!important;
}
}
}
.box {
flex: 1;
@ -341,7 +405,13 @@ onShow(()=>{
.imgbox {
display: flex;
margin-top: 24rpx;
position: relative;
.videomask{
position: absolute;
width:100%;
height:100%;
z-index:2;
}
.imgcell {
width: 220rpx;
height: 220rpx;
@ -357,6 +427,7 @@ onShow(()=>{
}
.special {
background-color: #fff;
padding-top: 30rpx;
.list {
@ -366,6 +437,7 @@ onShow(()=>{
border-bottom: 2rpx solid #f3f4f6;
.circle {
flex-shrink: 0;
margin-top: 15rpx;
width: 15rpx;
height: 15rpx;
@ -377,6 +449,7 @@ onShow(()=>{
margin-left: 10rpx;
font-size: 32rpx;
color: #000000;
font-weight: bold;
line-height: 46rpx;
}
}
@ -391,7 +464,10 @@ onShow(()=>{
margin: 0 30rpx;
display: flex;
justify-content: space-between;
.title{
font-weight: bold;
font-size: 32rpx;
}
.more {
display: flex;
align-items: center;

View File

@ -1,12 +1,14 @@
<template>
<backNav :navName="'病例收录证书'"></backNav>
<view class="imgbox">
<u--image
:showLoading="true"
:src="src"
width="100%"
mode="widthFix"
></u--image>
<view class="box">
<u--image
:showLoading="true"
:src="src"
width="100%"
mode="widthFix"
></u--image>
</view>
</view>
</template>
@ -27,6 +29,16 @@ onLoad((options) => {
<style lang='scss' scoped>
.imgbox{
margin-top: -30rpx;
margin: -30rpx 30rpx 0;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction:column;
justify-content: center;
min-height:calc(100vh - 200rpx);
.box{
width:100%;
height:100%;
}
}
</style>

View File

@ -9,10 +9,10 @@
@query="queryList"
>
<template #top>
<navBarSearch :searchWord="keyWord" :navName="navName" @changeWord="changeWord"></navBarSearch>
<view class="detail" v-if="isSearch">
<view class="desc" >检索到<text class="red">{{numInfo.search_article_num}}篇文章</text></view>
<view class="desc">检索到<text class="red">{{numInfo.search_video_num}}个视频</text></view>
<navBarSearch :searchWord="keyWord" :navName="navName" @changeWord="changeWord" :type="'cert'"></navBarSearch>
<view class="detail" v-if="isSearch">
<view class="desc" v-if="tab==0">检索到:<text class="red">{{total}}篇文章</text></view>
<view class="desc" v-if="tab==1">检索到:<text class="red">{{total}}个视频</text></view>
<view class="desc" v-if="keyWord">检索词:<text class="red">{{ keyWord }}</text></view>
</view>
<view class="tabcon">
@ -25,10 +25,10 @@
</view>
</template>
<view class="item" v-for="(item, index) in dataList" :key="index" @click="goDetail(item.cert_image)">
<view class="title ellipsis">{{tab==0?item.article_title:item.video_title }}</view>
<view class="tagsbox">
<view class="title ellipsis-two-lines">{{tab==0?item.article_title:item.video_title }}</view>
<!-- <view class="tagsbox">
<view class="tag" v-for="tag in item.author" :key="tag.author_id">{{ tag.doctor_name }}</view>
</view>
</view> -->
<view class="deal">
<view class="left">
<view class="recored">
@ -55,7 +55,7 @@ import { ref, reactive } from "vue";
import navBarSearch from "@/components/navBarSearch/navBarSearch.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import navTo from "@/utils/navTo.js";
@ -70,7 +70,7 @@ const tab=ref(0);
const hospital_id=ref('');
const doctor_id=ref('');
const numInfo=reactive({});
const navName=ref('肝胆相照临床病例库')
const navName=ref('肝胆相照临床病例库收录证书')
const formatdate=(date)=>{
return dayjs(date).format('YYYY-MM-DD')
@ -93,6 +93,13 @@ onLoad((options) => {
if(options.doctor_id){
doctor_id.value=options.doctor_id;
}
let userInfo=uni.getStorageSync('userInfo');
if(userInfo.user_name){
navName.value=userInfo.user_name+"临床病例库收录证书"
}
})
onShow(()=>{
paging.value?.refresh();
})
const goDetail=(url)=>{
@ -278,6 +285,7 @@ const queryList = (pageNo, pageSize) => {
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;

File diff suppressed because it is too large Load Diff

View File

@ -68,10 +68,10 @@
</view>
</view>
<view class="listbox">
<view class="cell" v-for="item in new_articles" :key="item.article_id" @click="goDetail(item.article_id)">
<view class="cell" v-for="item in new_articles" :key="item.article_id" @click="goDetail(item.article_id,item.is_link,item.is_link_url)">
<view class="circle"></view>
<view class="info">
<view class="name">{{item.article_title }}</view>
<view class="name ellipsis-two-lines">{{item.article_title }}</view>
<view class="tagsbox">
<view class="tag" v-for="tag in item.author" :key="tag.author_id">{{ tag.doctor_name }}</view>
</view>
@ -89,10 +89,10 @@
</view>
</view>
<view class="listbox">
<view class="cell" v-for="item in most_read_articles" :key="item.article_id" @click="goDetail(item.article_id)">
<view class="circle"></view>
<view class="cell" v-for="item in most_read_articles" :key="item.article_id" @click="goDetail(item.article_id,item.is_link,item.is_link_url)">
<view class="circle "></view>
<view class="info">
<view class="name">{{item.article_title }}</view>
<view class="name ellipsis-two-lines">{{item.article_title }}</view>
<view class="tagsbox">
<view class="tag" v-for="tag in item.author" :key="tag.author_id">{{ tag.doctor_name }}</view>
</view>
@ -109,7 +109,7 @@
</view>
</view>
<view class="list">
<view class="cell" v-for="item in recommend_hospital" :key="item.hospital_id" @click="goListBy(item.hospital_id,item.hospital_name ,'hospital')">{{ item.hospital_name }}</view>
<view class="cell ellipsis-one-lines" v-for="item in recommend_hospital" :key="item.hospital_id" @click="goListBy(item.hospital_id,item.hospital_name ,'hospital')">{{ item.hospital_name }}</view>
</view>
</view>
<view class="recbox">
@ -121,12 +121,13 @@
</view>
</view>
<view class="list">
<view class="cell" v-for="item in recommend_doctor" :key="item.doctor_id" @click="goListBy(item.doctor_id,item.doctor_name ,'doctor')">{{ item.doctor_name }}</view>
<view class="cell ellipsis-one-lines" v-for="item in recommend_doctor" :key="item.doctor_id" @click="goListBy(item.doctor_id,item.doctor_name ,'doctor')">{{ item.doctor_name }}({{item.hospital_name}})</view>
</view>
</view>
</view>
<tabBar></tabBar>
</template>
<script setup>
@ -149,10 +150,28 @@ const new_articles = ref([]);
const recommend_doctor=ref([]);
const recommend_hospital=ref([]);
const numInfo=reactive({})
const goDetail = (id) => {
navTo({
url: `/pages/detail/detail?id=${id}&type=article`,
});
const goDetail = (id,isLink,src) => {
if(isLink==1){
api.readRecord({
type:1,
id:id
}).then(res=>{
})
// #ifdef MP-WEIXIN
navTo({
url: `/pages/web/web?src=${src}`,
});
// #endif
// #ifdef H5
window.location.href=`${src}`
// #endif
}else{
navTo({
url: `/pages/detail/detail?id=${id}&type=article`,
});
}
};
const goListBy=(id,name,type)=>{
let url=''
@ -208,6 +227,7 @@ const goHospital=()=>{
url:'/pages/searchList/searchList?type=hospital&name=医院&id='
})
}
onShow(() => {
getStatic();
getData();
@ -218,6 +238,7 @@ onShow(() => {
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;
@ -261,6 +282,7 @@ onShow(() => {
margin-top: 18rpx;
width: 16rpx;
height: 16rpx;
flex-shrink: 0;
background: #3cc7c0;
border-radius: 50%;
}
@ -280,6 +302,10 @@ onShow(() => {
margin: 0 30rpx;
display: flex;
justify-content: space-between;
.title{
font-weight: bold;
font-size: 32rpx;
}
.more {
display: flex;
align-items: center;
@ -296,7 +322,7 @@ onShow(() => {
}
.page {
background: #f9fafb;
height: calc(100vh - 400rpx);
height: calc(100vh - 425rpx);
overflow-y: scroll;
}
.kubox {
@ -344,4 +370,5 @@ onShow(() => {
}
}
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<view class="logincontent">
<view class="title">登录观看</view>
<view class="title">欢迎登录</view>
<up-form labelPosition="left" ref="form" labelWidth="115rpx">
<up-form-item
label="手机号"
@ -10,7 +10,7 @@
>
<up-input
v-model="phone"
placeholder="请输入肝胆相照专家版手机号"
placeholder="请输入手机号"
></up-input>
</up-form-item>
<!-- <view v-if="isPhoneLogin" class="pwdbox">
@ -122,10 +122,11 @@
<view class="right" @click="goRegister"> 注册 </view>
</view>
<view class="row" style="margin-top:10rpx">
<radio-group @click="radioChange" :labelDisabled="true" >
<label class="radio"><radio :labelDisabled="true" value="1" :checked="checked" color="rgb(41, 121, 255)" />
我已阅读并同意<a class="agree">用户协议</a>
<radio-group :labelDisabled="true" class="group" >
<label class="radio"><radio :labelDisabled="true" @click="radioChange" value="1" :checked="checked" color="rgb(41, 121, 255)" />
</label>
我已阅读并同意<text class="agree" @click="goAgree">用户协议</text>
</radio-group>
<!-- <up-radio-group v-model="checked">
<up-radio
@ -136,16 +137,16 @@
<view class="row">
<view class="tip">操作说明</view>
</view>
<view class="line">
<!-- <view class="line">
<view class="qq">1</view>
肝胆相照注册账号与微信绑定肝胆相照相关直播视频无忧随心看
</view>
<view class="line">
</view> -->
<!-- <view class="line">
<view class="qq">2</view>
仅需操作一次后续通过微信观看直播视频无需额外操作立即进入
</view>
</view> -->
<view class="line">
<view class="qq">3</view>
<!-- <view class="qq">3</view> -->
若您还未注册肝胆相照专家版App, 请直接点击注册进行注册操作
</view>
<view class="desc">
@ -155,6 +156,19 @@
>
</view>
</view>
<up-overlay :show="showModal" :mask-click-able="false">
<view class="zanboxpop">
<view class="zanwraper">
<view class="title">提示</view>
<view class="content">{{reason}} </view>
<view class="btnbox" style="justify-content: center;">
<view class="cancle" @click="showModal = false">取消</view>
<view class="ok" @click="goPrefect">去完善</view>
</view>
</view>
</view>
</up-overlay>
</template>
<script setup>
@ -162,10 +176,13 @@ import { ref, reactive } from "vue";
import { onShow,onLoad } from "@dcloudio/uni-app";
import api from "@/api/api";
import auth from "@/utils/auth";
import navTo from "../../utils/navTo";
const code = ref('');
const phone = ref(null);
const showModal=ref(false)
const isPwdPic = ref(false);
const isPhoneLogin = ref(true);
const reason=ref('');
const customStyle = reactive({
height: "90rpx",
fontSize: "36rpx",
@ -234,6 +251,7 @@ const getPhoneNumber = (e) => {
source:1
})
.then((data) => {
uni.hideKeyboard()
let result=data.data.data;
if(process.env.UNI_PLATFORM =="h5"){
if(window.location.href.indexOf('//casedata.igandan.com')>-1){
@ -259,15 +277,36 @@ const getPhoneNumber = (e) => {
doctor_id:result.doctor_id,
});
goPage()
}).catch((err)=>{
uni.showToast({
title: err,
icon: "none",
});
});
}).catch(err=>{
let err_result=err.data;
if(err_result.code==10007){
showModal.value=true;
reason.value=err_result.message;
}
});
});
}
};
const goAgree=()=>{
uni.navigateTo({
url:'/pages/web/web?src=https://doc.igandan.com/app/integral/expert_zcxy.html'
})
}
const goPrefect=()=>{
showModal.value=false;
const { envVersion } = uni.getAccountInfoSync().miniProgram;
let src =''
if (envVersion == "release") {
src = "https://wx.igandan.com/hcp/perfect?mobile="+phone.value+"&fromType=weChat";
} else {
src = "https://dev-wx.igandan.com/hcp/perfect?mobile="+phone.value+"&fromType=weChat";
}
uni.navigateTo({
url:'/pages/register/register?src='+encodeURIComponent(src)
})
}
const phoneLogin = () => {
if (!isPhoneNum(phone.value)) return;
if (!code.value){
@ -291,6 +330,7 @@ const phoneLogin = () => {
wx_code: res,
})
.then((data) => {
uni.hideKeyboard()
let result=data.data.data;
const { envVersion } = uni.getAccountInfoSync().miniProgram;
if (envVersion == "release") {
@ -306,7 +346,15 @@ const phoneLogin = () => {
doctor_id:result.doctor_id,
});
goPage()
});
}).catch(err=>{
let err_result=err.data;
if(err_result.code==10007){
showModal.value=true;
reason.value=err_result.message;
}
});
});
}
@ -372,18 +420,26 @@ const start = () => {
</script>
<style lang="scss" scoped>
.radio{
display: inline-flex;
font-size: 28rpx;
radio{
transform:scale(0.8) translateY(-2px);
}
.agree {
background: none;
color:#3cc7c0;
}
.group{
display: flex;
font-size: 28rpx;
.agree {
background: none;
color:#3cc7c0;
font-size: 28rpx;
}
.radio{
display: inline-flex;
font-size: 28rpx;
radio{
transform:scale(0.8) translateY(-2px);
}
}
}
.logincontent {
width: 100%;
height: 100vh;
@ -392,7 +448,7 @@ const start = () => {
width: 180rpx !important;
}
.smsbox ::v-deep .u-form-item:first-child .u-form-item__body__left {
width: 180rpx !important;
width: 114rpx !important;
}
::v-deep .u-form-item__body__left__content__label {
font-size: 34rpx;
@ -410,7 +466,7 @@ const start = () => {
padding: 0 30rpx;
}
.row {
margin-top: 30rpx;
margin-top: 40rpx;
display: flex;
justify-content: space-between;
padding: 0 30rpx;
@ -468,4 +524,137 @@ const start = () => {
}
}
}
.zanboxpop {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.zanwraper {
width: 630rpx;
margin: 0 auto;
padding-bottom: 50rpx;
background: #f5f6f9;
border-radius: 16rpx;
.title {
margin-top: 48rpx;
text-align: center;
font-weight: 500;
font-size: 36rpx;
color: rgba(0, 0, 0, 0.85);
}
.content {
padding: 30rpx 0;
background: #f5f6f9;
text-align: center;
:deep(.imgstyle){
max-width: 100%!important;
}
:deep(._block){
text-align: left!important;
}
}
.count {
margin-top: 24rpx;
display: flex;
align-items: center;
text-align: center;
justify-content: center;
font-weight: 400;
font-size: 28rpx;
color: rgba(0, 0, 0, 0.65);
.num {
color: #ff0000;
font-size: 32rpx;
}
.earn {
font-size: 32rpx;
color: #3cc7c0;
}
}
.countbox {
display: flex;
width: 100%;
margin: 30rpx 0px 40rpx;
padding: 0 40rpx;
justify-content: center;
box-sizing: border-box;
.minus {
flex-shrink: 0;
margin-left: 10rp;
width: 90rpx;
height: 90rpx;
font-size: 60rpx;
color: #333;
display: flex;
justify-content: center;
align-items: center;
background: #ffffff;
border-radius: 12rpx;
border: 2rpx solid #e9e9e9;
}
.add {
flex-shrink: 0;
width: 90rpx;
margin-left: 10rpx;
height: 90rpx;
font-size: 60rpx;
color: #333;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
background: #3cc7c0;
border-radius: 12rpx;
border: 2rpx solid #3cc7c0;
}
:deep(.u-input__content__field-wrapper__field) {
height: 60rpx;
font-size: 34rpx !important;
text-align: center !important;
}
:deep(.u-input) {
background: #f2f2f2;
width: 200rpx !important;
flex: none;
}
:deep(.u-input--radius) {
border-radius: 24rrpx;
}
}
.btnbox {
margin: 0px 40rpx 0px;
display: flex;
justify-content: space-between;
.cancle {
color: rgba(0, 0, 0, 0.3);
width: 256rpx;
height: 88rpx;
background: #f5f6f9;
border-radius: 25px;
border: 2rpx solid rgba(0, 0, 0, 0.15);
font-weight: 500;
margin-right: 18rpx;
font-size: 32rpx;
color: rgba(0, 0, 0, 0.85);
display: flex;
justify-content: center;
align-items: center;
}
.ok {
color: #fff;
width: 256rpx;
height: 88rpx;
background: #3cc7c0;
border-radius: 25px;
border: 2rpx solid #3cc7c0;
font-weight: 500;
font-size: 32rpx;
display: flex;
justify-content: center;
align-items: center;
}
}
}
}
</style>

View File

@ -17,7 +17,7 @@
</view>
</view>
<view class="databox">
<view class="databox" v-if="numInfo.article_num+numInfo.video_num>=5">
<view class="cell">
<view class="num">{{ numInfo.article_num }}</view>
<view class="name">文章</view>
@ -32,45 +32,75 @@
</view>
</view>
<view class="listbox">
<view class="titlename">我的临床病例库</view>
<view class="cell" @click="goDoctor(userInfo.doctor_id,userInfo.user_name)" v-if="numInfo.article_num>0">
<view class="left">
<u-icon name="chat-fill" color="#000" size="28"></u-icon>
<!-- <view class="titlename">我的临床病例库</view> -->
<view class="cell" :class="{on:isHarmonyos}" @click="goDoctor(userInfo.doctor_id,userInfo.user_name)" v-if="numInfo.article_num+numInfo.video_num>=5"> <!-- 5 -->
<view class="left" >
<up--image
:src="myFile"
width="34rpx"
height="34rpx"
></up--image>
<view class="title">我的病例库</view>
</view>
<u-icon name="arrow-right" color="#9CA3AF" size="18"></u-icon>
</view>
<view class="cell" @click="goCert">
<view class="left">
<u-icon name="chat-fill" color="#000" size="28"></u-icon>
<view class="cell" :class="{on:isHarmonyos}" @click="goCert" v-if="numInfo.article_num+numInfo.video_num>=5">
<view class="left" >
<up--image
:src="myDownload"
width="42rpx"
height="42rpx"
></up--image>
<view class="title">临床病例库收录证书下载</view>
</view>
<u-icon name="arrow-right" color="#9CA3AF" size="18"></u-icon>
</view>
<view class="cell" @click="goHospital(userInfo.hospital_id,userInfo.hospital_name)" v-if="hospitalInfo.video_read_num>0 || hospitalInfo.article_read_num>0">
<view class="left">
<u-icon name="chat-fill" color="#000" size="28"></u-icon>
<view class="cell" :class="{on:isHarmonyos}" @click="goHospital(userInfo.hospital_id,userInfo.hospital_name)" v-if="hospitalInfo.video_num+hospitalInfo.article_num>=10">
<view class="left" >
<up--image
:src="myHospital"
width="39rpx"
height="39rpx"
></up--image>
<view class="title">{{userInfo.hospital_name}}临床病例库</view>
</view>
<u-icon name="arrow-right" color="#9CA3AF" size="18"></u-icon>
</view>
<view class="cell" @click="mySpecial">
<view class="left">
<u-icon name="chat-fill" color="#000" size="28"></u-icon>
<view class="cell" :class="{on:isHarmonyos}" @click="mySpecial">
<view class="left" >
<up--image
:src="myTalk"
width="39rpx"
height="39rpx"
></up--image>
<view class="title">我的病例交流</view>
</view>
<u-icon name="arrow-right" color="#9CA3AF" size="18"></u-icon>
</view>
<view class="cell" @click="goJoin">
<view class="left">
<u-icon name="chat-fill" color="#000" size="28"></u-icon>
<view class="cell" @click="goJoin" :class="{on:isHarmonyos}">
<view class="left" >
<up--image
:src="myJoin"
width="39rpx"
height="39rpx"
></up--image>
<view class="title">我的参与互动</view>
</view>
<u-icon name="arrow-right" color="#9CA3AF" size="18"></u-icon>
</view>
<view class="cell" @click="goCollect">
<view class="left">
<u-icon name="chat-fill" color="#000" size="28"></u-icon>
<view class="cell" @click="goCollect" :class="{on:isHarmonyos}">
<view class="left" >
<up--image
:src="myCollect"
width="39rpx"
height="39rpx"
></up--image>
<view class="title">浏览与收藏</view>
</view>
<u-icon name="arrow-right" color="#9CA3AF" size="18"></u-icon>
@ -80,7 +110,7 @@
</view>
</view>
<view class="publish" @click="goPublish">
<u-icon name="plus" color="#fff" size="18"></u-icon>
<up-icon name="plus" color="#fff" size="18"></up-icon>
</view>
</div>
</template>
@ -88,10 +118,18 @@
<script setup>
import dNav from "@/components/backNav/backNav.vue";
import headImg from "@/static/headImg.png";
import myFile from "@/static/myFile.png";
import myDownload from "@/static/myDownload.png";
import myCollect from "@/static/myCollect.png";
import myHospital from "@/static/myHospital.png";
import myJoin from "@/static/myJoin.png";
import myTalk from "@/static/myTalk.png";
import { onLoad } from "@dcloudio/uni-app";
import navTo from "@/utils/navTo.js";
import api from "@/api/api";
import { ref,reactive} from "vue";
const isHarmonyos=ref(false)
const userInfo=reactive({})
const numInfo=reactive({})
const hospitalInfo=reactive({})
@ -123,6 +161,15 @@ const getHospitalNum=(id)=>{
}
onLoad(()=>{
getInfo()
uni.getSystemInfo({
success(res){
console.log(res.platform);
console.log(res.platform=="ohos")
if(res.platform=="ohos"){
isHarmonyos.value=true;
}
}
})
})
const goCert = () => {
navTo({
@ -131,17 +178,17 @@ const goCert = () => {
};
const goDoctor=(id,name)=>{
navTo({
url:'/pages/searchList/searchList?type=doctor&id='+id+'&name='+name
url:'/pages/search/search?doctor_id='+id+'&doctor_name='+name+"&from=myCase"
})
}
const goHospital=(id,name)=>{
navTo({
url:'/pages/searchList/searchList?type=hospital&id='+id+'&name='+name
url:'/pages/search/search?hospital_id='+id+'&hospital_name='+name
})
}
const mySpecial=()=>{
navTo({
url:'/pages/specialList/specialList?userId='+userInfo.user_id
url:'/pages/specialList/specialList?userId='+userInfo.user_id+'&type=mine'
})
}
const goJoin=()=>{
@ -164,9 +211,11 @@ const goPublish=()=>{
.upage{
height:100vh;
display: flex;
background-color: #fff;
flex-direction: column;
}
.con{
background-color: #fff;
height:calc(100vh - 100rpx);
overflow-y: scroll;
}
@ -196,6 +245,10 @@ const goPublish=()=>{
display: flex;
justify-content: space-between;
align-items: center;
:deep(.u-image){
margin-top: 9rpx;
background: none!important;
}
.left{
display: flex;
justify-content: space-between;
@ -208,6 +261,12 @@ const goPublish=()=>{
}
}
.cell.on{
:deep(.u-image){
margin-top: 0rpx;
background: none!important;
}
}
}
.databox {
height: 162rpx;

View File

@ -10,6 +10,11 @@
>
<template #top>
<navBarSearch :searchWord="keyWord" :navName="navName" @changeWord="changeWord"></navBarSearch>
<view class="detail" v-if="isSearch">
<view class="desc" >检索到<text class="red">{{total}}</text></view>
<view class="desc" v-if="keyWord">检索词:<text class="red">{{ keyWord }}</text></view>
</view>
<view class="filterbox">
<!-- <view class="type" @click="swicthType">{{!isArticle?'文章':'视频'}}<up--image :src="switchImg" width="31rpx" height="31rpx"></up--image></view> -->
<up-dropdown class="u-dropdown" ref="uDropdownRef">
@ -29,8 +34,8 @@
</view>
</template>
<view class="item" v-for="(item, index) in dataList" @click="goDetail()" :key="item.collect_id">
<view class="title ellipsis">{{item.data.title}}</view>
<view class="item" v-for="(item, index) in dataList" @click="goDetail(item.data.id)" :key="item.collect_id">
<view class="title ellipsis-two-lines">{{item.data.title}}</view>
<view class="tagsbox">
<view class="tag" v-for="tag in item.data.author" :key="tag.author_id">{{ tag.doctor_name }}</view>
</view>
@ -60,7 +65,7 @@ import { ref, reactive } from "vue";
import navBarSearch from "@/components/navBarSearch/navBarSearch.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow} from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import navTo from "@/utils/navTo.js";
@ -71,6 +76,7 @@ const total = ref(0);
const value = ref("");
const keyWord = ref("");
const isArticle=ref(true);
const isSearch=ref(false);
const navName=ref('肝胆相照临床病例库');
const type1=ref(0);
const type2=ref(1);
@ -110,6 +116,9 @@ onLoad((options) => {
keyWord.value = options.keyWord;
};
});
onShow(()=>{
paging.value?.refresh();
})
const changetype1=(e)=>{
type1.value=e;
paging.value.reload();
@ -123,14 +132,22 @@ const formatdate=(date)=>{
return dayjs(date).format('YYYY-MM-DD')
}
const goDetail=(id)=>{
console.log(11111)
console.log(type1.value)
console.log(id)
let type=isArticle.value?'article':'video'
let type="article";
if(type2.value==1){
type="article";
}else if(type2.value==2){
type="video";
}else if(type2.value==3){
type="exchange";
}
navTo({
url: `/pages/detail/detail?id=${id}&type=${type}`
})
}
const changeWord=(val)=>{
isSearch.value=true;
keyWord.value=val;
paging.value.reload();
}
@ -203,6 +220,7 @@ const queryList = (pageNo, pageSize) => {
}
}
.filterbox{
background-color: #fff;
display: flex;
height:128rpx;
align-items: center;
@ -232,7 +250,7 @@ const queryList = (pageNo, pageSize) => {
background: #fff;
z-index:1;
margin-left:-30rpx;
margin-left:0rpx;
}
:deep(.u-dropdown__menu__item){
@ -242,9 +260,13 @@ const queryList = (pageNo, pageSize) => {
background: #F3F4F6;
border-radius: 15rpx;
flex:none;
margin-left: 60rpx;
margin-left: 30rpx;
}
:deep(.u-dropdown__menu__item__text){
font-size: 14px!important;
color:#3CC7C0!important;
}
.deal {
margin-top: 20rpx;
display: flex;
@ -273,6 +295,7 @@ const queryList = (pageNo, pageSize) => {
}
}
.item {
background-color: #fff;
border-bottom: 1rpx solid #f3f4f6;
padding: 30rpx;
.title {
@ -284,6 +307,7 @@ const queryList = (pageNo, pageSize) => {
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;

View File

@ -90,7 +90,7 @@ import { ref, reactive } from "vue";
import backLogoNav from "@/components/backLogoNav/backLogoNav.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import certImg from "@/static/cert.png";
@ -193,7 +193,9 @@ const switchTab = (item) => {
onLoad((options) => {
});
onShow(()=>{
paging.value?.refresh();
})
const formatdate = (date) => {
return dayjs(date).format("YYYY-MM-DD");
};
@ -530,6 +532,7 @@ const queryList = (pageNo, pageSize) => {
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;

File diff suppressed because it is too large Load Diff

2083
pages/publish/publish222.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -63,7 +63,7 @@
<script setup>
import { ref, reactive } from "vue";
import { onLoad } from "@dcloudio/uni-app";
const src = ref("https://wx.igandan.com/hcp/toRegister?fromType=wx");
const src = ref("https://dev-wx.igandan.com/hcp/toRegister?fromType=wx");
const model = reactive({
name: "",
});
@ -137,14 +137,20 @@ const end = () => {
const start = () => {
customCode.opacity = 0.5;
};
onLoad(() => {
onLoad((options) => {
const { envVersion } = uni.getAccountInfoSync().miniProgram;
console.log(envVersion)
if (envVersion == "release") {
src.value = "https://wx.igandan.com/hcp/toRegister";
} else {
src.value = "https://dev-wx.igandan.com/hcp/toRegister?fromType=weChat";
if(options.src){
console.log(decodeURIComponent(options.src))
src.value=decodeURIComponent(options.src);
}else{
if (envVersion == "release") {
src.value = "https://wx.igandan.com/hcp/toRegister?fromType=weChat";
} else {
src.value = "https://dev-wx.igandan.com/hcp/toRegister?fromType=weChat";
}
}
});
</script>

View File

@ -9,14 +9,14 @@
@query="queryList"
>
<template #top>
<navBarSearch :searchWord="keyWord" :navName="navName" @changeWord="changeWord"></navBarSearch>
<navBarSearch :searchWord="keyWord" :navName="navName" @changeWord="changeWord" :type="navType"></navBarSearch>
<view class="databox" v-if="hospital_id || doctor_id || (label_iden && !canOpenCase)">
<view class="cell">
<view class="num">{{ numInfo.article_collect_num }}</view>
<view class="num">{{ numInfo.article_num }}</view>
<view class="name">文章</view>
</view>
<view class="cell">
<view class="num">{{ numInfo.video_collect_num }}</view>
<view class="num">{{ numInfo.video_num }}</view>
<view class="name">视频</view>
</view>
<view class="cell">
@ -27,31 +27,53 @@
<view class="bar"></view>
<view class="detail" v-if="isSearch">
<view class="desc" >检索到<text class="red">{{searchInfo.search_article_num}}篇文章</text></view>
<view class="desc" >检索到<text class="red">{{searchInfo.search_video_num}}个视频</text></view>
<view class="desc" v-if="!hideType">检索到:<text class="red">{{searchInfo.search_video_num}}个视频</text></view>
<view class="desc" v-if="keyWord">检索词:<text class="red">{{ keyWord }}</text></view>
</view>
<view class="filterbox" :class="{active:canOpenCase}">
<view class="type" @click="swicthType">{{!isArticle?'文章':'视频'}}<up--image :src="switchImg" width="31rpx" height="31rpx"></up--image></view>
<view class="casesdown" @click="openCase" v-if="canOpenCase">筛选<up--image :src="caseImg" width="31rpx" height="31rpx"></up--image></view>
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<view class="filterbox" :class="{active:canOpenCase,'on':hideType,'isCase':isCase}">
<view class="type" v-if="!hideType && !isCase" @click="swicthType">{{!isArticle?'视频':'文章'}}<up--image :src="switchImg" width="31rpx" height="31rpx"></up--image></view>
<view class="casesdown" :class="{active:label_iden}" @click="openCase" v-if="canOpenCase">筛选<up--image :src="caseImg" width="31rpx" height="31rpx"></up--image></view>
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<up-dropdown-item
v-model="order.push_date"
title="发布时间"
@change="change"
:options="options"
></up-dropdown-item>
<up-dropdown-item
:title="dropTitle"
>
<view class="dropcontent">
<up-radio-group
@change="changeDate"
v-model="orderFilter"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in options"
:key="item.value"
:class="[orderFilter==item.value?'active':'']"
>
<up-radio
activeColor="#3CC7C0"
:label="item.label"
:name="item.value"
></up-radio>
</view>
</up-radio-group>
</view>
</up-dropdown-item>
<!-- <up-dropdown-item
v-model="order.read_num"
title="阅读量"
@change="change"
@change="changeRead"
:options="options"
></up-dropdown-item>
></up-dropdown-item> -->
</up-dropdown>
</view>
</template>
<view class="item" v-for="(item, index) in dataList" :key="index" @click="isArticle?goDetail(item.article_id):goDetail(item.video_id)">
<view class="title ellipsis">{{isArticle?item.article_title:item.video_title }}</view>
<view class="item" v-for="(item, index) in dataList" :key="index" @click="isArticle?goDetail(item.article_id,item.is_link,item.is_link_url):goDetail(item.video_id,item.is_link,item.is_link_url)">
<view class="title ellipsis-two-lines">{{isArticle?item.article_title:item.video_title }}</view>
<view class="tagsbox">
<view class="tag" v-for="tag in item.author" :key="tag.author_id">{{ tag.doctor_name }}</view>
</view>
@ -61,9 +83,9 @@
<up-icon name="eye" color="#6B7280" size="28rpx"></up-icon>
<view class="num">{{item.read_num }}</view>
</view>
<view class="collect">
<view class="collect" v-if="item.collect_num>0">
<up-icon name="heart" color="#6B7280" size="28rpx"></up-icon>
<view class="num">{{item.collect_num }}</view>
<view class="num" >{{item.collect_num }}</view>
</view>
</view>
<view class="time">
@ -77,30 +99,60 @@
<up-popup
:round="10"
zIndex="9"
:closeOnClickOverlay="false"
:show="showCase"
mode="bottom"
@close="closeCase"
>
<view class="votepop casepop">
<view class="titlebox">
<view class="left" @click="showCase = false">取消</view>
<view class="left continue" @click="continueCase" v-show="level != 3"
>继续选择</view
>
<view class="left" @click="closeCase">取消</view>
<view class="right" @click="confirmCase">确定</view>
</view>
<view class="casecon">
<view class="stepbox">
<up-steps :current="level-1" direction="column" :key="freshKey">
<up-steps-item >
<template v-slot:content>
<view class="slot-content" @click="openCaseLevel('1')">
<view class="left">{{!caseValue1.name?'请选择选项':caseValue1.name}}</view>
<u-icon name="arrow-right"></u-icon>
</view>
</template>
</up-steps-item>
<up-steps-item v-if="caseValue1.name && labelObj.list2.length>0">
<template v-slot:content>
<view class="slot-content" @click="openCaseLevel('2')">
<view class="left">{{!caseValue2.name?'请选择选项':caseValue2.name}}</view>
<u-icon name="arrow-right"></u-icon>
</view>
</template>
</up-steps-item>
<up-steps-item v-if="caseValue2.name && labelObj.list3.length>0">
<template v-slot:content>
<view class="slot-content" @click="openCaseLevel('3')">
<view class="left">{{!caseValue3.name?'请选择选项':caseValue3.name}}</view>
<u-icon name="arrow-right"></u-icon>
</view>
</template>
</up-steps-item>
</up-steps>
</view>
<scroll-view class="casecon" scroll-y="true">
<view v-show="level == 1" >
<up-radio-group
v-model="caseValue1"
v-model="caseValue1.value"
name="group1"
iconPlacement="right"
placement="column"
@change="groupChange1"
>
<view
class="column"
v-for="item in labelObj.list1"
:key="item.app_iden"
v-show="item.label_name!='热门话题'"
>
<up-radio
activeColor="#3CC7C0 "
@ -112,7 +164,9 @@
</view>
<view v-show="level == 2" >
<up-radio-group
v-model="caseValue2"
name="group2"
@change="groupChange2"
v-model="caseValue2.value"
iconPlacement="right"
placement="column"
>
@ -131,14 +185,15 @@
</view>
<view v-show="level == 3" >
<up-radio-group
v-model="caseValue3"
name="group3"
@change="groupChange3"
v-model="caseValue3.value"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in labelObj.list2"
v-for="item in labelObj.list3"
:key="item.app_iden"
>
<up-radio
@ -149,7 +204,7 @@
</view>
</up-radio-group>
</view>
</view>
</scroll-view>
</view>
</up-popup>
</template>
@ -159,13 +214,12 @@ import { ref, reactive } from "vue";
import navBarSearch from "@/components/navBarSearch/navBarSearch.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow} from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import caseImg from "@/static/caseIcon.png";
import navTo from "@/utils/navTo.js";
const paging = ref(null);
const uDropdownRef=ref(null);
const dataList = ref([]);
const total = ref(0);
const keyWord = ref("");
@ -176,98 +230,167 @@ const hospital_name=ref('');
const doctor_id=ref('');
const doctor_name=ref('');
const numInfo=reactive({});
const searchInfo=reactive({})
const searchInfo=reactive({});
const freshKey=ref(0);
const navType=ref('')
const navName=ref('肝胆相照临床病例库')
const paging = ref(null);
const hideType=ref(false)
const dropTitle=ref('发布时间');
const orderFilter=ref('1')
const uDropdownRef=ref(null);
const isCase=ref(false);
const options= ref([
{
label: "正序",
value: 'asc',
label: "发布时间",
value: '1',
},
{
label: "倒序",
value: 'desc',
label: "阅读次数",
value: '2',
},
]);
const isSearch=ref(false);
const order=reactive({
read_num:'',
push_date:''
push_date:'desc'
})
const showCase = ref(false);
const canOpenCase = ref(false);
const caseValue1 = ref("");
const caseValue2 = ref("");
const caseValue3 = ref("");
const caseValue1 = reactive({
value:'',
name:'',
});
const caseValue2 = reactive({
value:'',
name:'',
});
const caseValue3 = reactive({
value:'',
name:'',
});
const level = ref(1);
const labelObj = reactive({
list1: [],
list2: [],
list3: [],
list3: []
});
const openCaseLevel=(lev)=>{
freshKey.value++;
level.value=lev;
if(lev==1){
caseValue2.name='';
caseValue2.value='';
labelObj.list2=[]
}else if(lev==2){
labelObj.list3=[];
caseValue3.name='';
caseValue3.value=''
}
};
const groupChange1=(e)=>{
caseValue1.value=e;
for (var i = 0; i <labelObj.list1.length; i++) {
if(labelObj.list1[i].app_iden==caseValue1.value){
caseValue1.name=labelObj.list1[i].label_name;
break;
}
}
getCaseLabel(2,e)
}
const groupChange2=(e)=>{
caseValue2.value=e;
for (var i = 0; i <labelObj.list2.length; i++) {
if(labelObj.list2[i].app_iden==caseValue2.value){
caseValue2.name=labelObj.list2[i].label_name;
break;
}
}
getCaseLabel(3,e)
}
const groupChange3=(e)=>{
caseValue3.value=e;
level.value =3
for (var i = 0; i <labelObj.list3.length; i++) {
if(labelObj.list3[i].app_iden==caseValue3.value){
caseValue3.name=labelObj.list3[i].label_name;
break;
}
}
}
const getCaseLabel = (lev,pid=0) => {
api.getCaseLabel({
pid:pid
pId:pid
}).then((res) => {
level.value = lev;
if (lev == 1) {
labelObj.list1 = res.data.data;
labelObj.list1 = res.data.data;
//label_iden.value = caseValue1.value;
} else if (lev == 2) {
labelObj.list2 = res.data.data;
if(res.data.data.length==0){
level.value = 1
}
//label_iden.value = caseValue2.value;
} else if (lev == 3) {
labelObj.list3 = res.data.data;
}
level.value = lev;
if(res.data.data.length==0){
level.value = 2
}
//label_iden.value = caseValue3.value;
}
});
};
const openCase=()=>{
getCaseLabel(1,0)
showCase.value = true;
}
const cancelCase=()=>{
showCase.value=false;
level.value=1;
caseValue1.name='';
caseValue1.value='';
caseValue2.name='';
caseValue2.value='';
caseValue3.name='';
caseValue3.value='';
//labelObj.list1=[];
labelObj.list2=[];
labelObj.list3=[];
}
const confirmCase = () => {
if (level.value == 1 && caseValue1.value == "") {
uni.showToast({ title: "请选择疾病", icon: "none" });
uni.showToast({ title: "请选择疾病选项", icon: "none" });
return false;
}
if (level.value == 2 && caseValue2.value == "") {
uni.showToast({ title: "请选择疾病", icon: "none" });
return false;
}
if (level.value == 3 && caseValue3.value == "") {
uni.showToast({ title: "请选择疾病", icon: "none" });
return false;
}
showCase.value = false;
};
if(level.value == 1){
label_iden.value = caseValue1.value
}
label_iden.value = caseValue1.value;
}
if(level.value == 2 ){
label_iden.value = caseValue2.value
if(!caseValue2.value){
label_iden.value = caseValue1.value;
}else{
label_iden.value = caseValue2.value;
}
}
if(level.value == 3 ){
label_iden.value = caseValue3.value
}
};
const continueCase = () => {
if (level.value == 1 && caseValue1.value == "") {
uni.showToast({ title: "请选择疾病", icon: "none" });
return false;
}
if (level.value == 2 && caseValue2.value == "") {
uni.showToast({ title: "请选择疾病", icon: "none" });
return false;
}
if (level.value == 3 && caseValue3.value == "") {
uni.showToast({ title: "请选择疾病", icon: "none" });
return false;
}
if(level.value == 1 ){
getCaseLabel(2,caseValue1.value);
}else if(level.value == 2 ){
getCaseLabel(3,caseValue2.value);
if(!caseValue3.value){
label_iden.value = caseValue2.value;
}else{
label_iden.value = caseValue3.value;
}
}
paging.value.reload();
showCase.value=false;
//cancelCase();
};
const closeCase = () => {
showCase.value = false;
@ -279,14 +402,23 @@ const changeWord=(value)=>{
}
onLoad((options) => {
if(options.from=='home'){
isSearch.value=true;
}else if(options.from=="myCase"){
navType.value="myCase"
}
if(options.keyWord){
keyWord.value = options.keyWord;
};
if(options.order=='new'){
order.push_date='asc'
order.push_date='desc';
hideType.value=true;
navName.value='文章临床病例库'
};
if(options.order=='read'){
order.read_num='desc'
order.push_date='desc';
hideType.value=true;
navName.value='文章临床病例库'
};
if(options.order=='video'){
isArticle.value=false;
@ -307,38 +439,81 @@ onLoad((options) => {
if(options.case_id){
label_iden.value=options.case_id;
navName.value= options.case_name+'临床病例库'
getStaticSick(label_iden.value)
getStaticSick(label_iden.value);
//isCase.value=true;
//console.log(111113)
}else{
canOpenCase.value = true;
}
getCaseLabel(1,0)
});
const change=(e)=>{
onShow(()=>{
paging.value?.refresh();
})
const changeDate=(e)=>{
if(e==1){
order.push_date='desc';
order.read_num='';
dropTitle.value="发布时间"
}else{
order.push_date='';
order.read_num="desc";
dropTitle.value="阅读次数"
}
orderFilter.value=e;
uDropdownRef.value.close();
paging.value.reload();
}
const changeRead=(e)=>{
console.log(e);
order.push_date=''
paging.value.reload();
}
const formatdate=(date)=>{
return dayjs(date).format('YYYY-MM-DD')
}
const goDetail=(id)=>{
console.log(11111)
console.log(id)
let type=isArticle.value?'article':'video'
navTo({
url: `/pages/detail/detail?id=${id}&type=${type}`
})
const goDetail=(id,isLink,src)=>{
console.log(isLink)
if(isLink==1){
api.readRecord({
type:isArticle.value?1:2,
id:id
}).then(res=>{
})
// #ifdef MP-WEIXIN
navTo({
url: `/pages/web/web?src=${src}`,
});
// #endif
// #ifdef H5
window.location.href=`${src}`
// #endif
}else{
let type=isArticle.value?'article':'video'
navTo({
url: `/pages/detail/detail?id=${id}&type=${type}`
})
}
}
const swicthType=()=>{
isArticle.value=!isArticle.value;
dataList.value=[];
order.read_num='';
order.push_date='';
// order.read_num='';
// order.push_date='';
paging.value.reload();
}
const searchArticle =(params) => {
let searchForm={
keyword: keyWord.value,
hospital_id:hospital_id.value,
doctor_id:doctor_id.value
doctor_id:doctor_id.value,
label_iden:label_iden.value
}
if(label_iden.value===''){
delete searchForm.label_iden
}
if(isSearch.value){
searchForm.is_need_num=1;
}
@ -368,8 +543,12 @@ const searchVideo = async(params) => {
let searchForm={
keyword: keyWord.value,
hospital_id:hospital_id.value,
doctor_id:doctor_id.value
doctor_id:doctor_id.value,
label_iden:label_iden.value
}
if(label_iden.value===''){
delete searchForm.label_iden
}
if(isSearch.value){
searchForm.is_need_num=1;
}
@ -458,6 +637,42 @@ const queryList = (pageNo, pageSize) => {
</script>
<style lang="scss" scoped>
.dropcontent{
padding-top: 40rpx;
padding-bottom: 20rpx;
background-color: #fff;
:deep(.u-radio){
margin-bottom: 6px!important;
margin-top: 16px!important;
}
.column {
padding: 0 30rpx;
border-top: 2rpx solid #e5e7eb;
:deep(.u-radio__text){
color: #333 !important;
}
}
.column.active{
:deep(.u-radio__text){
color: #3CC7C0 !important;
}
}
}
.stepbox{
padding:15rpx 30rpx;
border-bottom: 2rpx dotted #f3f4f6;
:deep(.u-steps-item__content){
margin-top: -5rpx!important;
}
.slot-content{
width:100%;
margin-bottom: 25rpx;
justify-content: space-between;
align-items: center;
display: flex;
}
}
.bar {
width: 100%;
background: #f9fafb;
@ -556,16 +771,23 @@ const queryList = (pageNo, pageSize) => {
color: #2878ff !important;
}
}
.casecon {
.casecon{
flex:1;
max-height: calc(100vh - 800rpx);
overflow-y: scroll;
padding-top: 10rpx;
padding-top: 22rpx;
padding-bottom: 20rpx;
min-height: 350rpx;
:deep(.u-radio){
margin-bottom: 10px!important;
margin-top: 0px!important;
}
.column {
padding: 0 30rpx;
border-bottom: 2rpx solid #e5e7eb;
}
}
.databox {
height: 162rpx;
background: #ffffff;
@ -591,6 +813,7 @@ const queryList = (pageNo, pageSize) => {
}
}
.filterbox{
background:#fff;
:deep(.u-flex) {
display: flex;
flex-direction:row;
@ -600,11 +823,12 @@ const queryList = (pageNo, pageSize) => {
:deep(.u-dropdown__menu){
background: #fff;
z-index:1;
margin-left: 160rpx;
margin-left: 150rpx;
}
:deep(.u-dropdown__menu__item__text){
font-size: 14px!important;
color:#3CC7C0!important;
}
:deep(.u-dropdown__menu__item){
@ -710,6 +934,25 @@ const queryList = (pageNo, pageSize) => {
z-index:2;
}
}
.filterbox.on{
.casesdown{
left:30rpx;
}
:deep(.u-dropdown__menu){
background: #fff;
z-index:1;
margin-left: 170rpx;
}
}
.filterbox.isCase{
:deep(.u-dropdown__menu){
background: #fff;
z-index:1;
margin-left: -30rpx;
}
}
.u-page {
.deal {
@ -741,6 +984,7 @@ const queryList = (pageNo, pageSize) => {
}
.item {
border-bottom: 1rpx solid #f3f4f6;
background-color: #fff;
padding: 30rpx;
.title {
font-size: 30rpx;
@ -751,6 +995,7 @@ const queryList = (pageNo, pageSize) => {
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;

View File

@ -31,20 +31,43 @@
</view> -->
<view class="filterbox">
<!-- <view class="type" @click="swicthType">{{!isArticle?'文章':'视频'}}<up--image :src="switchImg" width="31rpx" height="31rpx"></up--image></view> -->
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<up-dropdown-item
v-model="order.push_date"
title="发布时间"
@change="change"
:options="options"
></up-dropdown-item>
<!-- <up-dropdown-item
v-model="order.read_num"
title="阅读量"
@change="change"
:options="options"
></up-dropdown-item> -->
</up-dropdown>
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<up-dropdown-item
:title="dropTitle"
>
<view class="dropcontent">
<up-radio-group
@change="change"
v-model="orderFilter"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in option"
:key="item.value"
:class="[orderFilter==item.value?'active':'']"
>
<up-radio
activeColor="#3CC7C0"
:label="item.label"
:name="item.value"
></up-radio>
</view>
</up-radio-group>
</view>
</up-dropdown-item>
<!-- <up-dropdown-item
v-model="order.read_num"
title="阅读量"
@change="changeRead"
:options="options"
></up-dropdown-item> -->
</up-dropdown>
</view>
</template>
@ -77,7 +100,7 @@ import { ref, reactive} from "vue";
import navBarSearch from "@/components/navBarSearch/navBarSearch.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow } from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import navTo from "@/utils/navTo.js";
@ -91,26 +114,32 @@ const keyWord = ref("");
const isArticle=ref(true)
const type=ref('doctor')
const navName=ref('医生临床病例库');
const options= ref([
const orderFilter=ref('1')
const dropTitle=ref('更新时间')
const option= ref([
{
label: "正序",
value: 'asc',
label: "更新时间",
value: '1',
},
{
label: "倒序",
value: 'desc',
label: "医生名称",
value: '2',
},
{
label: "病例数",
value: '3',
},
]);
const order=reactive({
read_num:'',
push_date:''
push_date:'desc'
})
onLoad((options) => {
if(options.type=='hospital'){
type.value ='hospital';
(option.value)[1].label="医院名称"
}
console.log( type.value)
keyWord.value=options.name;
if(options.name){
navName.value=options.name+'临床病例库'
@ -125,7 +154,43 @@ onLoad((options) => {
order.read_num='desc'
};
});
onShow(()=>{
paging.value?.refresh();
})
const change=(e)=>{
if(e==1){
order.push_date='desc';
if(type.value =='hospital'){
order.hospital_name='';
}else{
order.doctor_name='';
}
order.article_num='';
dropTitle.value="更新时间"
}else if(e==2){
order.push_date='';
order.article_num='';
if(type.value =='hospital'){
order.hospital_name='asc';
dropTitle.value="医院名称"
}else{
order.doctor_name='asc';
dropTitle.value="医生名称"
}
}else if(e==3){
order.push_date='';
if(type.value =='hospital'){
order.hospital_name='';
}else{
order.doctor_name='';
}
order.article_num='desc';
dropTitle.value="病例数"
}
orderFilter.value=e;
uDropdownRef.value.close();
paging.value.reload();
}
const formatdate=(date)=>{
@ -190,15 +255,19 @@ const searchHospital = async(params) => {
let searchForm={
hospital_name: keyWord.value,
}
if(!order.read_num){
delete order.read_num
}
if(!order.push_date){
delete order.push_date
delete order.push_date
}
if(!order.article_num){
delete order.article_num
}
if(order.read_num || order.push_date){
searchForm.order=order
}
if(!order.hospital_name){
delete order.hospital_name
}
if(order.push_date || order.article_num || order.hospital_name){
searchForm.order=order
}
api.searchHospital({
...searchForm,
...params
@ -213,13 +282,16 @@ const searchDoctor = async(params) => {
let searchForm={
doctor_name: keyWord.value,
}
if(!order.read_num){
delete order.read_num
}
if(!order.push_date){
delete order.push_date
delete order.push_date
}
if(!order.article_num){
delete order.article_num
}
if(order.read_num || order.push_date){
if(!order.doctor_name){
delete order.doctor_name
}
if(order.push_date || order.article_num || order.doctor_name){
searchForm.order=order
}
api.searchDoctor({
@ -257,20 +329,49 @@ const goDetail=(id,name)=>{
*/
const changeWord=(value)=>{
console.log(value);
if(value){
navName.value=value+'临床病例库';
if(type.value=='hospital'){
navName.value='医院临床病例库';
order.hospital_name='';
}else{
navName.value='医生临床病例库';
order.doctor_name='';
}
keyWord.value=value;
dataList.value=[];
order.read_num='';
order.push_date='';
dataList.value=[];
order.push_date='desc';
order.article_num='';
paging.value.reload();
}
}
</script>
<style lang="scss" scoped>
.dropcontent{
padding-top: 40rpx;
padding-bottom: 20rpx;
background-color: #fff;
:deep(.u-radio){
margin-bottom: 6px!important;
margin-top: 16px!important;
}
.column {
padding: 0 30rpx;
border-top: 2rpx solid #e5e7eb;
:deep(.u-radio__text){
color: #333 !important;
}
}
.column.active{
:deep(.u-radio__text){
color: #3CC7C0 !important;
}
}
}
.databox {
height: 162rpx;
background: #ffffff;
@ -308,6 +409,7 @@ const changeWord=(value)=>{
position: relative;
:deep(.u-dropdown__menu__item__text){
font-size: 14px!important;
color:#3CC7C0!important;
}
.type{
position: absolute;
@ -402,6 +504,7 @@ const changeWord=(value)=>{
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;

View File

@ -31,26 +31,43 @@
</view> -->
<view class="filterbox">
<!-- <view class="type" @click="swicthType">{{!isArticle?'文章':'视频'}}<up--image :src="switchImg" width="31rpx" height="31rpx"></up--image></view> -->
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<up-dropdown-item
v-model="order.push_date"
title="发布时间"
@change="change"
:options="options"
></up-dropdown-item>
<up-dropdown-item
v-model="order.article_num"
title="文章数量"
@change="change"
:options="options"
></up-dropdown-item>
<!-- <up-dropdown-item
v-model="order.article_num"
title="阅读量"
@change="change"
:options="options"
></up-dropdown-item> -->
</up-dropdown>
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<up-dropdown-item
:title="dropTitle"
>
<view class="dropcontent">
<up-radio-group
@change="changeDate"
v-model="orderFilter"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in options"
:key="item.value"
:class="[orderFilter==item.value?'active':'']"
>
<up-radio
activeColor="#3CC7C0"
:label="item.label"
:name="item.value"
></up-radio>
</view>
</up-radio-group>
</view>
</up-dropdown-item>
<!-- <up-dropdown-item
v-model="order.read_num"
title="阅读量"
@change="changeRead"
:options="options"
></up-dropdown-item> -->
</up-dropdown>
</view>
</template>
@ -83,7 +100,7 @@ import { ref, reactive} from "vue";
import navBarSearch from "@/components/navBarSearch/navBarSearch.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow} from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import navTo from "@/utils/navTo.js";
@ -96,22 +113,26 @@ const value = ref("");
const keyWord = ref("");
const isArticle=ref(true)
const type=ref('doctor')
const dropTitle=ref('更新时间');
const navName=ref('疾病临床病例库');
const orderFilter=ref('1')
const options= ref([
{
label: "正序",
value: 'asc',
label: "更新时间",
value: '1',
},
{
label: "倒序",
value: 'desc',
label: "疾病名称",
value: '2',
},
{
label: "病例数",
value: '3',
},
]);
const order=reactive({
article_num:'',
push_date:''
updated_at:'desc'
})
onLoad((options) => {
@ -132,9 +153,31 @@ onLoad((options) => {
order.article_num='desc'
};
});
const change=(e)=>{
onShow(()=>{
paging.value?.refresh();
})
const changeDate=(e)=>{
if(e==1){
order.updated_at='desc';
order.label_name='';
order.article_num='';
dropTitle.value="更新时间"
}else if(e==2){
order.updated_at='';
order.label_name='asc';
order.article_num='';
dropTitle.value="疾病名称"
}else if(e==3){
order.updated_at='';
order.label_name='';
order.article_num='desc';
dropTitle.value="病例数"
}
orderFilter.value=e;
uDropdownRef.value.close();
paging.value.reload();
}
const formatdate=(date)=>{
return dayjs(date).format('YYYY-MM-DD')
}
@ -146,10 +189,13 @@ const searchList = async(params) => {
if(!order.article_num){
delete order.article_num
}
if(!order.push_date){
delete order.push_date
if(!order.updated_at){
delete order.updated_at
}
if(order.article_num || order.push_date){
if(!order.label_name){
delete order.label_name
}
if(order.article_num || order.updated_at || order.label_name){
searchForm.order=order
}
api.getSearchLabel({
@ -181,20 +227,41 @@ const goDetail=(id,name)=>{
* @param value 输入的关键词
*/
const changeWord=(value)=>{
console.log(value);
if(value){
navName.value=value+'临床病例库';
navName.value='疾病临床病例库';
keyWord.value=value;
dataList.value=[];
order.article_num='';
order.push_date='';
order.updated_at='desc';
order.article_num='';
order.label_name='';
paging.value.reload();
}
}
</script>
<style lang="scss" scoped>
.dropcontent{
padding-top: 40rpx;
padding-bottom: 20rpx;
background-color: #fff;
:deep(.u-radio){
margin-bottom: 6px!important;
margin-top: 16px!important;
}
.column {
padding: 0 30rpx;
border-top: 2rpx solid #e5e7eb;
:deep(.u-radio__text){
color: #333 !important;
}
}
.column.active{
:deep(.u-radio__text){
color: #3CC7C0 !important;
}
}
}
.votepop {
.confirm {
margin: 39rpx 30rpx 0;
@ -364,9 +431,13 @@ const changeWord=(value)=>{
background: #fff;
z-index:1;
margin-left:30rpx;
margin-left:0rpx;
}
:deep(.u-dropdown__menu__item__text){
font-size: 14px!important;
color:#3CC7C0!important;
}
:deep(.u-dropdown__menu__item){
height:74rpx;
@ -427,6 +498,7 @@ const changeWord=(value)=>{
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;

File diff suppressed because it is too large Load Diff

View File

@ -16,7 +16,7 @@
<view class="name">文章</view>
</view>
<view class="cell" v-else>
<view class="num">{{ numInfo.video_collect_num }}</view>
<view class="num">{{ numInfo.video_num }}</view>
<view class="name">视频</view>
</view>
<view class="cell" v-if="isArticle">
@ -29,32 +29,48 @@
</view>
</view>
<view class="bar"></view>
<!-- <view class="detail" v-if="isSearch">
<view class="desc" v-if="isArticle">检索到:<text class="red">{{searchInfo.search_article_num}}篇文章</text></view>
<view class="desc" v-else>检索到<text class="red">{{searchInfo.search_video_num}}个视频</text></view>
<view class="detail" v-if="isSearch">
<view class="desc" >检索到<text class="red">{{searchInfo.search_video_num}}个视频</text></view>
<view class="desc" v-if="keyWord">检索词:<text class="red">{{ keyWord }}</text></view>
</view> -->
</view>
<view class="filterbox">
<view class="casesdown" :class="{active:label_iden}" @click="openCase" >筛选<up--image :src="caseImg" width="31rpx" height="31rpx"></up--image></view>
<!-- <view class="type" @click="swicthType">{{!isArticle?'文章':'视频'}}<up--image :src="switchImg" width="31rpx" height="31rpx"></up--image></view> -->
<up-dropdown class="u-dropdown" ref="uDropdownRef">
<up-dropdown-item
v-model="order.push_date"
title="发布时间"
@change="change"
:options="options"
></up-dropdown-item>
<up-dropdown-item
v-model="order.read_num"
title="阅读量"
@change="change"
:options="options"
></up-dropdown-item>
</up-dropdown>
<up-dropdown-item
:title="dropTitle"
>
<view class="dropcontent">
<up-radio-group
@change="changeDate"
v-model="orderFilter"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in options"
:key="item.value"
:class="[orderFilter==item.value?'active':'']"
>
<up-radio
activeColor="#3CC7C0"
:label="item.label"
:name="item.value"
></up-radio>
</view>
</up-radio-group>
</view>
</up-dropdown-item>
</up-dropdown>
</view>
</template>
<view class="item" v-for="(item, index) in dataList" :key="index" @click="isArticle?goDetail(item.article_id):goDetail(item.video_id)">
<view class="title ellipsis">{{isArticle?item.article_title:item.video_title }}</view>
<view class="item" v-for="(item, index) in dataList" :key="index" @click="goDetail(item.video_id,item.is_link,item.is_link_url)">
<view class="title ellipsis-two-lines">{{isArticle?item.article_title:item.video_title }}</view>
<view class="tagsbox">
<view class="tag" v-for="tag in item.author" :key="tag.author_id">{{ tag.doctor_name }}</view>
</view>
@ -64,7 +80,7 @@
<up-icon name="eye" color="#6B7280" size="28rpx"></up-icon>
<view class="num">{{item.read_num }}</view>
</view>
<view class="collect">
<view class="collect" v-if="item.collect_num>0">
<up-icon name="heart" color="#6B7280" size="28rpx"></up-icon>
<view class="num">{{item.collect_num }}</view>
</view>
@ -77,6 +93,117 @@
</view>
</z-paging>
</view>
<up-popup
:round="10"
zIndex="9"
:closeOnClickOverlay="false"
:show="showCase"
mode="bottom"
@close="closeCase"
>
<view class="votepop casepop">
<view class="titlebox">
<view class="left" @click="closeCase">取消</view>
<view class="right" @click="confirmCase">确定</view>
</view>
<view class="stepbox">
<up-steps :current="level-1" direction="column" :key="freshKey">
<up-steps-item >
<template v-slot:content>
<view class="slot-content" @click="openCaseLevel('1')">
<view class="left">{{!caseValue1.name?'请选择选项':caseValue1.name}}</view>
<u-icon name="arrow-right"></u-icon>
</view>
</template>
</up-steps-item>
<up-steps-item v-if="caseValue1.name && labelObj.list2.length>0">
<template v-slot:content>
<view class="slot-content" @click="openCaseLevel('2')">
<view class="left">{{!caseValue2.name?'请选择选项':caseValue2.name}}</view>
<u-icon name="arrow-right"></u-icon>
</view>
</template>
</up-steps-item>
<up-steps-item v-if="caseValue2.name && labelObj.list3.length>0">
<template v-slot:content>
<view class="slot-content" @click="openCaseLevel('3')">
<view class="left">{{!caseValue3.name?'请选择选项':caseValue3.name}}</view>
<u-icon name="arrow-right"></u-icon>
</view>
</template>
</up-steps-item>
</up-steps>
</view>
<scroll-view class="casecon" scroll-y="true">
<view v-show="level == 1" >
<up-radio-group
v-model="caseValue1.value"
name="group1"
iconPlacement="right"
placement="column"
@change="groupChange1"
>
<view
class="column"
v-for="item in labelObj.list1"
:key="item.app_iden"
v-show="item.label_name!='热门话题'"
>
<up-radio
activeColor="#3CC7C0 "
:label="item.label_name"
:name="item.app_iden"
></up-radio>
</view>
</up-radio-group>
</view>
<view v-show="level == 2" >
<up-radio-group
name="group2"
@change="groupChange2"
v-model="caseValue2.value"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in labelObj.list2"
:key="item.app_iden"
>
<up-radio
activeColor="#3CC7C0 "
:label="item.label_name"
:name="item.app_iden"
></up-radio>
</view>
</up-radio-group>
</view>
<view v-show="level == 3" >
<up-radio-group
name="group3"
@change="groupChange3"
v-model="caseValue3.value"
iconPlacement="right"
placement="column"
>
<view
class="column"
v-for="item in labelObj.list3"
:key="item.app_iden"
>
<up-radio
activeColor="#3CC7C0 "
:label="item.label_name"
:name="item.app_iden"
></up-radio>
</view>
</up-radio-group>
</view>
</scroll-view>
</view>
</up-popup>
</template>
<script setup>
@ -85,11 +212,11 @@ import navBarSearch from "@/components/navBarSearch/navBarSearch.vue";
import list from "@/uni_modules/z-paging/components/z-paging/z-paging";
import api from "@/api/api";
import navTo from "@/utils/navTo.js";
import { onLoad } from "@dcloudio/uni-app";
import { onLoad,onShow} from "@dcloudio/uni-app";
import dayjs from "dayjs";
import switchImg from "@/static/switch.png";
import caseImg from "@/static/caseIcon.png";
const paging = ref(null);
const uDropdownRef=ref(null);
const dataList = ref([]);
const total = ref(0);
const keyWord = ref("");
@ -102,21 +229,166 @@ const doctor_name=ref('');
const numInfo=reactive({});
const searchInfo=reactive({})
const navName=ref('视频临床病例库')
const dropTitle=ref('发布时间');
const orderFilter=ref('1')
const uDropdownRef=ref(null);
const label_iden=ref(null);
const options= ref([
{
label: "正序",
value: 'asc',
label: "发布时间",
value: '1',
},
{
label: "倒序",
value: 'desc',
label: "阅读次数",
value: '2',
},
]);
const isSearch=ref(false);
const order=reactive({
read_num:'',
push_date:''
push_date:'desc'
})
const showCase = ref(false);
const canOpenCase = ref(false);
const freshKey=ref(0);
const caseValue1 = reactive({
value:'',
name:'',
});
const caseValue2 = reactive({
value:'',
name:'',
});
const caseValue3 = reactive({
value:'',
name:'',
});
const level = ref(1);
const labelObj = reactive({
list1: [],
list2: [],
list3: []
});
const openCaseLevel=(lev)=>{
freshKey.value++;
level.value=lev;
if(lev==1){
caseValue2.name='';
caseValue2.value='';
labelObj.list2=[]
}else if(lev==2){
labelObj.list3=[];
caseValue3.name='';
caseValue3.value=''
}
};
const groupChange1=(e)=>{
caseValue1.value=e;
for (var i = 0; i <labelObj.list1.length; i++) {
if(labelObj.list1[i].app_iden==caseValue1.value){
caseValue1.name=labelObj.list1[i].label_name;
break;
}
}
getCaseLabel(2,e)
}
const groupChange2=(e)=>{
caseValue2.value=e;
for (var i = 0; i <labelObj.list2.length; i++) {
if(labelObj.list2[i].app_iden==caseValue2.value){
caseValue2.name=labelObj.list2[i].label_name;
break;
}
}
getCaseLabel(3,e)
}
const groupChange3=(e)=>{
caseValue3.value=e;
level.value =3
for (var i = 0; i <labelObj.list3.length; i++) {
if(labelObj.list3[i].app_iden==caseValue3.value){
caseValue3.name=labelObj.list3[i].label_name;
break;
}
}
}
const getCaseLabel = (lev,pid=0) => {
api.getCaseLabel({
pId:pid
}).then((res) => {
level.value = lev;
if (lev == 1) {
labelObj.list1 = res.data.data;
//label_iden.value = caseValue1.value;
} else if (lev == 2) {
labelObj.list2 = res.data.data;
if(res.data.data.length==0){
level.value = 1
}
//label_iden.value = caseValue2.value;
} else if (lev == 3) {
labelObj.list3 = res.data.data;
if(res.data.data.length==0){
level.value = 2
}
//label_iden.value = caseValue3.value;
}
});
};
const openCase=()=>{
showCase.value = true;
}
const cancelCase=()=>{
showCase.value=false;
level.value=1;
caseValue1.name='';
caseValue1.value='';
caseValue2.name='';
caseValue2.value='';
caseValue3.name='';
caseValue3.value='';
//labelObj.list1=[];
labelObj.list2=[];
labelObj.list3=[];
}
const confirmCase = () => {
if (level.value == 1 && caseValue1.value == "") {
uni.showToast({ title: "请选择疾病选项", icon: "none" });
return false;
};
if(level.value == 1){
label_iden.value = caseValue1.value;
}
if(level.value == 2 ){
if(!caseValue2.value){
label_iden.value = caseValue1.value;
}else{
label_iden.value = caseValue2.value;
}
}
if(level.value == 3 ){
if(!caseValue3.value){
label_iden.value = caseValue2.value;
}else{
label_iden.value = caseValue3.value;
}
}
paging.value.reload();
showCase.value=false;
//cancelCase();
};
const closeCase = () => {
showCase.value = false;
};
const changeWord=(value)=>{
keyWord.value=value;
@ -151,20 +423,58 @@ onLoad((options) => {
getStaticDoctor(hospital_id.value)
}
getCaseLabel(1,0)
});
const change=(e)=>{
onShow(()=>{
paging.value?.refresh();
})
const changeDate=(e)=>{
if(e==1){
order.push_date='desc';
order.read_num='';
dropTitle.value="发布时间"
}else{
order.push_date='';
order.read_num="desc";
dropTitle.value="阅读次数"
}
orderFilter.value=e;
uDropdownRef.value.close();
paging.value.reload();
}
const changeRead=(e)=>{
console.log(e);
order.push_date=''
paging.value.reload();
}
const formatdate=(date)=>{
return dayjs(date).format('YYYY-MM-DD')
}
const goDetail=(id)=>{
console.log(11111)
console.log(id)
let type=isArticle.value?'article':'video'
navTo({
url: `/pages/detail/detail?id=${id}&type=${type}`
})
const goDetail=(id,isLink,src)=>{
console.log(999999)
console.log(isLink,src)
if(isLink==1){
api.readRecord({
type:2,
id:id
}).then(res=>{
})
// #ifdef MP-WEIXIN
navTo({
url: `/pages/web/web?src=${src}`,
});
// #endif
// #ifdef H5
window.location.href=`${src}`
// #endif
}else{
let type=isArticle.value?'article':'video'
navTo({
url: `/pages/detail/detail?id=${id}&type=${type}`
})
}
}
const getStatic=()=>{
api.getHomeStatics().then((res) => {
@ -176,8 +486,12 @@ const searchArticle =(params) => {
let searchForm={
keyword: keyWord.value,
hospital_id:hospital_id.value,
doctor_id:doctor_id.value
}
doctor_id:doctor_id.value,
label_iden:label_iden.value
}
if(label_iden.value===''){
delete searchForm.label_iden
}
if(isSearch.value){
searchForm.is_need_num=1;
}
@ -207,7 +521,11 @@ const searchVideo = async(params) => {
let searchForm={
keyword: keyWord.value,
hospital_id:hospital_id.value,
doctor_id:doctor_id.value
doctor_id:doctor_id.value,
label_iden:label_iden.value
}
if(label_iden.value===''){
delete searchForm.label_iden
}
if(isSearch.value){
searchForm.is_need_num=1;
@ -245,6 +563,42 @@ const queryList = (pageNo, pageSize) => {
</script>
<style lang="scss" scoped>
.dropcontent{
padding-top: 40rpx;
padding-bottom: 20rpx;
background-color: #fff;
:deep(.u-radio){
margin-bottom: 6px!important;
margin-top: 16px!important;
}
.column {
padding: 0 30rpx;
border-top: 2rpx solid #e5e7eb;
:deep(.u-radio__text){
color: #333 !important;
}
}
.column.active{
:deep(.u-radio__text){
color: #3CC7C0 !important;
}
}
}
.stepbox{
padding:15rpx 30rpx;
border-bottom: 2rpx dotted #f3f4f6;
:deep(.u-steps-item__content){
margin-top: -5rpx!important;
}
.slot-content{
width:100%;
margin-bottom: 25rpx;
justify-content: space-between;
align-items: center;
display: flex;
}
}
.bar {
width: 100%;
background: #f9fafb;
@ -276,13 +630,30 @@ const queryList = (pageNo, pageSize) => {
}
}
.filterbox{
background-color: #fff;
display: flex;
height:128rpx;
align-items: center;
position: relative;
border-bottom: 2rpx solid #f3f4f6;
:deep(.u-dropdown__menu__item__text){
font-size: 14px!important;
:deep(.u-dropdown__menu__item__text){
font-size: 14px!important;
color:#3CC7C0!important;
}
.casesdown{
font-size:14px;
color: #606266;
position: absolute;
left:30rpx;
top:24rpx;
display: flex;
justify-content: center;
align-items: center;
background: #F3F4F6;
border-radius: 15rpx;
height: 74rpx;
padding:0 25rpx;
z-index:2;
}
.type{
color: #606266;
@ -311,7 +682,7 @@ const queryList = (pageNo, pageSize) => {
background: #fff;
z-index:1;
margin-left: 30rpx;
margin-left: 165rpx;
}
:deep(.u-dropdown__menu__item){
@ -321,7 +692,7 @@ const queryList = (pageNo, pageSize) => {
background: #F3F4F6;
border-radius: 15rpx;
flex:none;
margin-left: 30rpx;
margin-left: 30rpx;
}
.deal {
@ -352,6 +723,7 @@ const queryList = (pageNo, pageSize) => {
}
}
.item {
background-color: #fff;
border-bottom: 1rpx solid #f3f4f6;
padding: 30rpx;
.title {
@ -363,6 +735,7 @@ const queryList = (pageNo, pageSize) => {
.tagsbox {
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.tag {
padding: 0 10rpx;
margin-right: 16rpx;
@ -389,4 +762,114 @@ const queryList = (pageNo, pageSize) => {
color: #FF0000;
}
}
.votepop {
.confirm {
margin: 39rpx 30rpx 0;
height: 92rpx;
background: #3cc7c0;
border-radius: 15rpx;
display: flex;
align-items: center;
font-size: 31rpx;
color: #ffffff;
justify-content: center;
}
.del {
margin: 30rpx 30rpx 30rpx;
height: 92rpx;
background: #fff;
border-radius: 15rpx;
font-size: 31rpx;
color: #666666;
display: flex;
align-items: center;
justify-content: center;
}
.tips {
margin-top: 30rpx;
font-size: 27rpx;
color: #9ca3af;
line-height: 38rpx;
padding: 0 30rpx;
}
.add {
margin: 0 30rpx;
display: flex;
align-items: center;
height: 92rpx;
justify-content: center;
background: #f5f5f5;
border-radius: 15rpx;
font-size: 31rpx;
color: #4b5563;
.desc {
margin-left: 10rpx;
}
}
.titlebox {
padding: 0 30rpx;
height: 86rpx;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 2rpx solid #f3f4f6;
.left {
font-size: 31rpx;
color: #4b5563;
}
.right {
font-size: 31rpx;
color: #3cc7c0;
}
}
.votecon {
max-height: calc(100vh - 530rpx);
overflow-y: scroll;
.row {
display: flex;
align-items: center;
.iconbox {
margin-left: 15rpx;
align-items: center;
}
}
.titlebox {
border: none;
margin: 30rpx 0 20rpx;
.title {
font-size: 31rpx;
color: #111827;
}
.desc {
font-size: 27rpx;
color: #9ca3af;
}
}
}
}
.casepop{
display: flex;
flex-direction: column;
max-height: calc(100vh - 400rpx);
.continue{
color: #2878ff !important;
}
}
.casecon{
flex:1;
max-height: calc(100vh - 800rpx);
overflow-y: scroll;
padding-top: 22rpx;
padding-bottom: 20rpx;
min-height: 350rpx;
:deep(.u-radio){
margin-bottom: 10px!important;
margin-top: 0px!important;
}
.column {
padding: 0 30rpx;
border-bottom: 2rpx solid #e5e7eb;
}
}
</style>

16
pages/web/web.vue Normal file
View File

@ -0,0 +1,16 @@
<template>
<web-view :src="src"></web-view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from "@dcloudio/uni-app";
const src=ref('')
onLoad((option)=>{
src.value=option.src
})
</script>
<style scoped>
</style>

BIN
static/benren.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 48 KiB

BIN
static/draft.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 43 KiB

BIN
static/myCollect.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/myDownload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/myFile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

BIN
static/myHospital.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
static/myJoin.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
static/myTalk.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
static/navbg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

BIN
static/ppt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 44 KiB

BIN
static/videoface.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
static/voteon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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,752 @@
<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 warn" type="primary" size="mini" @click="cropCancel">取消</button>
<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);
},
cropCancel(){
this.$emit('cancel');
},
/** 确认裁剪 */
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-success;
color: #fff;
}
.warn {
margin: auto $uni-spacing-row-lg auto auto;
background-color:$uni-color-error;
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端导入组件后无法正常渲染请尝试重新运行

View File

@ -0,0 +1,35 @@
## 1.1.22025-04-14
1. 更新视频封面图片地址,之前的已失效
2. 更新文档
## 1.1.12025-03-19
1. 更新vue2环境下状态销毁异常的问题
## 1.1.02025-03-07
1. 更新状态销毁逻辑
## 1.0.92025-01-18
1. 修复调色板在微信小程序vue2环境下的问题
## 1.0.82025-01-18
1. 修复了微信小程序在vue2环境下的报错
## 1.0.72025-01-18
1. 修复了微信小程序在vue2环境下出现的报错
## 1.0.62024-12-17
1. 优化了ios端兼容性问题
2. 更新示例工程和文档
## 1.0.52024-12-17
1. 更新文档
## 1.0.42024-12-17
1. 新增扩展功能
2. 更新文档
3. 更新示例工程
## 1.0.32024-12-11
1. 优化了多编辑器实例模式,现在单页面可以存在多个编辑器了
2. 更新了文档与示例工程,多实例可以参考示例二
## 1.0.22024-12-10
1. 添加调色板功能
2. 预设更多样式格式
3. 更新示例工程和文档
## 1.0.12024-12-06
1. v1正式版发布
2. 更新文档
3. 上传示例工程
## 1.0.02024-11-29
1. 基于uni-editor的仿知乎富文本初稿

View File

@ -0,0 +1,656 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:sid="sid"
:change:sid="quillEditor.watchStartID"
:video="videoUrl"
:change:pastemode="quillEditor.watchPasteMode"
:pastemode="pastemode"
:change:video="quillEditor.watchVideoUrl"
:cover="coverUrl"
:change:cover="quillEditor.watchCoverUrl"
:coverios="coverUrlIOS"
:change:coverios="quillEditor.watchCoverUrlIOS"
></text>
</template>
<script>
/**
* 富文本plugin特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-14
*/
export default {
props: {
sid: {
type: String,
default: ''
},
eid: {
type: String,
default: ''
}
},
data() {
return {
videoUrl: '', //
coverUrl: '', //
coverUrlIOS: '', // ios
pastemode: 'text' // text | origin
}
},
mounted() {},
methods: {
changePasteMode(e) {
this.pastemode = e
},
editorPaste(e) {
this.$emit('epaste', e)
},
createVideoThumbnail(url) {
this.videoUrl = url
},
getVideoThumbnail(e) {
// e: { video, cover }
uni.$emit(`E_EDITOR_GET_VIDEO_THUMBNAIL_${e.video}`, e)
},
createCoverThumbnail(url) {
// #ifdef H5
this.coverUrl = url
// #endif
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
if (isIOS) {
this.coverUrlIOS = url // iOSOffscreenCanvas
} else {
this.coverUrl = url
}
// #endif
},
getCoverThumbnail(e) {
// e: { image, cover }
uni.$emit(`E_EDITOR_GET_COVER_THUMBNAIL_${e.image}`, e)
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
import config from '../common/config.js'
export default {
data() {
return {
editorID: '',
idStack: [], // sid
matcherMode: '' // text | origin
}
},
methods: {
/**
* 注意watch频繁触发时需要异步修改否则可能会导致监听不到
*/
watchPasteMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.matcherMode = newValue
}
},
watchStartID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.addMatcher(newValue)
}
},
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchVideoUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateVideoThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getVideoThumbnail', {
video: newValue,
cover: res
})
})
}
},
watchCoverUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* Only Apple Can Do !!!
*/
watchCoverUrlIOS(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnailIOS(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* 保留格式粘贴内容
* @description 此方式尽可能保留原格式易于再编辑但是部分格式会丢失
* @param {String} sid 当前编辑器id
*/
addMatcher(sid) {
if(this.idStack.includes(sid)) return // Matcher
this.idStack.push(sid)
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const getStyleAttributes = (node, style) => {
let attributes = {}
// node
const width = node.getAttribute('width');
const height = node.getAttribute('height');
if (width) attributes.width = width
if (height) attributes.height = height
const dataCustom = node.getAttribute('data-custom');
if (dataCustom) attributes['data-custom'] = dataCustom;
// style
if (style.textAlign) attributes.align = style.textAlign;
if (style.fontWeight === 'bold' || style.fontWeight === '700') attributes.bold = true;
if (style.fontStyle === 'italic') attributes.italic = true;
if (style.textDecoration.includes('underline')) attributes.underline = true;
if (style.textDecoration.includes('line-through')) attributes.strike = true;
if (style.verticalAlign === 'super') attributes.script = 'super'
if (style.verticalAlign === 'sub') attributes.script = 'sub'
if (style.fontFamily) attributes.font = style.fontFamily;
if (style.fontSize) attributes.size = parseFloat(style.fontSize);
if (style.color) attributes.color = style.color;
if (style.backgroundColor) attributes.background = style.backgroundColor;
return attributes
}
const processNode = (node) => {
let ops = [];
if (node.nodeType === Node.ELEMENT_NODE) {
const computedStyle = document.defaultView.getComputedStyle(node);
// <img>
if (node.tagName === 'IMG') {
const imgSrc = node.getAttribute('src');
if (imgSrc) {
ops.push({ insert: '\n' }); //
ops.push({
insert: { image: imgSrc },
attributes: getStyleAttributes(node, computedStyle)
});
ops.push({ insert: '\n' }); //
return ops; //
}
}
// <p> <div>
else if (node.tagName === 'P' || node.tagName === 'DIV') {
ops.push({ insert: '\n' }); //
}
// <ol>
else if (node.tagName === 'OL') {
// ops.push({ insert: '\n', attributes: { list: 'ordered' } });
}
// <ul>
else if (node.tagName === 'UL') {
// ops.push({ insert: '\n', attributes: { list: 'bullet' } });
}
// <li>
else if (node.tagName === 'LI') {
ops.push({ insert: '\n' });
}
// <hr>
else if (node.tagName === 'HR') {
ops.push({ insert: '\n' }); //
ops.push({ insert: { divider: true } });
return ops; //
}
// <a>
else if (node.tagName === 'A') {
const href = node.getAttribute('href');
const textContent = node.textContent.trim();
if (href && textContent) {
ops.push({
insert: ' ' + textContent + ' ',
attributes: {
link: href,
textDecoration: computedStyle.textDecoration,
...getStyleAttributes(node, computedStyle)
}
});
return ops; //
}
}
// <h1> <h6>
else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(node.tagName)) {
// <h1> <h6> header
const headerLevel = parseInt(node.tagName.charAt(1), 10);
const textContent = node.textContent.trim();
if (textContent) {
ops.push({
insert: textContent + '\n', //
attributes: {
header: headerLevel,
...getStyleAttributes(node, computedStyle)
}
});
return ops; //
}
}
//
for (let child of node.childNodes) {
ops = ops.concat(processNode(child));
}
} else if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue.trim();
if (textContent) {
//
const parentNode = node.parentNode;
if (parentNode) {
const computedStyle = document.defaultView.getComputedStyle(parentNode);
ops.push({
insert: textContent,
attributes: getStyleAttributes(parentNode, computedStyle)
});
} else {
//
ops.push({ insert: textContent });
}
}
}
return ops;
}
quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
if (this.matcherMode == 'origin') {
let newOps = processNode(node);
if (newOps.length > 0) {
return { ops: newOps };
}
}
return delta;
})
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使 <!--StartFragment--> <!--EndFragment-->
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
//
return match[1].trim();
}
// HTML
return html;
}
el.addEventListener('paste', (e) => {
let clipboardText = e.clipboardData.getData('text/plain'); //
let clipboardHtml = e.clipboardData.getData('text/html'); // HTML
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
setTimeout(() => {
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml,
range: quill.getSelection() //
})
}, 100);
});
},
/**
* 保留格式粘贴内容
* @description 此方式虽然可以极大程度保留格式但是会导致粘贴下来的内容为一整个块且不易再编辑
* @param {String} sid 当前编辑器id
*/
/*
addMatcher(sid) {
if(this.idStack.includes(sid)) return // Matcher
this.idStack.push(sid)
// BlockEmbed
const BlockEmbed = Quill.import('blots/block/embed');
// blot
class AppPanelEmbed extends BlockEmbed {
static create(value) {
const node = super.create(value);
node.setAttribute('width', '100%');
// html
node.innerHTML = this.transformValue(value)
return node;
}
static transformValue(value) {
let handleArr = value.split('\n')
handleArr = handleArr.map(e => e.replace(/^[\s]+/, '').replace(/[\s]+$/, ''))
return handleArr.join('')
}
// value
static value(node) {
return node.innerHTML
}
}
// blotName
AppPanelEmbed.blotName = 'AppPanelEmbed';
//
AppPanelEmbed.tagName = 'p';
Quill.register(AppPanelEmbed, true);
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使 <!--StartFragment--> <!--EndFragment-->
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
//
return match[1].trim();
}
// HTML
return html;
}
el.addEventListener('paste', (e) => {
e.preventDefault();
let clipboardText = e.clipboardData.getData('text/plain'); //
let clipboardHtml = e.clipboardData.getData('text/html'); // HTML
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml
})
setTimeout(() => {
const range = quill.getSelection(); //
quill.insertEmbed(range.index, 'AppPanelEmbed', clipboardHtml);
}, 100);
});
},
*/
/**
* 生成视频封面图片不兼容iOS
* @property {String} videoUrl 视频地址
* @returns {Promise} 视频封面图片 注意异步处理
*/
async generateVideoThumbnail(videoUrl) {
//
// @param {CanvasContext} context canvas
// @param {Canvas} canvas
const drawPlayButton = (context, canvas) => {
// <img>
const img = new Image();
img.src = config.video_playicon;
//
return new Promise((resolve, reject) => {
img.onload = () => {
//
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// canvas
context.drawImage(img, playButtonX, playButtonY, playButtonSize, playButtonSize);
resolve();
};
img.onerror = (error) => {
reject(new Error('Failed to load SVG image.'));
};
});
}
return new Promise(async (resolve, reject) => {
try {
// video crossOrigin
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // crossOrigin
video.preload = 'metadata';
video.src = videoUrl;
// canvas
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
//
video.onloadedmetadata = async () => {
// canvas
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// canvas
video.currentTime = 0; //
video.onseeked = async () => {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
//
await drawPlayButton(context, canvas);
// canvas Data URL
// resolve(canvas.toDataURL('image/png')); // base64使
// canvas Blob
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
};
// seek
setTimeout(async () => {
if (!video.seeking) {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
//
await drawPlayButton(context, canvas);
// canvas Data URL
// resolve(canvas.toDataURL('image/png')); // base64使
// canvas Blob
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
}
}, 1000); // 1 seek
};
//
video.onerror = (error) => {
// reject(new Error('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.'));
//
console.warn('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.');
resolve(config.video_thumbnail);
};
} catch (error) {
// reject(error);
//
console.warn(error)
resolve(config.video_thumbnail);
}
});
},
/**
* 生成封面图片OffscreenCanvas方式不兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnail(coverUrl) {
return new Promise((resolve, reject) => {
// Worker
const workerCode = `
self.onmessage = async function(e) {
const { imageUrl, iconBase64 } = e.data;
try {
// ImageBitmap
let imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(\`Failed to load image from \${imageUrl}: \${imgResponse.statusText}\`);
}
let imgBlob = await imgResponse.blob();
let imgBitmap = await createImageBitmap(imgBlob);
// OffscreenCanvas
const offscreen = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(imgBitmap, 0, 0);
// ImageBitmap
let iconResponse = await fetch(iconBase64);
if (!iconResponse.ok) {
throw new Error(\`Failed to load icon from \${iconBase64}: \${iconResponse.statusText}\`);
}
let iconBlob = await iconResponse.blob();
let iconBitmap = await createImageBitmap(iconBlob);
//
const x = (imgBitmap.width - iconBitmap.width) / 2;
const y = (imgBitmap.height - iconBitmap.height) / 2;
ctx.drawImage(iconBitmap, x, y);
//
const result = await offscreen.convertToBlob();
// 线
self.postMessage(result);
} catch (error) {
console.error('Error processing image:', error.message);
self.postMessage({ error: error.message });
}
};
`
// Blob
const blob = new Blob([workerCode], { type: 'application/javascript' });
// Blob URL
const workerUrl = URL.createObjectURL(blob);
// Worker
const worker = new Worker(workerUrl);
// Worker
worker.onmessage = (e) => {
if (e.data instanceof Blob) {
resolve(URL.createObjectURL(e.data));
} else {
console.warn(e.data.error);
//
resolve(config.video_thumbnail);
}
worker.terminate(); // worker
};
// Worker
worker.postMessage({ imageUrl: coverUrl, iconBase64: config.video_playicon });
});
},
/**
* 生成封面图片普通方式可能影响性能兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnailIOS(coverUrl){
return new Promise(async (resolve, reject) => {
try {
// Image
const img = new Image();
img.src = coverUrl;
await new Promise(resolve => img.onload = resolve);
// Canvas
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
//
const icon = new Image();
icon.src = config.video_playicon; // URL
await new Promise(resolve => icon.onload = resolve);
//
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
//
const iconAspectRatio = icon.width / icon.height;
const iconWidth = playButtonSize;
const iconHeight = iconWidth / iconAspectRatio;
// Canvas
ctx.drawImage(icon, playButtonX, playButtonY, iconWidth, iconHeight);
// canvas Blob
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
// iOS Safari file://
console.warn('iOS createCoverThumbnail error :', error);
// reject(error);
//
resolve(config.video_thumbnail);
}
})
},
}
}
</script>

View File

@ -0,0 +1,94 @@
/**
* 富文本plugin微信小程序特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-17
*/
import config from '../common/config.js'
/**
* 微信小程序特有的OffscreenCanvas方法
* @param {String} coverImageUrl 封面资源地址
* @returns {Promise<String>} 处理后的封面图片的临时文件路径
*/
export function wxCreateCoverThumbnail(coverImageUrl) {
const loadImage = () => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadImage()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export default {
wxCreateCoverThumbnail
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,261 @@
/**
* 以下方法出自 image-tools
* @see https://ext.dcloud.net.cn/plugin?id=123
*/
function getLocalFilePath(path) {
if (path.indexOf('_www') === 0 || path.indexOf('_doc') === 0 || path.indexOf('_documents') === 0 || path.indexOf(
'_downloads') === 0) {
return path
}
if (path.indexOf('file://') === 0) {
return path
}
if (path.indexOf('/storage/emulated/0/') === 0) {
return path
}
if (path.indexOf('/') === 0) {
let localFilePath = plus.io.convertAbsoluteFileSystem(path)
if (localFilePath !== path) {
return localFilePath
} else {
path = path.substr(1)
}
}
return '_www/' + path
}
function dataUrlToBase64(str) {
let array = str.split(',')
return array[array.length - 1]
}
let index = 0
function getNewFileId() {
return Date.now() + String(index++)
}
function biggerThan(v1, v2) {
let v1Array = v1.split('.')
let v2Array = v2.split('.')
let update = false
for (let index = 0; index < v2Array.length; index++) {
let diff = v1Array[index] - v2Array[index]
if (diff !== 0) {
update = diff > 0
break
}
}
return update
}
export function pathToBase64(path) {
return new Promise(function(resolve, reject) {
if (typeof window === 'object' && 'document' in window) {
if (typeof FileReader === 'function') {
let xhr = new XMLHttpRequest()
xhr.open('GET', path, true)
xhr.responseType = 'blob'
xhr.onload = function() {
if (this.status === 200) {
let fileReader = new FileReader()
fileReader.onload = function(e) {
resolve(e.target.result)
}
fileReader.onerror = reject
fileReader.readAsDataURL(this.response)
}
}
xhr.onerror = reject
xhr.send()
return
}
let canvas = document.createElement('canvas')
let c2x = canvas.getContext('2d')
let img = new Image
img.onload = function() {
canvas.width = img.width
canvas.height = img.height
c2x.drawImage(img, 0, 0)
resolve(canvas.toDataURL())
canvas.height = canvas.width = 0
}
img.onerror = reject
img.src = path
return
}
if (typeof plus === 'object') {
plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), function(entry) {
entry.file(function(file) {
let fileReader = new plus.io.FileReader()
fileReader.onload = function(data) {
resolve(data.target.result)
}
fileReader.onerror = function(error) {
reject(error)
}
fileReader.readAsDataURL(file)
}, function(error) {
reject(error)
})
}, function(error) {
reject(error)
})
return
}
if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
wx.getFileSystemManager().readFile({
filePath: path,
encoding: 'base64',
success: function(res) {
resolve('data:image/png;base64,' + res.data)
},
fail: function(error) {
reject(error)
}
})
return
}
reject(new Error('not support'))
})
}
export function base64ToPath(base64) {
return new Promise(function(resolve, reject) {
if (typeof window === 'object' && 'document' in window) {
base64 = base64.split(',')
let type = base64[0].match(/:(.*?);/)[1]
let str = atob(base64[1])
let n = str.length
let array = new Uint8Array(n)
while (n--) {
array[n] = str.charCodeAt(n)
}
return resolve((window.URL || window.webkitURL).createObjectURL(new Blob([array], {
type: type
})))
}
let extName = base64.split(',')[0].match(/data\:\S+\/(\S+);/)
if (extName) {
extName = extName[1]
} else {
reject(new Error('base64 error'))
}
let fileName = getNewFileId() + '.' + extName
if (typeof plus === 'object') {
let basePath = '_doc'
let dirPath = 'uniapp_temp'
let filePath = basePath + '/' + dirPath + '/' + fileName
if (!biggerThan(plus.os.name === 'Android' ? '1.9.9.80627' : '1.9.9.80472', plus.runtime.innerVersion)) {
plus.io.resolveLocalFileSystemURL(basePath, function(entry) {
entry.getDirectory(dirPath, {
create: true,
exclusive: false,
}, function(entry) {
entry.getFile(fileName, {
create: true,
exclusive: false,
}, function(entry) {
entry.createWriter(function(writer) {
writer.onwrite = function() {
resolve(filePath)
}
writer.onerror = reject
writer.seek(0)
writer.writeAsBinary(dataUrlToBase64(base64))
}, reject)
}, reject)
}, reject)
}, reject)
return
}
let bitmap = new plus.nativeObj.Bitmap(fileName)
bitmap.loadBase64Data(base64, function() {
bitmap.save(filePath, {}, function() {
bitmap.clear()
resolve(filePath)
}, function(error) {
bitmap.clear()
reject(error)
})
}, function(error) {
bitmap.clear()
reject(error)
})
return
}
if (typeof wx === 'object' && wx.canIUse('getFileSystemManager')) {
let filePath = wx.env.USER_DATA_PATH + '/' + fileName
wx.getFileSystemManager().writeFile({
filePath: filePath,
data: dataUrlToBase64(base64),
encoding: 'base64',
success: function() {
resolve(filePath)
},
fail: function(error) {
reject(error)
}
})
return
}
reject(new Error('not support'))
})
}
/**
* 本方法为本人自己写的建议还是使用上述的pathToBase64方法
* @description 图片地址转换为base64格式图片
* @param {string} url 图片地址 网络地址 本地相对路径
* @param {string} type base64图片类型 默认png
*/
export function urlToBase64(url, type = 'png') {
let promises
// 网络地址 或者h5端本地相对路径 可使用request方式
promises = new Promise((resolve, reject) => {
uni.request({
url: url,
method: 'GET',
responseType: 'arraybuffer',
success: (res) => {
const base64 = `data:image/${type};base64,${uni.arrayBufferToBase64(res.data)}`
resolve(base64);
},
fail: (err) => {
reject(err);
},
})
})
// #ifdef APP
if (!url.startsWith('http')) {
// app真机本地相对路径
promises = new Promise((resolve, reject) => {
// 使用compressImage获取到安卓本地路径file:///...
uni.compressImage({
src: url,
quality: 100,
success: (res) => {
const tempUrl = res.tempFilePath
plus.io.resolveLocalFileSystemURL(tempUrl, (entry) => {
entry.file((e) => {
let fileReader = new plus.io.FileReader();
fileReader.onload = (r) => {
resolve(r.target.result)
}
fileReader.readAsDataURL(e)
})
})
},
fail: (err) => {
reject(err);
},
})
})
}
// #endif
return promises
}

View File

@ -0,0 +1,188 @@
/**
* 富文本解析工具
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import config from './config.js'
/**
* 将含有封面占位图形式的视频富文本转换成正常视频的富文本
* @param {String} richText 要进行处理的富文本字符串
* @returns {String} 返回处理结果
*/
export function parseHtmlWithVideo(richText) {
// 正则表达式匹配<img>标签及其属性
const imgRegex = /<img\s+([^>]+)>/gi;
// 正则表达式匹配data-custom属性中的url值
const customUrlRegex = /\bdata-custom="[^"]*url=([^&"]+)/i;
return richText.replace(imgRegex, (match, attrs) => {
// 查找data-custom属性中的url值
const urlMatch = attrs.match(customUrlRegex);
if (urlMatch) {
// 获取data-custom中的url
const videoUrl = urlMatch[1];
// 解析出所有属性
const attrArray = attrs.split(/\s+/).filter(attr => attr.trim() !== '');
// 过滤掉src属性和data-custom属性
const newAttrs = attrArray.filter(attr => !attr.startsWith('src=') && !attr.startsWith('data-custom='))
.join(' ');
// 构建新的video标签保留原有的其他属性但去除src和data-custom
//return `<video controls ${newAttrs}><source src="${videoUrl}" /></video>`;
return `<video controls ${newAttrs} src="${videoUrl}" width="100%" ></video>`;
}
// 如果没有匹配到data-custom中的url则保持原样
return match;
});
}
/**
* 带有视频的富文本逆向转换
* @description 可自定义处理封面
* @param {Promise} richText 要转换的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调需要return封面图片资源自带参数为视频地址
* @returns {Promise} 转换后的富文本 注意异步处理
*/
export async function replaceVideoWithImageRender(richText, customCallback) {
console.log(1);
// 正则表达式用于匹配 <video> 标签以及其内部的 <source> 标签
const videoRegex = /<video\s+([^>]+)>(.*?)<\/video>/gi;
console.log(2);
// 找到所有的 <video> 标签
const matches = [];
let match;
while ((match = videoRegex.exec(richText)) !== null) {
matches.push(match);
}
console.log(3);
// 并行处理每个 <video> 标签,生成对应的缩略图
const replacements = await Promise.all(
matches.map(async (match) => {
console.log(5);
const [fullMatch, attributes, content] = match;
// 匹配 <source> 标签中的 src 属性
const sourceRegex = /<source\s+[^>]*src="([^">]+)"/i;
const matchSource = content.match(sourceRegex);
let videoUrl = '';
if (matchSource && matchSource.length > 1) {
videoUrl = matchSource[1];
}
// 生成视频封面图
console.log('5-1')
let thumbnailRes
//if (customCallback) thumbnailRes = await customCallback(videoUrl)
console.log('5-2')
// 自定义封面处理
//if (!thumbnailRes) thumbnailRes = config.video_thumbnail // 无效值则默认封面处理
console.log('5-3')
thumbnailRes="https://caseplatform.oss-cn-beijing.aliyuncs.com/prod/static/shipinfengmian.jpg"
// 过滤掉不需要的属性,例如 controls
const filteredAttributes = attributes
.split(/\s+/)
.filter(attr => !attr.startsWith('controls'))
.join(' ').replace('src=','').replace("").replaceAll('width="100%"','').replaceAll('"','').replace(/\s+/g, "");;
console.log('5-4')
// 构建新的 img 标签,继承 video 的属性(除了 controls并添加 data-custom 属性
const imgTag = `<img src="${thumbnailRes}" data-custom="url=${filteredAttributes}">`;
console.log(6);
return { fullMatch, imgTag };
}));
console.log(7);
// 使用 replacements 替换原始的 <video> 标签
let result = richText;
for (const { fullMatch, imgTag } of replacements) {
console.log(8);
result = result.replace(fullMatch, imgTag);
}
console.log("打印结果2")
console.log(result)
return result;
}
/**
* 解析出富文本中的图片和视频
* @param {String} richText 要解析的富文本
* @returns {Array} 图片和视频数组
*/
export function parseImagesAndVideos(richText) {
// 创建一个空数组用于存储图片和视频信息
const result = [];
// 正则表达式匹配 <img> 标签及其属性
const imgRegex = /<img\s+[^>]*>/gi;
// 匹配属性名和值的正则表达式,改进后的版本可以处理属性名中包含连字符的情况
const attrRegex = /(\w+(-\w+)*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'<>]+))/gi;
// 找到所有的 <img> 标签
const matches = richText.match(imgRegex);
// 如果没有找到任何 <img> 标签,返回空数组
if (!matches) return [];
// 遍历所有的 <img> 标签
matches.forEach(match => {
// 创建一个对象用于存储单个图片或视频的信息
const ivInfo = {};
// 使用正则表达式匹配每个 <img> 标签的属性
let attrsMatch;
while ((attrsMatch = attrRegex.exec(match)) !== null) {
// 属性名
const name = attrsMatch[1].toLowerCase();
// 属性值可能存在于第三、第四或第五个捕获组中
let value = attrsMatch[3] || attrsMatch[4] || attrsMatch[5] || '';
// 去除属性值两端可能存在的引号
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
value = value.substring(1, value.length - 1);
}
// 将属性名和值添加到 ivInfo 对象中
ivInfo[name] = value;
}
// 将单个图片或视频信息添加到数组中
result.push(ivInfo);
});
// 返回包含所有图片和视频信息的数组
return result;
}
/**
* 解析出富文本中的图片
* @param {String} richText 要解析的富文本
* @returns {Array} 图片数组
*/
export function parseImages(richText) {
let result = []
const ivList = parseImagesAndVideos(richText)
ivList.forEach(item => {
if (!item['data-custom'] || !item['data-custom'].startsWith('url')) {
result.push(item)
}
})
return result
}
/**
* 解析出富文本中的视频
* @param {String} richText 要解析的富文本
* @returns {Array} 视频数组
*/
export function parseVideos(richText) {
let result = []
const ivList = parseImagesAndVideos(richText)
ivList.forEach(item => {
if (item['data-custom'] && item['data-custom'].startsWith('url')) {
result.push(item)
}
})
return result
}

View File

@ -0,0 +1,101 @@
/**
* 插件内全局状态管理
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
// #ifdef VUE3
import { reactive } from 'vue';
// #endif
// #ifdef VUE2
import Vue from 'vue';
// #endif
// 定义state状态
let state = null
// #ifdef VUE3
// 定义响应式状态
state = reactive({
curEID: '',
formats: {},
isReadOnly: false,
firstInstanceFlag: '' // 首次实例化标志,禁止手动更改
})
// #endif
// #ifdef VUE2
// 定义响应式状态
state = Vue.observable({
curEID: '',
formats: {},
isReadOnly: false,
firstInstanceFlag: '' // 首次实例化标志,禁止手动更改
})
// #endif
// 定义方法
function getEditor(eid) {
return state[`${eid}-ctx`];
};
function setEditor(eid, ctx) {
state[`${eid}-ctx`] = ctx
// #ifdef MP-WEIXIN
state[`${eid}-ctx`].id = eid
// #endif
}
function getEID() {
return state.curEID
};
function setEID(eid) {
state.curEID = eid
}
function getFormats() {
return state.formats
}
function setFormats(formats) {
state.formats = formats
}
function getReadOnly() {
return state.isReadOnly
}
function setReadOnly(readOnly) {
state.isReadOnly = readOnly
}
function destroy() {
// 重置所有状态
state = {}
state.curEID = ''
state.formats = {}
state.isReadOnly = false
state.firstInstanceFlag = '' // 首次实例化标志,禁止手动更改
}
// 定义options对象
const options = {
state,
actions: {
getEditor,
setEditor,
getEID,
setEID,
getFormats,
setFormats,
getReadOnly,
setReadOnly,
destroy
}
}
// 导出
export default options

View File

@ -0,0 +1,208 @@
/**
* 工具栏
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export const toolList = [
{ title: '样式', name: 'style', icon: 'icon-zitiyanse' },
{ title: '表情', name: 'emoji', icon: 'icon-xiaolian' },
{ title: '撤销', name: 'undo', icon: 'icon-shangyibu1' },
{ title: '重做', name: 'redo', icon: 'icon-xiayibu1' },
{ title: '更多', name: 'more', icon: 'icon-icon_tianjia' },
{ title: '扩展', name: 'setting', icon: 'icon-bianji' },
]
export const styleToolList = [
{ title: '标题', name: 'header', value: 2, icon: 'icon-zitibiaoti' },
{ title: '分割线', name: 'divider', icon: 'icon-fengexian' },
{ title: '粗体', name: 'bold', icon: 'icon-zitijiacu' },
{ title: '斜体', name: 'italic', icon: 'icon-zitixieti' },
{ title: '下划线', name: 'underline', icon: 'icon-zitixiahuaxian' },
{ title: '删除线', name: 'strike', icon: 'icon-zitishanchuxian' },
{ title: '左对齐', name: 'align', value: 'left', icon: 'icon-zuoduiqi' },
{ title: '居中', name: 'align', value: 'center', icon: 'icon-juzhongduiqi' },
{ title: '右对齐', name: 'align', value: 'right', icon: 'icon-youduiqi' },
{ title: '有序列表', name: 'list', value: 'ordered', icon: 'icon-youxupailie' },
{ title: '无序列表', name: 'list', value: 'bullet', icon: 'icon-wuxupailie' },
{ title: '上标', name: 'script', value: 'super', icon: 'icon-zitishangbiao' },
{ title: '左缩进', name: 'indent', value: '+1', icon: 'icon-zuosuojin' },
{ title: '右缩进', name: 'indent', value: '-1', icon: 'icon-yousuojin' },
{ title: '下标', name: 'script', value: 'sub', icon: 'icon-ziti-xiabiao' },
{ title: '文字颜色', name: 'color', icon: 'icon-wenziyanse' },
{ title: '背景颜色', name: 'backgroundColor', icon: 'icon-beijingyanse' },
{ title: '清除格式', name: 'removeformat', icon: 'icon-qingchugeshi' },
]
export const moreToolList = [
{ title: '添加图片', name: 'image', value: 'popup', icon: 'icon-charutupian' },
{ title: '添加视频', name: 'video', value: 'popup', icon: 'icon-shexiangji' },
{ title: '添加链接', name: 'link', value: 'popup', icon: 'icon-charulianjie' },
{ title: '添加附件', name: 'attachment', value: 'popup', icon: 'icon-huixingzhen' },
{ title: '提及', name: 'at', value: 'popup', icon: 'icon-at' },
{ title: '话题', name: 'topic', value: 'popup', icon: 'icon-huati' },
{ title: '清空', name: 'clear', value: 'button', icon: 'icon-shanchu' },
]
export const emojiToolList = [
'😊', // 笑笑
'😃', // 大笑
'😄', // 开心果
'😁', // 嘲讽
'😆', // 爆笑
'😅', // 出汗笑
'🤣', // 滚地大笑
'😂', // 泪流满面
'🙂', // 轻松愉快
'🙃', // 上下翻白眼
'😉', // 鬼鬼祟祟
'😌', // 安慰
'😍', // 心动
'🥰', // 深情
'😘', // 吻
'😗', // 接吻
'😙', // 亲吻
'😚', // 亲吻
'😋', // 哇塞
'😛', // 舌头外伸
'😝', // 舌头吐出
'😜', // 顽皮
'🤪', // 疯狂
'😎', // 自豪
'🤓', // 学究
'🧐', // 思考
'😏', // 狡猾
'😒', // 不高兴
'😞', // 不开心
'😔', // 抒发情绪
'😟', // 担忧
'😕', // 困惑
'🙁', // 小失望
'☹️️', // 不好意思
'😣', // 苦恼
'😖', // 愤怒
'😫', // 累
'😩', // 悲伤
'😤', // 生气
'😠', // 生气
'😡', // 极端愤怒
'🤬', // 发飙
'🤯', // 爆炸头脑
'😳', // 吃惊
'😱', // 惊吓
'😨', // 恐惧
'😰', // 慌张
'😢', // 哭泣
'😭', // 大哭
'😓', // 受挫
'🤗', // 给力
'🤔', // 思考
'🤭', // 戴口罩捂嘴笑
'🤫', // 戴口罩做鬼脸
'🤥', // 说谎
'😬', // 格格不入
'😴', // 睡觉
'🤤', // 垂涎欲滴
'🥳', // 庆祝
'🥺', // 求求你
'😈', // 恶魔
'👿', // 恶灵
'🤡', // 小丑
'👻', // 鬼魂
'👽', // 外星人
'👾', // 游戏角色
'🤖', // 机器人
'😺', // 笑猫
'😸', // 大笑猫
'😹', // 开心猫
'😻', // 心动猫
'😼', // 傲娇猫
'😽', // 亲吻猫
'🙀', // 惊吓猫
'😿', // 哭猫
'😾' // 生气猫
]
export const colorList = [
'#000000',
'#222222',
'#444444',
'#666666',
'#999999',
'#cccccc',
'#eeeeee',
'#ffffff',
'#c92a2a',
'#e03131',
'#f03e3e',
'#fa5252',
'#ff6b6b',
'#ff8787',
'#ffa8a8',
'#ffc9c9',
'#a61e4d',
'#c2255c',
'#d6336c',
'#e64980',
'#f06595',
'#f783ac',
'#faa2c1',
'#fcc2d7',
'#862e9c',
'#9c36b5',
'#ae3ec9',
'#be4bdb',
'#cc5de8',
'#da77f2',
'#e599f7',
'#eebefa',
'#5f3dc4',
'#6741d9',
'#7048e8',
'#7950f2',
'#845ef7',
'#9775fa',
'#b197fc',
'#d0bfff',
'#0b7285',
'#0c8599',
'#1098ad',
'#15aabf',
'#22b8cf',
'#3bc9db',
'#66d9e8',
'#99e9f2',
'#087f5b',
'#099268',
'#0ca678',
'#12b886',
'#20c997',
'#38d9a9',
'#63e6be',
'#96f2d7',
'#5c940d',
'#66a80f',
'#74b816',
'#82c91e',
'#94d82d',
'#a9e34b',
'#c0eb75',
'#ffec99',
'#d9480f',
'#e8590c',
'#f76707',
'#fd7e14',
'#ff922b',
'#ffa94d',
'#ffc078',
'#ffd8a8'
]

View File

@ -0,0 +1,430 @@
/**
* 通用工具api
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from './store.js'
export function addText(word){
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
editorCtx.insertText({ text: '\n' })
editorCtx.insertText({ text:'【'+word+'】:' })
// 建议加个换行虽然会导致input回调再次触发不过问题不大
editorCtx.insertText({ text: '\n' })
})
}
/**
* 添加图片
* @param {Function} uploadFunc 文件上传函数开发者自行调用上传接口上传本地图片至服务器后获取服务器图片真实地址需要return包含地址的数组
* @param {Object} options 图片配置项
* @property {String} options.srcFiled 图片地址字段名默认无时使用数组元素本身
* @property {String} options.alt 图像无法显示时的替代文本
* @property {String} options.width 图片宽度pixels/百分比为空时自适应图片本身宽度默认空不建议100%预留一点空隙以便用户编辑
* @property {String} options.height 图片高度 (pixels/百分比为空时自适应图片本身高度默认空
* @property {String} options.extClass 添加到图片 img 标签上的类名
* @property {String} options.data 被序列化为 v1=1;v2=2 的格式挂在属性 data-custom
* @returns {Array|Promise} 上传的文件数组
*/
export function addImage(imgs, options = {}) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传图片
//if (!uploadFunc) return
const upRes = imgs //await uploadFunc(editorCtx)
console.log('upRes')
console.log(upRes)
console.log(upRes instanceof Array)
console.log(upRes.length)
if (!upRes || upRes.length==0) return
// 取消键盘副作用
noKeyboardEffect(() => {
//editorCtx.insertText({ text: '\n' })
upRes?.forEach((item) => {
console.log('item')
console.log(item)
editorCtx.insertImage({
...options,
src: options.srcFiled ? item[options.srcFiled] : item,
})
})
// 建议加个换行虽然会导致input回调再次触发不过问题不大
//editorCtx.insertText({ text: '\n' })
})
return upRes
}
/**
* 添加视频
* @description uni-editor暂不支持插入视频此处使用视频封面占位将视频地址作为属性挂在至data-custom上携带视频的富文本需要使用专用的api进行解析导出建议后端配合返回视频封面图片地址或者使用固定的网络图片作为封面
* @param {Function} uploadFunc 文件上传函数开发者自行调用上传接口上传本地视频至服务器后获取服务器视频真实地址需要return包含地址的数组
* @param {Object} options 视频封面图片配置项
* @property {String} options.imageFiled 视频封面图片地址字段名默认imagePath
* @property {String} options.videoFiled 视频真实地址字段名默认videoPath
* @property {String} options.alt 视频封面图片无法显示时的替代文本
* @property {String} options.width 视频封面图片宽度pixels/百分比默认空但是要注意不设置width的话video标签默认宽度为300px
* @property {String} options.height 视频封面图片高度 (pixels/百分比默认空
* @property {String} options.extClass 添加到视频封面图片 img 标签上的类名
* @property {String} options.data 警告视频地址已存入data-custom中请勿使用此参数导致视频地址被覆盖
* @returns {Array|Promise} 上传的文件数组
*/
export async function addVideo(uploadFunc, options = {}) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传视频
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
console.log(upRes);
if (!upRes || !upRes?.length) return
// 取消键盘副作用
noKeyboardEffect(() => {
//editorCtx.insertText({ text: '\n' })
upRes?.forEach((item) => {
editorCtx.insertImage({
...options,
src: item.videoImg,//item[options.imageFiled || 'imagePath'],
data: { url: item.videoUrl },
})
})
// 建议加个换行虽然会导致input回调再次触发不过问题不大
//editorCtx.insertText({ text: '\n' })
})
return upRes
}
/**
* 添加链接
* @param {Object} options 链接配置项
* @property {String} options.link 链接地址
* @property {String} options.text 链接文本 空缺时使用link
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#007aff
* @property {Object} options.style 其他样式例如 { bold: true, italic: true } 详见https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
* @returns {void}
*/
export async function addLink(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
...options,
link: options.link,
text: ` ${options.text || options.link} `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 添加附件
* @param {Function} uploadFunc 文件上传函数开发者自行调用上传接口上传本地附件至服务器后获取服务器附件真实地址需要return包含地址的对象
* @param {Object} options 附件配置项
* @property {String} options.srcFiled 附件地址字段名默认path
* @property {String} options.link 附件地址 临时地址会自动转成about:blank导致无效
* @property {String} options.text 附件文本 空缺时使用link
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#34d19d
* @property {Object} options.style 其他样式例如 { bold: true, italic: true } 详见https://quilljs.com/docs/delta
* @param {Function} callback 添加附件成功后回调
* @returns {Object|Promise} 上传的文件对象
*/
export async function addAttachment(uploadFunc, options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 服务器上传附件
if (!uploadFunc) return
const upRes = await uploadFunc(editorCtx)
if (!upRes) return
const link = upRes[options.srcFiled || 'path'] || options.link
if (!link) return
const text = ` 📄${upRes.text || options.text || upRes.file?.name || link } ` // 加上附件图标前置,并前后各加一个空格
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#34d19d',
...options,
text,
link,
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
return upRes
}
/**
* 添加提及
* @param {Object} options 提及配置项
* @property {String} options.username 用户名称
* @property {String} options.userid 用户id
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#66ccff
* @property {Object} options.style 其他样式例如 { bold: true, italic: true } 详见https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
*/
export async function addAt(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#66ccff',
...options,
link: `@${options.userid}`, // 添加特殊前缀,后续便于解析标识
text: ` @${options.username} `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 添加话题
* @param {Object} options 话题配置项
* @property {String} options.link 话题链接
* @property {String} options.topic 话题名称
* @property {String} options.textDecoration 下划线
* @property {String} options.color 颜色 默认#909399
* @property {Object} options.style 其他样式例如 { bold: true, italic: true } 详见https://quilljs.com/docs/delta
* @param {Function} callback 添加链接成功后回调
*/
export async function addTopic(options = {}, callback) {
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 取消键盘副作用
noKeyboardEffect(() => {
insertLink(editorCtx, {
color: '#909399',
...options,
link: `#${options.link}`, // 添加特殊前缀,后续便于解析标识
text: ` #${options.topic}# `, // 前后各加一个空格
}, () => {
editorCtx.changeInput() // 通知更新编辑器input事件
if (callback) callback()
})
})
}
/**
* 标识必须独一无二 - 标识是为了使用insertText插入标识文本后查找到标识所在delta位置的索引
* 因为做了一次insertText操作所有可能会有linkFlag标识字样闪一下的副作用没有办法避免
*/
export const linkFlag = '🔗添加链接中, 请稍后...🔗' // 建议语义化该标识,否则闪烁的时候可能会有点尴尬
/**
* 插入链接
* @description uni-editor暂不支持插入链接此api使用delta替换链接本文标识方式实现因硬性原因会导致光标失焦
* @param {Object} editorCtx 编辑器上下文
* @param {Object} attr 链接属性
* @property {String} attr.link 链接地址 临时地址会自动转成about:blank导致无效
* @property {String} attr.text 链接文本 空缺时使用link
* @property {String} attr.textDecoration 下划线
* @property {String} attr.color 颜色 默认#007aff
* @property {Object} attr.style 其他样式例如 { bold: true, italic: true } 详见https://quilljs.com/docs/delta
* @param {Object} callback 成功回调
*/
export function insertLink(editorCtx, attr, callback) {
// 先插入一段文本内容
editorCtx.insertText({ text: linkFlag })
// 必须先失焦,否则光标会移至开始位置
editorCtx.blur()
// 获取全文delta内容
editorCtx.getContents({
success: (res) => {
let options = res.delta.ops
const findex = options.findIndex(item => {
return item.insert && typeof item.insert !== 'object' && item.insert?.indexOf(linkFlag) !== -1
})
// 根据标识查找到插入的位置
if (findex > -1) {
const findOption = options[findex]
const findAttributes = findOption.attributes
// 将该findOption分成三部分前内容 要插入的link 后内容
const [prefix, suffix] = findOption.insert.split(linkFlag);
const handleOps = []
// 前内容
if (prefix) {
const prefixOps = findAttributes ? {
insert: prefix,
attributes: findAttributes
} : {
insert: prefix
}
handleOps.push(prefixOps)
}
// 插入的link
const linkOps = {
insert: attr.text || attr.link,
attributes: {
link: attr.link,
textDecoration: attr.textDecoration || 'none', // 下划线
color: attr.color || '#007aff',
...attr.style
}
}
handleOps.push(linkOps)
// 后内容
if (suffix) {
const suffixOps = findAttributes ? {
insert: suffix,
attributes: findAttributes
} : {
insert: suffix
}
handleOps.push(suffixOps)
}
// 删除原options[findex]并在findex位置插入上述三个ops
options.splice(findex, 1);
options.splice(findex, 0, ...handleOps);
// 最后重新初始化内容
editorCtx.setContents({
delta: {
ops: options
}
})
// 清除格式,以防残留超链接格式
editorCtx.removeFormat()
editorCtx.format('color', 'inherit')
// 后续回调操作
if (callback) callback()
}
}
})
}
/**
* 尽量消除键盘带来的影响重要核心功能
* @param {Function} callback 回调
* @param {Object} options 配置项
* @property {String} options.mode 可选setInputMode通过控制ql-editor的inputmode属性控制键盘 [H5 APP] | loseFocus通过blur失焦隐藏键盘 [MP-WEIXIN] | hideKeyboard通过hideKeyboard隐藏键盘 | setReadOnly通过控制读写隐藏键盘
* @property {Number} options.delay 延时毫秒默认50
*/
export function noKeyboardEffect(callback, options) {
let defaultOpt = { delay: 50 }
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
defaultOpt.mode = isIOS ? 'loseFocus' : 'setInputMode' // iOS使用setInputMode无效
// #endif
// #ifdef H5
defaultOpt.mode = 'setInputMode'
// #endif
// #ifdef MP-WEIXIN
defaultOpt.mode = 'loseFocus'
// #endif
const opt = Object.assign(defaultOpt, options)
const eid = store.actions.getEID()
const editorCtx = store.actions.getEditor(eid)
// 通过 uni.hideKeyboard() 隐藏键盘,但是会导致键盘闪烁
// 微信小程序好像无法正常隐藏键盘
if (opt.mode == 'hideKeyboard') {
callback()
setTimeout(() => {
uni.hideKeyboard()
}, opt.delay)
}
// 通过控制编辑器失焦来隐藏键盘,但是会导致键盘闪烁
// 只推荐微信小程序使用(也是无可奈何)
if (opt.mode == 'loseFocus') {
callback()
editorCtx.blur()
}
// 通过控制编辑器读写模式进行屏蔽焦点,虽然隐藏了键盘,但是也失焦了
// 微信小程序中当只读时是无法使用api去修改内容的
if (opt.mode == 'setReadOnly') {
store.actions.setReadOnly(true)
callback()
setTimeout(() => {
store.actions.setReadOnly(false)
}, opt.delay)
}
// 使用renderjs给ql-editor节点设置inputmode属性来控制键盘是否弹出
// 设置none时将会阻止键盘弹出设置remove将会恢复完美适配H5、App(Android)但是不支持App(iOS)和微信小程序
if (opt.mode == 'setInputMode') {
// #ifdef APP || H5
// 以下严格处理异步与延时操作,缺一不可
editorCtx.changeInputMode('none')
setTimeout(() => {
callback()
setTimeout(() => {
editorCtx.changeInputMode('remove')
}, opt.delay)
}, opt.delay)
// #endif
}
}
/**
* 版权信息
*/
import packageConfig from '../../package.json'
export function copyrightPrint() {
/* 标题样式 */
const styleTitle1 = `font-size:16px;font-weight:700;color:#ff4500;`
const styleTitle2 = `font-style:oblique;font-size:14px;color:#fb7299;`
const styleContent = `color:#66ccff;`
/* 版权信息 */
const title1 = ` 📝 sv-editor v${packageConfig.version} `
const title2 = 'by Sonve'
const content = `
版权声明
1. 本插件免费开源还望保留此版权声明在控制台输出
2. 如需借鉴源码还望注明出处
3. 未经授权您不得以任何形式转载售卖本插件或以其他形式侵犯版权及附属权利
4. 作者将保留对此插件版权信息的最终解释权
🏠 地址: https://ext.dcloud.net.cn/plugin?id=21184
😸 Gitee: https://gitee.com/Sonve/sv-editor
💬 微信: s1051399604
🐧 QQ群: 852637893 816646292
`
console.log(`%c${title1}%c${title2}%c${content}`, styleTitle1, styleTitle2, styleContent)
}
export function noAuthorization(name) {
/* 标题样式 */
const styleTitle1 = `font-size:16px;font-weight:700;color:#e6a23c;`
const styleTitle2 = `font-style:oblique;font-size:14px;color:#fb7299;`
const styleContent = `color:#f56c6c;`
/* 授权信息 */
const title1 = ` ⛔ sv-editor ${name} `
const title2 = 'by Sonve'
const content = `
提示您还未获取插件特殊扩展功能授权可联系作者获取
💬 微信: s1051399604 | 🐧 QQ群: 852637893 816646292
🏠 插件地址: https://ext.dcloud.net.cn/plugin?id=21184
`
console.log(`%c${title1}%c${title2}%c${content}`, styleTitle1, styleTitle2, styleContent)
}

View File

@ -0,0 +1,233 @@
@font-face {
font-family: "iconfont";
/* 在vue2中直接使用 ./iconfont.ttf 会找不到文件,很坑,需要返回上一级再点回来 */
src: url('../icons/iconfont.ttf') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-zitishangbiao:before {
content: "\e8e5";
}
.icon-ziti-xiabiao:before {
content: "\e8ea";
}
.icon-yousuojin:before {
content: "\e6f0";
}
.icon-zuosuojin:before {
content: "\e6f1";
}
.icon-wenziyanse:before {
content: "\e60b";
}
.icon-beijingyanse:before {
content: "\e634";
}
.icon-qingchugeshi:before {
content: "\e6f5";
}
.icon-tuige:before {
content: "\e61a";
}
.icon-xiajiantou:before {
content: "\eb6d";
}
.icon-shangjiantou:before {
content: "\eb6e";
}
.icon-huati:before {
content: "\e659";
}
.icon-video:before {
content: "\e60a";
}
.icon-jianpan:before {
content: "\e636";
}
.icon-at:before {
content: "\e81e";
}
.icon-bianji:before {
content: "\eb61";
}
.icon-icon_tianjia:before {
content: "\eb89";
}
.icon-xingzhuang-tupian:before {
content: "\eb98";
}
.icon-xingzhuang-wenzi:before {
content: "\eb99";
}
.icon-huixingzhen:before {
content: "\ebe6";
}
.icon-xiayibu:before {
content: "\ebef";
}
.icon-shangyibu:before {
content: "\ebf0";
}
.icon-baocun:before {
content: "\ec09";
}
.icon-xiayibu1:before {
content: "\ec0a";
}
.icon-shangyibu1:before {
content: "\ec0b";
}
.icon-weizhigeshi:before {
content: "\ec1a";
}
.icon-chehuisekuai:before {
content: "\ec45";
}
.icon-shexiangji:before {
content: "\ec59";
}
.icon-fuzhi:before {
content: "\ec7a";
}
.icon-shanchu:before {
content: "\ec7b";
}
.icon-bianjisekuai:before {
content: "\ec7c";
}
.icon-fengexian:before {
content: "\ec7f";
}
.icon-charulianjie:before {
content: "\ec80";
}
.icon-charutupian:before {
content: "\ec81";
}
.icon-quxiaolianjie:before {
content: "\ec82";
}
.icon-wuxupailie:before {
content: "\ec83";
}
.icon-juzhongduiqi:before {
content: "\ec84";
}
.icon-yinyong:before {
content: "\ec85";
}
.icon-youxupailie:before {
content: "\ec86";
}
.icon-youduiqi:before {
content: "\ec87";
}
.icon-zitidaima:before {
content: "\ec88";
}
.icon-xiaolian:before {
content: "\ec89";
}
.icon-zitijiacu:before {
content: "\ec8a";
}
.icon-zitishanchuxian:before {
content: "\ec8b";
}
.icon-zitibiaoti:before {
content: "\ec8c";
}
.icon-zitixiahuaxian:before {
content: "\ec8d";
}
.icon-zitixieti:before {
content: "\ec8e";
}
.icon-zitiyanse:before {
content: "\ec8f";
}
.icon-zuoduiqi:before {
content: "\ec90";
}
.icon-zuoyouduiqi:before {
content: "\ec91";
}
.icon-tianxie:before {
content: "\ec92";
}
.icon-kongxinwenhao:before {
content: "\ed19";
}
.icon-fangkuai:before {
content: "\ed1a";
}
.icon-jia_sekuai:before {
content: "\ed21";
}
.icon-jian_sekuai:before {
content: "\ed22";
}
.icon-fenxiangfangshi:before {
content: "\ed2e";
}

Binary file not shown.

View File

@ -0,0 +1,656 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:sid="sid"
:change:sid="quillEditor.watchStartID"
:video="videoUrl"
:change:pastemode="quillEditor.watchPasteMode"
:pastemode="pastemode"
:change:video="quillEditor.watchVideoUrl"
:cover="coverUrl"
:change:cover="quillEditor.watchCoverUrl"
:coverios="coverUrlIOS"
:change:coverios="quillEditor.watchCoverUrlIOS"
></text>
</template>
<script>
/**
* 富文本plugin特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-14
*/
export default {
props: {
sid: {
type: String,
default: ''
},
eid: {
type: String,
default: ''
}
},
data() {
return {
videoUrl: '', //
coverUrl: '', //
coverUrlIOS: '', // ios
pastemode: 'text' // text | origin
}
},
mounted() {},
methods: {
changePasteMode(e) {
this.pastemode = e
},
editorPaste(e) {
this.$emit('epaste', e)
},
createVideoThumbnail(url) {
this.videoUrl = url
},
getVideoThumbnail(e) {
// e: { video, cover }
uni.$emit(`E_EDITOR_GET_VIDEO_THUMBNAIL_${e.video}`, e)
},
createCoverThumbnail(url) {
// #ifdef H5
this.coverUrl = url
// #endif
// #ifdef APP
const isIOS = uni.getSystemInfoSync().platform == 'ios'
if (isIOS) {
this.coverUrlIOS = url // iOSOffscreenCanvas
} else {
this.coverUrl = url
}
// #endif
},
getCoverThumbnail(e) {
// e: { image, cover }
uni.$emit(`E_EDITOR_GET_COVER_THUMBNAIL_${e.image}`, e)
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
import config from '../common/config.js'
export default {
data() {
return {
editorID: '',
idStack: [], // sid
matcherMode: '' // text | origin
}
},
methods: {
/**
* 注意watch频繁触发时需要异步修改否则可能会导致监听不到
*/
watchPasteMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.matcherMode = newValue
}
},
watchStartID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.addMatcher(newValue)
}
},
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchVideoUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateVideoThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getVideoThumbnail', {
video: newValue,
cover: res
})
})
}
},
watchCoverUrl(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnail(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* Only Apple Can Do !!!
*/
watchCoverUrlIOS(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.generateCoverThumbnailIOS(newValue).then((res) => {
this.$ownerInstance.callMethod('getCoverThumbnail', {
image: newValue,
cover: res
})
})
}
},
/**
* 保留格式粘贴内容
* @description 此方式尽可能保留原格式易于再编辑但是部分格式会丢失
* @param {String} sid 当前编辑器id
*/
addMatcher(sid) {
if(this.idStack.includes(sid)) return // Matcher
this.idStack.push(sid)
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const getStyleAttributes = (node, style) => {
let attributes = {}
// node
const width = node.getAttribute('width');
const height = node.getAttribute('height');
if (width) attributes.width = width
if (height) attributes.height = height
const dataCustom = node.getAttribute('data-custom');
if (dataCustom) attributes['data-custom'] = dataCustom;
// style
if (style.textAlign) attributes.align = style.textAlign;
if (style.fontWeight === 'bold' || style.fontWeight === '700') attributes.bold = true;
if (style.fontStyle === 'italic') attributes.italic = true;
if (style.textDecoration.includes('underline')) attributes.underline = true;
if (style.textDecoration.includes('line-through')) attributes.strike = true;
if (style.verticalAlign === 'super') attributes.script = 'super'
if (style.verticalAlign === 'sub') attributes.script = 'sub'
if (style.fontFamily) attributes.font = style.fontFamily;
if (style.fontSize) attributes.size = parseFloat(style.fontSize);
if (style.color) attributes.color = style.color;
if (style.backgroundColor) attributes.background = style.backgroundColor;
return attributes
}
const processNode = (node) => {
let ops = [];
if (node.nodeType === Node.ELEMENT_NODE) {
const computedStyle = document.defaultView.getComputedStyle(node);
// <img>
if (node.tagName === 'IMG') {
const imgSrc = node.getAttribute('src');
if (imgSrc) {
ops.push({ insert: '\n' }); //
ops.push({
insert: { image: imgSrc },
attributes: getStyleAttributes(node, computedStyle)
});
ops.push({ insert: '\n' }); //
return ops; //
}
}
// <p> <div>
else if (node.tagName === 'P' || node.tagName === 'DIV') {
ops.push({ insert: '\n' }); //
}
// <ol>
else if (node.tagName === 'OL') {
// ops.push({ insert: '\n', attributes: { list: 'ordered' } });
}
// <ul>
else if (node.tagName === 'UL') {
// ops.push({ insert: '\n', attributes: { list: 'bullet' } });
}
// <li>
else if (node.tagName === 'LI') {
ops.push({ insert: '\n' });
}
// <hr>
else if (node.tagName === 'HR') {
ops.push({ insert: '\n' }); //
ops.push({ insert: { divider: true } });
return ops; //
}
// <a>
else if (node.tagName === 'A') {
const href = node.getAttribute('href');
const textContent = node.textContent.trim();
if (href && textContent) {
ops.push({
insert: ' ' + textContent + ' ',
attributes: {
link: href,
textDecoration: computedStyle.textDecoration,
...getStyleAttributes(node, computedStyle)
}
});
return ops; //
}
}
// <h1> <h6>
else if (['H1', 'H2', 'H3', 'H4', 'H5', 'H6'].includes(node.tagName)) {
// <h1> <h6> header
const headerLevel = parseInt(node.tagName.charAt(1), 10);
const textContent = node.textContent.trim();
if (textContent) {
ops.push({
insert: textContent + '\n', //
attributes: {
header: headerLevel,
...getStyleAttributes(node, computedStyle)
}
});
return ops; //
}
}
//
for (let child of node.childNodes) {
ops = ops.concat(processNode(child));
}
} else if (node.nodeType === Node.TEXT_NODE) {
const textContent = node.nodeValue.trim();
if (textContent) {
//
const parentNode = node.parentNode;
if (parentNode) {
const computedStyle = document.defaultView.getComputedStyle(parentNode);
ops.push({
insert: textContent,
attributes: getStyleAttributes(parentNode, computedStyle)
});
} else {
//
ops.push({ insert: textContent });
}
}
}
return ops;
}
quill.clipboard.addMatcher(Node.ELEMENT_NODE, (node, delta) => {
if (this.matcherMode == 'origin') {
let newOps = processNode(node);
if (newOps.length > 0) {
return { ops: newOps };
}
}
return delta;
})
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使 <!--StartFragment--> <!--EndFragment-->
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
//
return match[1].trim();
}
// HTML
return html;
}
el.addEventListener('paste', (e) => {
let clipboardText = e.clipboardData.getData('text/plain'); //
let clipboardHtml = e.clipboardData.getData('text/html'); // HTML
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
setTimeout(() => {
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml,
range: quill.getSelection() //
})
}, 100);
});
},
/**
* 保留格式粘贴内容
* @description 此方式虽然可以极大程度保留格式但是会导致粘贴下来的内容为一整个块且不易再编辑
* @param {String} sid 当前编辑器id
*/
/*
addMatcher(sid) {
if(this.idStack.includes(sid)) return // Matcher
this.idStack.push(sid)
// BlockEmbed
const BlockEmbed = Quill.import('blots/block/embed');
// blot
class AppPanelEmbed extends BlockEmbed {
static create(value) {
const node = super.create(value);
node.setAttribute('width', '100%');
// html
node.innerHTML = this.transformValue(value)
return node;
}
static transformValue(value) {
let handleArr = value.split('\n')
handleArr = handleArr.map(e => e.replace(/^[\s]+/, '').replace(/[\s]+$/, ''))
return handleArr.join('')
}
// value
static value(node) {
return node.innerHTML
}
}
// blotName
AppPanelEmbed.blotName = 'AppPanelEmbed';
//
AppPanelEmbed.tagName = 'p';
Quill.register(AppPanelEmbed, true);
const el = document.querySelector(`#${sid}`);
const quill = Quill.find(el);
const cleanClipboardHTML = (html, text) => {
if(!html) return text
// 使 <!--StartFragment--> <!--EndFragment-->
const fragmentRegex = /<!--StartFragment-->([\s\S]*?)<!--EndFragment-->/;
const match = html.match(fragmentRegex);
if (match && match[1]) {
//
return match[1].trim();
}
// HTML
return html;
}
el.addEventListener('paste', (e) => {
e.preventDefault();
let clipboardText = e.clipboardData.getData('text/plain'); //
let clipboardHtml = e.clipboardData.getData('text/html'); // HTML
clipboardHtml = cleanClipboardHTML(clipboardHtml, clipboardText)
this.$ownerInstance.callMethod('editorPaste', {
id: sid,
text: clipboardText,
html: clipboardHtml
})
setTimeout(() => {
const range = quill.getSelection(); //
quill.insertEmbed(range.index, 'AppPanelEmbed', clipboardHtml);
}, 100);
});
},
*/
/**
* 生成视频封面图片不兼容iOS
* @property {String} videoUrl 视频地址
* @returns {Promise} 视频封面图片 注意异步处理
*/
async generateVideoThumbnail(videoUrl) {
//
// @param {CanvasContext} context canvas
// @param {Canvas} canvas
const drawPlayButton = (context, canvas) => {
// <img>
const img = new Image();
img.src = config.video_playicon;
//
return new Promise((resolve, reject) => {
img.onload = () => {
//
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
// canvas
context.drawImage(img, playButtonX, playButtonY, playButtonSize, playButtonSize);
resolve();
};
img.onerror = (error) => {
reject(new Error('Failed to load SVG image.'));
};
});
}
return new Promise(async (resolve, reject) => {
try {
// video crossOrigin
const video = document.createElement('video');
video.crossOrigin = 'anonymous'; // crossOrigin
video.preload = 'metadata';
video.src = videoUrl;
// canvas
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
//
video.onloadedmetadata = async () => {
// canvas
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
// canvas
video.currentTime = 0; //
video.onseeked = async () => {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
//
await drawPlayButton(context, canvas);
// canvas Data URL
// resolve(canvas.toDataURL('image/png')); // base64使
// canvas Blob
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
};
// seek
setTimeout(async () => {
if (!video.seeking) {
try {
context.drawImage(video, 0, 0, canvas.width, canvas.height);
//
await drawPlayButton(context, canvas);
// canvas Data URL
// resolve(canvas.toDataURL('image/png')); // base64使
// canvas Blob
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
reject(new Error('Failed to draw image to canvas.'));
}
}
}, 1000); // 1 seek
};
//
video.onerror = (error) => {
// reject(new Error('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.'));
//
console.warn('Failed to load video or get metadata. PS: Maybe the browser cannot play videos.');
resolve(config.video_thumbnail);
};
} catch (error) {
// reject(error);
//
console.warn(error)
resolve(config.video_thumbnail);
}
});
},
/**
* 生成封面图片OffscreenCanvas方式不兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnail(coverUrl) {
return new Promise((resolve, reject) => {
// Worker
const workerCode = `
self.onmessage = async function(e) {
const { imageUrl, iconBase64 } = e.data;
try {
// ImageBitmap
let imgResponse = await fetch(imageUrl);
if (!imgResponse.ok) {
throw new Error(\`Failed to load image from \${imageUrl}: \${imgResponse.statusText}\`);
}
let imgBlob = await imgResponse.blob();
let imgBitmap = await createImageBitmap(imgBlob);
// OffscreenCanvas
const offscreen = new OffscreenCanvas(imgBitmap.width, imgBitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(imgBitmap, 0, 0);
// ImageBitmap
let iconResponse = await fetch(iconBase64);
if (!iconResponse.ok) {
throw new Error(\`Failed to load icon from \${iconBase64}: \${iconResponse.statusText}\`);
}
let iconBlob = await iconResponse.blob();
let iconBitmap = await createImageBitmap(iconBlob);
//
const x = (imgBitmap.width - iconBitmap.width) / 2;
const y = (imgBitmap.height - iconBitmap.height) / 2;
ctx.drawImage(iconBitmap, x, y);
//
const result = await offscreen.convertToBlob();
// 线
self.postMessage(result);
} catch (error) {
console.error('Error processing image:', error.message);
self.postMessage({ error: error.message });
}
};
`
// Blob
const blob = new Blob([workerCode], { type: 'application/javascript' });
// Blob URL
const workerUrl = URL.createObjectURL(blob);
// Worker
const worker = new Worker(workerUrl);
// Worker
worker.onmessage = (e) => {
if (e.data instanceof Blob) {
resolve(URL.createObjectURL(e.data));
} else {
console.warn(e.data.error);
//
resolve(config.video_thumbnail);
}
worker.terminate(); // worker
};
// Worker
worker.postMessage({ imageUrl: coverUrl, iconBase64: config.video_playicon });
});
},
/**
* 生成封面图片普通方式可能影响性能兼容iOS
* @param {Object} coverUrl 封面图片地址
* @returns {Promise}
*/
async generateCoverThumbnailIOS(coverUrl){
return new Promise(async (resolve, reject) => {
try {
// Image
const img = new Image();
img.src = coverUrl;
await new Promise(resolve => img.onload = resolve);
// Canvas
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
//
const icon = new Image();
icon.src = config.video_playicon; // URL
await new Promise(resolve => icon.onload = resolve);
//
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15;
const playButtonSize = canvas.width * 0.15;
const playButtonX = (canvas.width - playButtonSize) / 2;
const playButtonY = (canvas.height - playButtonSize) / 2;
//
const iconAspectRatio = icon.width / icon.height;
const iconWidth = playButtonSize;
const iconHeight = iconWidth / iconAspectRatio;
// Canvas
ctx.drawImage(icon, playButtonX, playButtonY, iconWidth, iconHeight);
// canvas Blob
canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob));
}, 'image/png');
} catch (error) {
// iOS Safari file://
console.warn('iOS createCoverThumbnail error :', error);
// reject(error);
//
resolve(config.video_thumbnail);
}
})
},
}
}
</script>

View File

@ -0,0 +1,175 @@
/**
* 富文本plugin微信小程序特殊扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-17
*/
import config from '../common/config.js'
/**
* 微信小程序特有的OffscreenCanvas方法
* @param {String} coverImageUrl 封面资源地址
* @returns {Promise<String>} 处理后的封面图片的临时文件路径
*/
export function wxCreateCoverThumbnail(coverImageUrl) {
const loadImage = () => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadImage()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export function wxCreateVideoThumbnail(coverImageUrl) {
const loadVideo = () => {
return new Promise((resolve, reject) => {
uni.getVideoInfo({
src: coverImageUrl,
success: (info) => {
resolve(info)
},
fail: (err) => {
reject(err)
}
})
})
}
return new Promise(async (resolve, reject) => {
try {
const imageInfo = await loadVideo()
// 创建离屏 Canvas
const canvas = uni.createOffscreenCanvas({
type: '2d',
width: imageInfo.width,
height: imageInfo.height
})
const ctx = canvas.getContext('2d')
// 创建一个图片
const coverImg = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
coverImg.onload = resolve
coverImg.src = coverImageUrl // 要加载的图片 url
})
// 绘制封面图片到离屏 Canvas
ctx.drawImage(coverImg, 0, 0, canvas.width, canvas.height)
// 加载播放按钮图标
const playIcon = canvas.createImage()
// 等待图片加载
await new Promise((resolve) => {
playIcon.onload = resolve
playIcon.src = config.video_playicon // 要加载的图片 url
})
// 计算播放按钮的位置和大小
// const playButtonSize = Math.min(canvas.width, canvas.height) * 0.15
const playButtonSize = canvas.width * 0.15
const playButtonX = (canvas.width - playButtonSize) / 2
const playButtonY = (canvas.height - playButtonSize) / 2
// 确保播放按钮图标按比例缩放
const iconAspectRatio = playIcon.width / playIcon.height
const iconWidth = playButtonSize
const iconHeight = iconWidth / iconAspectRatio
// 绘制播放按钮图标到离屏 Canvas
ctx.drawImage(playIcon, playButtonX, playButtonY, iconWidth, iconHeight)
// 获取画完后的数据
uni.canvasToTempFilePath({
canvas: canvas,
destWidth: canvas.width,
destHeight: canvas.height,
fileType: 'png',
success: (res) => {
resolve(res.tempFilePath)
},
fail: (err) => {
reject(new Error('Failed to convert canvas to image.'))
}
})
} catch (error) {
reject(error)
}
})
}
export default {
wxCreateCoverThumbnail,
wxCreateVideoThumbnail
}

View File

@ -0,0 +1,122 @@
<template>
<text :data="flag" :props="config" :change:data="fileManager.watchData" :change:props="fileManager.watchProps"></text>
</template>
<script>
/**
* 文件选择 - APP端
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export default {
props: {
/**
* 配置项
* @tutorial https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/input/file
*/
config: {
type: Object,
default: () => {
return {
accept: `.doc,.docx,.xls,.xlsx,.pdf,.zip,.rar,
application/msword,
application/vnd.openxmlformats-officedocument.wordprocessingml.document,
application/vnd.ms-excel,
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,
application/pdf,
application/zip,
application/x-rar-compressed`,
multiple: false
}
}
}
},
data() {
return {
flag: 0 //
}
},
methods: {
chooseFile() {
this.flag++ //
},
rawFile(file) {
this.$emit('confirm', file)
}
}
}
</script>
<script module="fileManager" lang="renderjs">
import { base64ToPath } from '../common/file-handler.js';
export default {
data() {
return {
configCopy: {}, // vueprops
}
},
methods: {
watchData(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.openFileManager()
}
},
watchProps(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.configCopy = newValue
}
},
openFileManager() {
try {
const { accept, multiple } = this.configCopy
// input
let fileInput = document.createElement('input')
fileInput.setAttribute('type', 'file')
fileInput.setAttribute('accept', accept)
// multiplefalsemultiple
if(multiple) fileInput.setAttribute('multiple', multiple)
fileInput.click()
// PromiseFileReader
const readFileAsDataURL = (file) => {
return new Promise((resolve, reject) => {
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async (event) => {
const base64 = event.target.result
const path = await base64ToPath(base64)
resolve({
name: file.name,
type: file.type,
size: file.size,
base64,
path
});
};
reader.onerror = (error) => {
reject(error);
};
});
}
fileInput.addEventListener('change', async (e) => {
let files = e.target.files // FileList
let results = await Promise.all(
// Array.from
Array.from(files).map(item => readFileAsDataURL(item))
);
// callMethod
this.$ownerInstance.callMethod('rawFile', results)
})
} catch (err) {
console.warn('==== openFileManager catch error :', err);
}
}
}
}
</script>

View File

@ -0,0 +1,178 @@
<template>
<view @touchmove.stop.prevent="moveStop">
<view class="sv-editor-colorpicker" v-if="showPicker">
<view class="editor-popup-header">
<!-- <view class="header-left" @click="cancel">取消</view> -->
<view class="header-left" @click="reset">重置</view>
<view class="header-title" :style="{ backgroundColor: selectColor }" v-if="selectColor">{{ selectColor }}</view>
<view class="header-right" @click="confirm">确认</view>
</view>
<view class="sv-editor-colorpicker-container">
<view
v-for="item in allColors"
:key="item"
class="color-item"
:style="{ backgroundColor: item }"
@click="onSelect(item)"
></view>
</view>
</view>
<view class="mask" v-if="showPicker" @click.stop="onMask"></view>
</view>
</template>
<script>
import { colorList } from '../common/tool-list'
export default {
name: 'sv-editor-colorpicker',
props: {
show: {
type: Boolean,
default: false
},
color: {
type: String,
default: ''
},
type: {
type: String,
default: 'color'
},
//
maskClose: {
type: Boolean,
default: true
}
},
emits: ['update:show', 'open', 'close', 'onMask', 'cancel', 'confirm'],
// #ifdef VUE2
model: {
prop: 'show',
event: 'update:show'
},
// #endif
data() {
return {
selectColor: this.color
}
},
watch: {
color(newVal) {
this.selectColor = newVal
}
},
computed: {
showPicker: {
set(newVal) {
this.$emit('update:show', newVal)
},
get() {
return this.show
}
},
allColors() {
return colorList
}
},
methods: {
// 穿
moveStop() {},
open() {
this.showPicker = true
this.$emit('open')
},
close() {
this.showPicker = false
this.$emit('close')
},
onMask() {
if (this.maskClose) this.close()
this.$emit('onMask')
},
cancel() {
this.$emit('cancel')
this.close()
},
confirm() {
this.$emit('confirm', this.selectColor, this.type)
},
reset() {
this.selectColor = ''
},
onSelect(e) {
this.selectColor = e
}
}
}
</script>
<style lang="scss">
.sv-editor-colorpicker {
--editor-colorpicker-bgcolor: #ffffff;
--editor-colorpicker-radius: 30rpx 30rpx 0 0;
--editor-colorpicker-confirm: #4d80f0;
--editor-colorpicker-cancel: #fa4350;
--editor-colorpicker-header-height: 50rpx;
width: 100%;
position: absolute;
bottom: 0;
z-index: 10000;
border-radius: var(--editor-colorpicker-radius);
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background-color: var(--editor-colorpicker-bgcolor);
box-sizing: border-box;
.editor-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
height: var(--editor-colorpicker-header-height);
.header-left {
color: var(--editor-colorpicker-cancel);
}
.header-title {
color: #000000;
text-shadow: 1rpx 1rpx #ffffff, -1rpx 1rpx #ffffff, 1rpx -1rpx #ffffff, -1rpx -1rpx #ffffff;
padding: 4rpx 12rpx;
box-shadow: 0 0 8rpx #cccccc;
border-radius: 10rpx;
}
.header-right {
color: var(--editor-colorpicker-confirm);
}
}
.sv-editor-colorpicker-container {
// max-height: 40vh;
overflow: auto;
display: grid;
grid-template-columns: repeat(8, 1fr);
align-items: center; /* 垂直居中 */
justify-items: center; /* 水平居中 */
gap: 20rpx;
box-sizing: border-box;
.color-item {
width: 100%;
height: 60rpx;
box-shadow: 0 0 8rpx #ccc;
border-radius: 10rpx;
}
}
}
.mask {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,445 @@
<template>
<view @touchmove.stop.prevent="moveStop">
<view class="sv-editor-popup" v-if="showPop">
<view class="editor-popup-header">
<view class="header-left" @click="cancel">取消</view>
<view class="header-title">{{ title }}</view>
<view class="header-right" @click="confirm">确认</view>
</view>
<view class="sv-editor-popup-container">
<!-- 添加图片 -->
<view class="popup-image" v-if="toolName == 'image'">
<view class="popup-form-input">
<text class="form-label">网络图片</text>
<input v-model="imageForm.link" type="text" class="form-input" placeholder="请输入图片地址" />
</view>
<view class="popup-form-input">
<text class="form-label">本地图片</text>
<button size="mini" class="form-button" @click="selectImage">选择文件</button>
<view class="form-thumbnail">
<image
class="form-thumbnail-item form-thumbnail-image"
v-for="(item, index) in imageForm.file"
:key="item.path"
:src="item.path"
@click="deleteImage(index)"
></image>
</view>
</view>
</view>
<!-- 添加视频 -->
<view class="popup-video" v-if="toolName == 'video'">
<view class="popup-form-input">
<text class="form-label">网络视频</text>
<input v-model="videoForm.link" type="text" class="form-input" placeholder="请输入视频地址" />
</view>
<view class="popup-form-input">
<text class="form-label">本地视频</text>
<button size="mini" class="form-button" @click="selectVideo">选择文件</button>
<view class="form-thumbnail" v-if="videoForm.file.tempFilePath">
<view class="form-thumbnail-item form-thumbnail-icon" @click="deleteVideo">
<text class="iconfont icon-video"></text>
</view>
</view>
</view>
</view>
<!-- 添加链接 -->
<view class="popup-link" v-if="toolName == 'link'">
<view class="popup-form-input">
<text class="form-label">链接地址</text>
<input v-model="linkForm.link" type="text" class="form-input" placeholder="请输入链接地址 (必填)" />
</view>
<view class="popup-form-input">
<text class="form-label">链接文本</text>
<input v-model="linkForm.text" type="text" class="form-input" placeholder="请输入链接文本 (可选)" />
</view>
</view>
<!-- 添加附件 -->
<view class="popup-attachment" v-if="toolName == 'attachment'">
<view class="popup-form-input">
<text class="form-label">附件地址</text>
<input v-model="attachmentForm.link" type="text" class="form-input" placeholder="请输入附件地址" />
</view>
<view class="popup-form-input">
<text class="form-label">附件描述</text>
<input v-model="attachmentForm.text" type="text" class="form-input" placeholder="请输入附件描述" />
</view>
<view class="popup-form-input">
<text class="form-label">本地文件</text>
<button size="mini" class="form-button" @click="selectAttachment">选择文件</button>
<view class="form-thumbnail" v-if="attachmentForm.file.path">
<view class="form-thumbnail-item form-thumbnail-icon" @click="deleteAttachment">
<text class="iconfont icon-huixingzhen"></text>
</view>
</view>
</view>
</view>
<!-- 提及 -->
<view class="popup-at" v-if="toolName == 'at'">
<slot name="at"></slot>
</view>
<!-- 话题 -->
<view class="popup-topic" v-if="toolName == 'topic'">
<slot name="topic"></slot>
</view>
</view>
</view>
<view class="mask" v-if="showPop" @click.stop="onMask"></view>
<!-- #ifdef APP -->
<sv-choose-file ref="chooseFileRef" @confirm="selectAppFile"></sv-choose-file>
<!-- #endif -->
</view>
</template>
<script>
/**
* 扩展工具面板弹窗
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import { moreToolList } from '../common/tool-list.js'
import SvChooseFile from './sv-choose-file.vue'
export default {
name: 'sv-editor-popup-more',
// #ifdef MP-WEIXIN
//
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvChooseFile
},
props: {
show: {
type: Boolean,
default: false
},
toolName: {
type: [String, null],
default: 'image'
},
//
maskClose: {
type: Boolean,
default: true
}
},
emits: ['update:show', 'open', 'close', 'onMask', 'cancel', 'confirm'],
// #ifdef VUE2
model: {
prop: 'show',
event: 'update:show'
},
// #endif
data() {
return {
imageForm: {
link: '',
file: []
},
videoForm: {
link: '',
file: {}
},
linkForm: {
link: '',
text: ''
},
attachmentForm: {
link: '',
text: '',
file: {}
}
}
},
computed: {
showPop: {
set(newVal) {
this.$emit('update:show', newVal)
},
get() {
return this.show
}
},
title() {
return moreToolList.find((item) => item.name == this.toolName)?.title
}
},
methods: {
// 穿
moveStop() {},
open() {
this.showPop = true
this.$emit('open')
},
close() {
this.showPop = false
this.$emit('close')
},
onMask() {
if (this.maskClose) this.close()
this.$emit('onMask')
},
cancel() {
this.$emit('cancel')
this.close()
},
confirm() {
let params = {}
params.name = this.toolName
switch (this.toolName) {
case 'image':
Object.assign(params, this.imageForm)
break
case 'video':
Object.assign(params, this.videoForm)
break
case 'link':
Object.assign(params, this.linkForm)
break
case 'attachment':
Object.assign(params, this.attachmentForm)
break
}
this.$emit('confirm', params)
},
/**
* 业务方法
*/
//
selectImage() {
// #ifdef APP || H5
uni.chooseImage({
count: 5, // 95
success: (res) => {
this.imageForm.file = res.tempFiles
},
fail: () => {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef MP-WEIXIN
uni.chooseMedia({
count: 5, // 95
mediaType: ['image'],
success: (res) => {
this.imageForm.file = res.tempFiles
},
fail: () => {
uni.showToast({
title: '未授权访问相册权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
//
deleteImage(index) {
this.imageForm.file.splice(index, 1)
},
//
selectVideo() {
uni.chooseVideo({
sourceType: ['camera', 'album'],
success: (res) => {
this.videoForm.file = res
},
fail: () => {
uni.showToast({
title: '未授权访问媒体权限,请授权后使用',
icon: 'none'
})
}
})
},
//
deleteVideo() {
this.videoForm.file = {}
},
//
selectAttachment() {
// #ifdef H5
uni.chooseFile({
count: 1, // 1001
extension: ['.doc', '.docx', '.xls', '.xlsx', '.pdf', '.zip', '.rar'],
success: (res) => {
this.attachmentForm.file = res.tempFiles[0]
},
fail: () => {
uni.showToast({
title: '未授权访问文件权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
// #ifdef APP
this.$refs.chooseFileRef.chooseFile()
// selectAppFile
// #endif
// #ifdef MP-WEIXIN
wx.chooseMessageFile({
count: 1, // 01001
type: 'file', //
extension: ['.doc', '.docx', '.xls', '.xlsx', '.pdf', '.zip', '.rar'],
success: (res) => {
this.attachmentForm.file = res.tempFiles[0]
},
fail: () => {
uni.showToast({
title: '未授权访问文件权限,请授权后使用',
icon: 'none'
})
}
})
// #endif
},
//
selectAppFile(files) {
this.attachmentForm.file = files[0]
},
//
deleteAttachment() {
this.attachmentForm.file = {}
}
}
}
</script>
<style lang="scss">
@import '../icons/iconfont.css';
.sv-editor-popup {
--editor-popup-radius: 30rpx 30rpx 0 0;
--editor-popup-bgcolor: #ffffff;
--editor-popup-confirm: #4d80f0;
--editor-popup-cancel: #fa4350;
--thumbnail-icon-bgcolor: #cccccc;
--editor-popup-header-height: 50rpx;
width: 100%;
position: absolute;
bottom: 0;
z-index: 10000;
border-radius: var(--editor-popup-radius);
padding: 30rpx;
padding-bottom: calc(30rpx + env(safe-area-inset-bottom));
background-color: var(--editor-popup-bgcolor);
box-sizing: border-box;
.editor-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
height: var(--editor-popup-header-height);
.header-left {
color: var(--editor-popup-cancel);
}
.header-right {
color: var(--editor-popup-confirm);
}
}
.sv-editor-popup-container {
box-sizing: border-box;
.popup-form-input {
display: flex;
align-items: center;
margin-bottom: 30rpx;
box-sizing: border-box;
.form-label {
margin-right: 20rpx;
flex-shrink: 0;
}
.form-input {
flex: 1;
padding: 12rpx;
border: 1rpx solid #eeeeee;
border-radius: 8rpx;
line-height: unset;
height: unset;
min-height: unset;
box-sizing: border-box;
.uni-input-placeholder {
color: #dddddd;
}
}
.form-button {
margin-left: unset;
margin-right: unset;
}
.form-thumbnail {
.form-thumbnail-item {
width: 25px;
height: 25px;
margin-left: 20rpx;
position: relative;
border: 1rpx solid #eeeeee;
&:active {
border-color: #d83b01;
&::after {
content: 'X';
font-size: 25px;
font-weight: bold;
color: #d83b01;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
}
}
}
.form-thumbnail-image {
vertical-align: bottom; // image
}
.form-thumbnail-icon {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--form-thumbnail-icon-bgcolor);
}
}
}
}
}
.mask {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
}
</style>

View File

@ -0,0 +1,150 @@
<template>
<text
:eid="eid"
:change:eid="quillEditor.watchEID"
:mode="inputmode"
:change:mode="quillEditor.watchInputMode"
:focus="focusFlag"
:change:focus="quillEditor.watchFocus"
:backspace="backspaceFlag"
:change:backspace="quillEditor.watchBackSpace"
></text>
</template>
<script>
/**
* 富文本renderjs扩展
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
export default {
props: {
eid: {
type: String,
default: ''
}
},
data() {
return {
inputmode: '', // none | remove
focusFlag: 0, //
backspaceFlag: 0 //
}
},
methods: {
changeInputMode(mode) {
this.inputmode = mode
},
focus() {
this.focusFlag++
},
backspace() {
this.backspaceFlag++
}
}
}
</script>
<script module="quillEditor" lang="renderjs">
export default {
data() {
return {
editorID: ''
}
},
methods: {
watchEID(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.editorID = newValue
}
},
watchInputMode(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeQuillInputMode(newValue)
}
},
watchFocus(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeFocus(newValue)
}
},
watchBackSpace(newValue, oldValue, ownerInstance, instance) {
if (newValue) {
this.changeBackSpace(newValue)
}
},
/**
* 通过增加或移出inputmode属性来控制是否允许键盘弹出
* @param {String} type none | remove
* @tutorial https://ask.dcloud.net.cn/article/39915
*/
changeQuillInputMode(type) {
try {
// inputmodenone
// inputmode
const el = document.querySelector(`#${this.editorID} .ql-editor`);
if(!el) return console.warn('==== quill dom error ====');
if(type == 'none') el.setAttribute('inputmode', 'none')
if(type == 'remove') el.removeAttribute('inputmode')
} catch (err) {
console.warn('==== changeQuillInputMode catch error :', err);
}
},
/**
* 通过quill节点实例的focus方法来主动触发编辑器聚焦
*/
changeFocus() {
try {
const el = document.querySelector(`#${this.editorID} .ql-editor`);
if(!el) return console.warn('==== quill dom error ====');
el.focus()
} catch (err) {
console.warn('==== changeFocus catch error :', err);
}
},
/**
* 通过quill节点实例的deleteText方法来主动触发编辑器删除
*/
changeBackSpace() {
try {
const el = document.querySelector(`#${this.editorID}`);
const quill = Quill.find(el);
if(!el || !quill) return console.warn('==== quill dom error ====');
const range = quill.getSelection(); //
if (range && range.length === 0) {
// emoji
if (range.index > 0) {
//
const text = quill.getText(0, range.index);
// Unicode emoji
const normalizedText = text.normalize('NFC');
//
const chars = Array.from(normalizedText);
//
const lastCharIndex = chars.length - 1;
if (lastCharIndex >= 0) {
//
const lastChar = chars[lastCharIndex];
const lastCharLength = text.slice(-lastChar.length).length;
quill.deleteText(range.index - lastCharLength, lastCharLength);
quill.setSelection(range.index - lastCharLength); //
}
}
} else if (range && range.length > 0) {
//
quill.deleteText(range.index, range.length);
quill.setSelection(range.index); //
}
} catch (err) {
console.warn('==== changeBackSpace catch error :', err);
}
},
}
}
</script>

View File

@ -0,0 +1,647 @@
<template>
<view class="sv-editor-toolbar">
<view class="editor-tools" @tap="onTool">
<text
v-for="item in allTools"
:key="item.title"
class="iconfont"
:class="item.icon"
:data-name="item.name"
></text>
<!-- [展开/折叠] 为固定工具 -->
<text v-if="isShowPanel" class="iconfont icon-xiajiantou" data-name="fold" data-value="0"></text>
<text v-else class="iconfont icon-shangjiantou" data-name="fold" data-value="1"></text>
</view>
<!-- 样式面板 不建议使用 :key="item.name" 因为 name 可能重复 -->
<view class="tool-panel" v-if="curTool == 'style' && isShowPanel">
<view class="panel-grid panel-style">
<view
class="panel-style-item"
:class="[(item.value ? formats[item.name] === item.value : formats[item.name]) ? 'ql-active' : '']"
:style="{ color: item.name == 'color' ? curTextColor : item.name == 'backgroundColor' ? curBgColor : '' }"
v-for="item in allStyleTools"
:key="item.title"
:title="item.title"
:data-name="item.name"
:data-value="item.value"
@tap="onToolStyleItem"
>
<text class="iconfont pointer-events-none" :class="item.icon"></text>
<text class="tool-item-title pointer-events-none">{{ item.title }}</text>
</view>
</view>
</view>
<!-- 表情面板 -->
<view class="tool-panel" v-if="curTool == 'emoji' && isShowPanel">
<view class="panel-grid panel-emoji">
<view
class="panel-emoji-item"
v-for="item in allEmojiTools"
:key="item"
:data-name="item"
@tap="onToolEmojiItem"
>
{{ item }}
</view>
</view>
<!-- #ifdef H5 -->
<view class="editor-backspace iconfont icon-tuige" @click="onBackSpace"></view>
<!-- #endif -->
<!-- #ifdef APP -->
<view v-if="!isIOS" class="editor-backspace iconfont icon-tuige" @click="onBackSpace"></view>
<!-- #endif -->
</view>
<!-- 更多功能面板 -->
<view class="tool-panel" v-if="curTool == 'more' && isShowPanel">
<view class="panel-grid panel-more">
<view
class="panel-more-item"
v-for="item in allMoreTools"
:key="item.title"
:title="item.title"
:data-name="item.name"
:data-value="item.value"
@tap="onToolMoreItem"
>
<view class="iconfont pointer-events-none" :class="item.icon"></view>
<view class="panel-more-item-title pointer-events-none">{{ item.title }}</view>
</view>
</view>
</view>
<!-- 扩展面板 -->
<view class="tool-panel" v-if="curTool == 'setting' && isShowPanel">
<slot name="setting"></slot>
</view>
<!-- 弹窗 因vue2/3的v-model写法有区别故需要条件编译我也是醉了 -->
<!-- #ifdef VUE3 -->
<sv-editor-popup-more v-model:show="showMorePop" :tool-name="curMoreTool" @confirm="moreItemConfirm">
<!-- APP端不支持循环插槽此处建议挨个写 -->
<!-- <template v-for="(slot, name) in $slots" #[name]="scope">
<slot :name="name" v-bind="scope"></slot>
</template> -->
<template #at>
<slot name="at"></slot>
</template>
<template #topic>
<slot name="topic"></slot>
</template>
</sv-editor-popup-more>
<!-- #endif -->
<!-- 弹窗 特别是微信小程序端的vue2必须使用.sync -->
<!-- #ifdef VUE2 -->
<sv-editor-popup-more :show.sync="showMorePop" :tool-name="curMoreTool" @confirm="moreItemConfirm">
<template #at>
<slot name="at"></slot>
</template>
<template #topic>
<slot name="topic"></slot>
</template>
</sv-editor-popup-more>
<!-- #endif -->
<!-- 调色板 -->
<!-- #ifdef VUE3 -->
<sv-editor-colorpicker
v-model:show="showColorPicker"
:type="colorType"
:color="curColor"
@confirm="selectColor"
></sv-editor-colorpicker>
<!-- #endif -->
<!-- #ifdef VUE2 -->
<sv-editor-colorpicker
:show.sync="showColorPicker"
:type="colorType"
:color="curColor"
@confirm="selectColor"
></sv-editor-colorpicker>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor 默认工具栏
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { toolList, emojiToolList, styleToolList, moreToolList } from '../common/tool-list.js'
import { noKeyboardEffect } from '../common/utils.js'
import SvEditorPopupMore from './sv-editor-popup-more.vue'
import SvEditorColorpicker from './sv-editor-colorpicker.vue'
export default {
// #ifdef MP-WEIXIN
//
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorPopupMore,
SvEditorColorpicker
},
props: {
//
tools: {
type: Array,
default: () => {
return [] //
}
},
//
styleTools: {
type: Array,
default: () => {
return [] //
}
},
//
moreTools: {
type: Array,
default: () => {
return [] //
}
}
},
emits: [
'toolMoreItem',
'moreItemConfirm',
'keyboardChange',
'changeMorePop',
'changeTool',
'tapTool',
'tapStyle',
'tapEmoji',
'backspace'
],
data() {
return {
curTool: '', //
showPanel: false, // isShowPanel
showMorePop: false, //
showColorPicker: false, //
curColor: '', //
curTextColor: '', //
curBgColor: '', //
colorType: '', // color | backgroundColor
curMoreTool: '', //
keyboardHeight: 0 //
}
},
computed: {
isIOS() {
return uni.getSystemInfoSync().platform == 'ios'
},
allTools() {
if (this.tools.length == 0) return toolList
const indexMap = new Map(this.tools.map((item, index) => [item, index]))
const filtered = toolList
.filter((item) => indexMap.has(item.name)) //
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) //
return filtered
},
allStyleTools() {
if (this.styleTools.length == 0) return styleToolList
const indexMap = new Map(this.styleTools.map((item, index) => [item, index]))
const filtered = styleToolList
.filter((item) => indexMap.has(item.name)) //
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) //
return filtered
},
allEmojiTools() {
return emojiToolList
},
allMoreTools() {
if (this.moreTools.length == 0) return moreToolList
const indexMap = new Map(this.moreTools.map((item, index) => [item, index]))
const filtered = moreToolList
.filter((item) => indexMap.has(item.name)) //
.sort((a, b) => indexMap.get(a.name) - indexMap.get(b.name)) //
return filtered
},
/**
* 在微信小程序端的vue2环境下无法直接使用计算属性读取editorCtx
* 为了统一化只在各自需要使用编辑器实例的地方按需重新获取
*/
// editorCtx() {
// const eid = store.actions.getEID()
// return store.actions.getEditor(eid)
// },
formats() {
return store.actions.getFormats()
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
},
isShowPanel() {
let show = this.showPanel
/**
* 规则
* 1. 当键盘弹出时必须折叠面板
* 2. 当点击有面板的工具栏时必须展开面板
* 3. 展开工具栏时可以点击fold进行展开折叠切换
*/
if (this.keyboardHeight !== 0) {
show = this.showMorePop ? true : false
} else {
if (!this.curTool) {
show = false
}
}
return show
}
},
watch: {
curTool(newVal) {
this.$emit('changeTool', newVal)
}
},
mounted() {
this.curTool = this.allTools[0].name //
uni.$on('E_EDITOR_STATUSCHANGE', (e) => {
this.curTextColor = e.detail.color || ''
this.curBgColor = e.detail.backgroundColor || ''
})
// #ifndef H5
uni.onKeyboardHeightChange(this.keyboardChange)
// #endif
},
destroyed() {
// #ifndef H5
uni.offKeyboardHeightChange(this.keyboardChange)
// #endif
uni.$off('E_EDITOR_STATUSCHANGE')
},
unmounted() {
// #ifndef H5
uni.offKeyboardHeightChange(this.keyboardChange)
// #endif
uni.$off('E_EDITOR_STATUSCHANGE')
},
methods: {
getEditorCtx() {
const eid = store.actions.getEID()
return store.actions.getEditor(eid)
},
onTool(e) {
this.editorCtx = this.getEditorCtx() //
if (!this.editorCtx) return console.warn('editor is null')
const { name, value } = e.target.dataset
this.$emit('tapTool', { name, value })
switch (name) {
case 'style':
case 'emoji':
case 'more':
case 'setting':
this.curTool = name
this.showPanel = true
break
case 'undo':
noKeyboardEffect(() => {
this.editorCtx.undo()
})
break
case 'redo':
noKeyboardEffect(() => {
this.editorCtx.redo()
})
break
case 'fold':
this.showPanel = value == '1' ? true : false
break
}
// toolbar
// #ifdef H5
noKeyboardEffect(() => {
this.editorCtx.focus()
})
// #endif
// #ifdef APP
if (!this.isIOS) {
noKeyboardEffect(() => {
this.editorCtx.focus()
})
}
// #endif
},
onToolStyleItem(e) {
const { name, value } = e.target.dataset
this.$emit('tapStyle', { name, value })
this.editorCtx = this.getEditorCtx() //
switch (name) {
case 'divider':
// 线使insertDivider
noKeyboardEffect(() => {
this.editorCtx.insertDivider()
})
break
case 'color':
this.colorType = name
this.curColor = this.curTextColor
this.showColorPicker = true
break
case 'backgroundColor':
this.colorType = name
this.curColor = this.curBgColor
this.showColorPicker = true
break
case 'removeformat':
//
uni.showModal({
title: '系统提示',
content: '是否清除当前选区样式',
success: ({ confirm }) => {
if (confirm) {
noKeyboardEffect(() => {
this.editorCtx.removeFormat()
})
}
}
})
break
case 'bold':
case 'italic':
case 'underline':
case 'strike':
case 'script':
// 使
this.editorCtx.format(name, value)
break
default:
noKeyboardEffect(() => {
this.editorCtx.format(name, value)
})
break
}
},
onToolEmojiItem(e) {
const { name, value } = e.target.dataset
this.$emit('tapEmoji', { name, value })
this.editorCtx = this.getEditorCtx() //
noKeyboardEffect(() => {
this.editorCtx.insertText({
text: name
})
})
},
onToolMoreItem(e) {
const { name, value } = e.target.dataset
this.curMoreTool = name
if (value == 'popup') this.openMorePop()
this.$emit('toolMoreItem', { name, value })
},
moreItemConfirm(e) {
this.$emit('moreItemConfirm', e)
},
//
openMorePop() {
this.showMorePop = true
this.$emit('changeMorePop', this.showMorePop)
},
//
closeMorePop() {
this.showMorePop = false
this.$emit('changeMorePop', this.showMorePop)
},
/**
* 键盘相关方法
*/
keyboardChange(e) {
this.keyboardHeight = e.height
this.$emit('keyboardChange', e)
if (this.showMorePop) return
// #ifdef H5
if (this.keyboardHeight > 0) {
this.showPanel = false
}
// #endif
// ,
const timerHandler = () => {
if (this.timer) {
//
clearTimeout(this.timer)
this.timer = null
}
this.timer = setTimeout(() => {
if (this.keyboardHeight > 0) {
this.showPanel = false
}
this.timer = null
}, 50)
}
// #ifdef APP
if (this.isIOS) {
timerHandler()
} else {
if (this.keyboardHeight > 0) {
this.showPanel = false
}
}
// #endif
// #ifdef MP-WEIXIN
timerHandler()
// #endif
},
// 退
onBackSpace() {
this.$emit('backspace')
// #ifdef H5 || APP
this.editorCtx = this.getEditorCtx() //
noKeyboardEffect(() => {
this.editorCtx.backspace()
})
// #endif
},
//
selectColor(color, type) {
this.curColor = color
this.showColorPicker = false
if (type == 'color') {
this.curTextColor = color
} else {
this.curBgColor = color
}
// noKeyboardEffect
this.editorCtx = this.getEditorCtx() //
this.editorCtx.format(type, color)
}
}
}
</script>
<style lang="scss">
@import '../icons/iconfont.css';
.sv-editor-toolbar {
--editor-toolbar-height: 88rpx;
--editor-toolbar-bgcolor: #ffffff;
--editor-toolbar-bordercolor: #eeeeee;
--editor-toolbar-iconsize: 32rpx;
--tool-panel-height: auto;
--tool-panel-bgcolor: #ffffff;
--tool-panel-max-height: 400rpx;
--tool-style-columns: 3;
--tool-style-iconsize: 32rpx;
--tool-style-titlesize: 28rpx;
--tool-emoji-columns: 8;
--tool-more-columns: 4;
--tool-more-iconsize: 60rpx;
--tool-more-titlesize: 24rpx;
--tool-item-bgcolor: #f1f1f1;
--editor-backspace-bgcolor: #ffffff;
--editor-backspace-shadow: 0 0 8px 6px rgba(0, 0, 0, 0.08);
.editor-tools {
width: 100%;
height: var(--editor-toolbar-height);
background-color: var(--editor-toolbar-bgcolor);
border-top: 1rpx solid var(--editor-toolbar-bordercolor);
border-bottom: 1rpx solid var(--editor-toolbar-bordercolor);
display: flex;
align-items: center;
justify-content: space-around;
box-sizing: border-box;
.iconfont {
width: 100%;
height: 100%;
font-size: var(--editor-toolbar-iconsize);
flex: 1;
display: flex;
justify-content: center;
align-items: center;
}
}
.tool-panel {
height: var(--tool-panel-height);
max-height: var(--tool-panel-max-height);
overflow: auto;
padding: 30rpx;
box-sizing: border-box;
// position: relative;
background-color: var(--tool-panel-bgcolor);
.editor-backspace {
width: 80rpx;
height: 60rpx;
position: absolute;
bottom: 30rpx;
right: 30rpx;
display: flex;
justify-content: center;
align-items: center;
font-size: 50rpx;
border-radius: 20rpx;
background-color: var(--editor-backspace-bgcolor);
box-shadow: var(--editor-backspace-shadow);
&:active {
opacity: 0.8;
bottom: 32rpx;
right: 32rpx;
}
}
.panel-grid {
width: 100%;
display: grid;
align-items: center; /* 垂直居中 */
justify-items: center; /* 水平居中 */
gap: 30rpx;
box-sizing: border-box;
&.panel-style {
grid-template-columns: repeat(var(--tool-style-columns), 1fr);
}
&.panel-emoji {
grid-template-columns: repeat(var(--tool-emoji-columns), 1fr);
}
&.panel-more {
grid-template-columns: repeat(var(--tool-more-columns), 1fr);
}
}
.panel-style-item {
width: 100%;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
background-color: var(--tool-item-bgcolor);
padding: 0 20rpx;
box-sizing: border-box;
.tool-item-title {
font-size: var(--tool-style-titlesize);
}
.iconfont {
font-size: var(--tool-style-iconsize);
margin-right: 10rpx;
}
}
.panel-emoji-item {
}
.panel-more-item {
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: var(--tool-item-bgcolor);
padding: 20rpx;
border-radius: 20rpx;
box-sizing: border-box;
&:active {
opacity: 0.85;
}
.iconfont {
font-size: var(--tool-more-iconsize);
}
.panel-more-item-title {
font-size: var(--tool-more-titlesize);
margin-top: 12rpx;
}
}
}
}
.ql-active {
color: #66ccff;
}
.pointer-events-none {
pointer-events: none;
}
</style>

View File

@ -0,0 +1,467 @@
<template>
<view class="sv-editor-wrapper" @longpress="eLongpress">
<slot name="header"></slot>
<editor
:id="eid"
class="sv-editor-container"
show-img-size
show-img-toolbar
show-img-resize
:placeholder="placeholder"
:read-only="isReadOnly"
@statuschange="onStatusChange"
@ready="onEditorReady"
@input="onEditorInput"
@focus="onEditorFocus"
@blur="onEditorBlur"
></editor>
<view class="maxlength-tip" v-if="maxlength > 0 && !hideMax">{{ textlength }}/{{ maxlength }}</view>
<slot name="footer"></slot>
<!-- renderjs辅助插件 -->
<!-- #ifdef APP || H5 -->
<sv-editor-render ref="editorRenderRef" :eid="editorEID"></sv-editor-render>
<sv-editor-plugin ref="editorPluginRef" :sid="startID" :eid="editorEID" @epaste="ePaste"></sv-editor-plugin>
<!-- #endif -->
</view>
</template>
<script>
/**
* sv-editor
* @author sonve
* @version 1.0.0
* @date 2024-12-04
*/
import store from '../common/store.js'
import { linkFlag, copyrightPrint } from '../common/utils.js'
import { parseHtmlWithVideo, replaceVideoWithImageRender } from '../common/parse.js'
import SvEditorRender from './sv-editor-render.vue'
import SvEditorPlugin from '../plugins/sv-editor-plugin.vue'
import wxplugin from '../plugins/sv-editor-wxplugin.js'
export default {
// #ifdef MP-WEIXIN
//
options: {
addGlobalClass: true,
virtualHost: true,
styleIsolation: 'shared'
},
// #endif
components: {
SvEditorRender,
SvEditorPlugin
},
props: {
// id便使id
eid: {
type: String,
default: 'sv-editor' //
},
placeholder: {
type: String,
default: '写点什么吧 ~'
},
//
readOnly: {
type: Boolean,
default: false
},
// <=0
maxlength: {
type: Number,
default: -1
},
//
hideMax: {
type: Boolean,
default: false
},
// text () | origin
pasteMode: {
type: String,
default: 'text'
}
},
emits: ['ready', 'input', 'statuschange', 'focus', 'blur', 'overmax', 'epaste'],
data() {
return {
textlength: 0, //
startID: '',
// #ifdef VUE2
// #ifdef MP-WEIXIN
editorIns: null // vue2
// #endif
// #endif
}
},
computed: {
editorEID: {
set(newVal) {
store.actions.setEID(newVal)
},
get() {
return store.actions.getEID()
}
},
editorCtx: {
set(newVal) {
store.actions.setEditor(newVal.eid, newVal.ctx)
// #ifdef VUE2
this.editorIns = newVal.ctx
this.editorIns.id = newVal.eid
// #endif
},
get() {
let instance = store.actions.getEditor(this.eid)
// #ifdef VUE2
instance = store.actions.getEditor(this.eid) || this.editorIns
// #endif
return instance
}
},
isReadOnly: {
set(newVal) {
store.actions.setReadOnly(newVal)
},
get() {
return store.actions.getReadOnly()
}
}
},
watch: {
readOnly(newVal) {
this.isReadOnly = newVal
}
},
mounted() {
//
if (!store.state.firstInstanceFlag) {
this.editorEID = this.eid
store.state.firstInstanceFlag = this.eid
copyrightPrint()
}
},
destroyed() {
store.actions.destroy()
},
unmounted() {
store.actions.destroy()
},
methods: {
onEditorReady() {
this.$nextTick(() => {
uni
.createSelectorQuery()
.in(this)
.select('#' + this.eid)
.context((res) => {
//
this.editorCtx = { eid: this.eid, ctx: res.context }
// api
this.bindMethods()
//
this.$emit('ready', this.editorCtx)
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
})
.exec()
})
},
/**
* 挂载实例api
*/
bindMethods() {
// ===== renderjsapi =====
// #ifdef APP || H5
/**
* 主动聚焦
* @returns {void}
*/
//this.editorCtx.focus = this.$refs.editorRenderRef.focus
/**
* 退格
* @returns {void}
*/
this.editorCtx.backspace = this.$refs.editorRenderRef.backspace
/**
* 键盘输入模式
* @param {String} type 模式可选none | remove
* @returns {void}
*/
this.editorCtx.changeInputMode = this.$refs.editorRenderRef.changeInputMode
/**
* 粘贴模式
* @param {String} type 模式可选text纯文本(默认) | origin尽可能保持原格式
* @returns {void}
*/
this.editorCtx.changePasteMode = (type) => {
// plugin
this.startID = this.eid
if (this.$refs.editorPluginRef?.changePasteMode) {
this.$refs.editorPluginRef.changePasteMode(type)
}
}
/**
* 生成视频封面图
* @param {String} url 封面图片地址
* @returns {Promise} 携带播放图标的封面图地址
*/
this.editorCtx.createCoverThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createCoverThumbnail) {
//
uni.$once(`E_EDITOR_GET_COVER_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createCoverThumbnail(url)
})
}
})
}
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createVideoThumbnail = (url) => {
return new Promise((resolve) => {
if (this.$refs.editorPluginRef?.createVideoThumbnail) {
//
uni.$once(`E_EDITOR_GET_VIDEO_THUMBNAIL_${url}`, (res) => {
resolve(res.cover)
})
setTimeout(() => {
this.$refs.editorPluginRef?.createVideoThumbnail(url)
})
}
})
}
// #endif
// ===== api =====
// #ifdef MP-WEIXIN
/**
* 生成视频封面图
* @param {String} url 视频地址
* @returns {Promise} 封面图地址
*/
this.editorCtx.createCoverThumbnail = wxplugin?.wxCreateCoverThumbnail
//this.editorCtx.createVideoThumbnail = wxplugin?.wxCreateVideoThumbnail
// #endif
// ===== api =====
/**
* 主动触发input回调事件
* @returns {void}
*/
// this.editorCtx.blur = ()=>{
// this.$refs.editorRenderRef.blur();
// };
this.editorCtx.changeInput = () => {
this.editorCtx.getContents({
success: (res) => {
this.$emit('input', { ctx: this.editorCtx, html: res.html, text: res.text })
}
})
}
this.editorCtx.scrollView=()=>{
this.editorCtx.scrollIntoView();
}
/**
* 获取最新内容
* @returns {Promise} 内容对象 { html, text... }
*/
this.editorCtx.getLastContent = async () => {
return new Promise((resolve) => {
this.editorCtx.getContents({
success: (res) => {
resolve(res)
}
})
})
}
/**
* 富文本内容初始化
* 注意微信小程序会导致聚焦滚动建议先将编辑器v-show=false待initHtml内容初始化完成后再true
* 也正是因为微信小程序端会聚焦滚动所以editorEID在初始阶段会默认保持最后一个实例eid需要手动重新聚焦
* @param {String} html 初始化的富文本
* @param {Function<Promise>} customCallback 自定义处理封面回调需要以Promise形式返回封面图片资源
* @returns {void}
*/
this.editorCtx.initHtml = async (html, customCallback) => {
console.log('打印html');
console.log(html);
let transHtml = await replaceVideoWithImageRender(html, customCallback)
console.log('transHtml')
console.log(transHtml);
// #ifdef APP || H5
this.editorCtx.changePasteMode('text') // text
// #endif
setTimeout(() => {
this.editorCtx.setContents({
html: transHtml,
success: () => {
console.log('成功了')
// input
this.editorCtx.changeInput()
// #ifdef APP || H5
if (this.pasteMode == 'origin') this.editorCtx.changePasteMode('origin')
// #endif
this.editorCtx.blur();
},
fail:(err)=>{
console.log('失败了')
uni.showToast({
title:err,
icon:'none'
})
}
})
})
}
/**
* 导出处理
* @param {String} html 要导出的富文本
* @returns {String} 处理后的富文本
*/
this.editorCtx.exportHtml = (html) => {
return parseHtmlWithVideo(html)
}
},
onEditorInput(e) {
// 使getContentshtmltextonStatusChangetoolbar
// detailreturn
if (Object.keys(e.detail).length <= 0) return
const { html, text } = e.detail
// return
if (text.indexOf(linkFlag) !== -1) return
/**
* 因为uni-editor不提供最大字符限制故需要手动进行以下特殊处理
*/
const maxlength = parseInt(this.maxlength)
const textStr = text.replace(/[ \t\r\n]/g, '')
this.textlength = textStr.length //
if (this.textlength >= maxlength && maxlength > 0) {
this.textlength = maxlength // editor
if (!this.lockHtmlFlag) {
this.lockHtml = html // html
this.lockHtmlFlag = true //
// input
this.$emit('input', { ctx: this.editorCtx, html, text })
} else {
//
this.$emit('overmax', { ctx: this.editorCtx })
}
// html
this.editorCtx.setContents({ html: this.lockHtml })
} else {
//
this.$emit('input', { ctx: this.editorCtx, html, text })
this.lockHtmlFlag = false //
}
},
/**
* 样式格式改变时触发
* 注意微信小程序端在多编辑器实例下切换编辑器后可能不会及时触发onStatusChange
*/
onStatusChange(e) {
store.actions.setFormats(e.detail)
this.$emit('statuschange', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_STATUSCHANGE', { ...e, ctx: this.editorCtx })
},
onEditorFocus(e) {
this.editorEID = this.eid
this.$emit('focus', { ...e, ctx: this.editorCtx })
},
onEditorBlur(e) {
this.$emit('blur', { ...e, ctx: this.editorCtx })
},
ePaste(e) {
this.$emit('epaste', { ...e, ctx: this.editorCtx })
uni.$emit('E_EDITOR_PASTE', { ...e, ctx: this.editorCtx })
},
/**
* 微信小程序官方editor的长按事件有bug需要重写覆盖不需做任何逻辑可见下面小程序社区问题链接
* @tutorial https://developers.weixin.qq.com/community/develop/doc/000c04b3e1c1006f660065e4f61000
*/
eLongpress() {}
}
}
</script>
<style lang="scss">
.sv-editor-wrapper {
--maxlength-text-color: #666666;
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.sv-editor-container {
flex: 1;
}
.maxlength-tip {
position: absolute;
bottom: 0;
right: 0;
font-size: 24rpx;
color: var(--maxlength-text-color);
opacity: 0.6;
}
}
// placeholder
.sv-editor-container ::v-deep .ql-blank::before {
font-style: normal;
color: #cccccc;
}
//
::v-deep .ql-container {
min-height: unset;
.ql-image-overlay {
pointer-events: none;
.ql-image-size {
right: 28px !important;
}
.ql-image-toolbar {
//
pointer-events: auto;
}
.ql-image-handle {
//
width: 30px;
height: 30px;
pointer-events: auto;
}
}
}
</style>

View File

@ -0,0 +1,87 @@
{
"id": "sv-editor",
"displayName": "基于官方 uni-editor 的富文本编辑器",
"version": "1.1.2",
"description": "可插入图片、视频、链接、@提及、#话题、Emoji表情包且优化了聚焦键盘闪烁等问题",
"keywords": [
"富文本",
"编辑器",
"editor",
"html"
],
"repository": "https://gitee.com/Sonve/sv-editor",
"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": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-vue": "y",
"app-nvue": "u",
"app-uvue": "u",
"app-harmony": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@ -0,0 +1,333 @@
## 基于官方 uni-editor 的富文本编辑器 [sv-editor]
### 一、前言
首先,你需要了解 uni-editor 相关注意事项以及api
传送门:
1. [editor 组件概况](https://uniapp.dcloud.net.cn/component/editor.html)
2. [editorContext api详情](https://uniapp.dcloud.net.cn/api/media/editor-context.html)
3. 仔细阅读 [HTML 标签和 style 内联样式支持情况](https://uniapp.dcloud.net.cn/component/editor.html#html-%E6%A0%87%E7%AD%BE%E5%92%8C-style-%E5%86%85%E8%81%94%E6%A0%B7%E5%BC%8F%E6%94%AF%E6%8C%81%E6%83%85%E5%86%B5)
4. 仔细了解 [注意事项](https://uniapp.dcloud.net.cn/component/editor.html#%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9)
### 二、本插件在官方 uni-editor 基础上做了什么
1. 提供插入视频的api
2. 提供插入链接的api
3. 在插入链接的基础上扩展了 @某人#话题#、以及 添加附件 的api
4. 支持插入emoji表情包可自定义表情包面板
5. 解决了在app端插入内容后编辑器聚焦后自动弹出键盘的问题提供api可在聚焦的同时取消键盘反复弹出带来的影响
6. 工具栏toolbar与编辑器editor分离式写法让你的代码更加自由
7. 插件内部大部分样式由css变量控制更方便你使用样式穿透去自定义对有暗黑主题的需求更加友好
8. 所有组件添加了 styleIsolation: 'shared' 配置项,再也不用怕小程序端的样式隔离穿透不了
9. 部分扩展基于renderjs因此小程序端无法使用可见下列关键功能概况详情
10. App与H5端关键扩展api如下
- noKeyboardEffect取消键盘影响不想让富文本聚焦后总是自动弹出键盘这个api可以完美解决你的问题
- focus主动聚焦你可以直接通过 editorCtx 实例调用此api以便直接主动使富文本聚焦
- backspace主动退格(删除)希望可以模拟键盘上的退格键这个api如同键盘的 backspace 键一样,删除光标前一个单位,或者删除所选区域
- 等等其他api详见下文
### 三、兼容性
✅已兼容,❌未兼容
| VUE2 | VUE3 | APP(Android) | APP(iOS)| H5 | 微信小程序 | 其他小程序 |
| :---:| :---:| :---: | :---: | :---: | :---: | :---: |
| ✅ | ✅️ | ✅ | ✅ | ✅ | ✅️️ | ❌(没测过) |
1. 实际请以真机效果为准,并不能保证所有机型都兼容,如遇到问题还请加群讨论
2. 注意因为部分api基于renderjs而小程序无法使用renderjs故部分api和功能并不适配小程序更多详情会在各api中说明
3. 特别注意:**在微信小程序中,生成的 a 标签的 href 属性会被自动抹去,因此在微信小程序中是无法点击超链接跳转的,这点目前微信小程序官方固件不支持,暂时也没啥好的办法**
### 四、关键功能概况
✅完美支持,☑可用但或有副作用,❌不支持
| 功能 | VUE2 | VUE3 | H5 | APP(Android) | APP(iOS) | 微信小程序 |
| :---: | :---:| :---:| :---:| :---: | :---: | :---: |
| 插入图片 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 插入视频 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 插入链接 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入提及 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入话题 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 插入附件 | ✅ | ✅ | ✅ | ✅ | ✅ | ☑️ |
| 主动聚焦 | ✅ | ✅ | ✅ | ✅ | ✅ | ❌️ |
| 主动退格 | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
| 多编辑器实例 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 消除键盘影响 | ✅ | ✅ | ✅ | ✅ | ☑ | ☑️ |
| 粘贴保留格式 <br/> [(*特殊扩展)](#特殊扩展) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| 粘贴事件监听 <br/> [(*特殊扩展)](#特殊扩展) | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
| 视频截取封面 | ✅ | ✅ | ✅ | ✅ | ☑️ | ☑ |
| 视频回显解析 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| 待补充 ... | | | | | | |
### 五、开始
1. 因为本插件不依赖其他第三方插件,因此直接点击右上角 `下载并导入HBuilderX` 导入至你的项目中即可
2. 强烈建议你先 `使用HBuilderX导入示例项目` ,跑一下示例看亿下先,部分写法可以直接抄示例
3. 因为本插件提供除 [editorContext](https://uniapp.dcloud.net.cn/api/media/editor-context.html) 官方api外额外扩展的api需要你对js有着基本的掌握特别是Promise和异步处理
4. 本插件仅为富文本编辑器如要解析回显还请自行寻找富文本解析插件不推荐rich-text
### 六、插件目录结构
```
uni_modules
└─ sv-editor
├─ components
│ ├─ common
│ │ ├─ config.js // 配置文件
│ │ ├─ file-handler.js // 文件处理方法
│ │ ├─ parse.js // 富文本解析工具
│ │ ├─ store.js // 插件内全局状态管理
│ │ ├─ tool-list.js // 工具栏工具列表
│ │ └─ utils.js // 通用工具api
│ ├─ icons
│ │ ├─ iconfont.css // 字体图标样式
│ │ └─ iconfont.ttf // 字体图标
│ └─ sv-editor
│ ├─ sv-choose-file.vue // 文件选择器
│ ├─ sv-editor-popup-more.vue // 更多工具弹窗面板
│ ├─ sv-editor-render.vue // renderjs组件
│ ├─ sv-editor-toolbar.vue // 内置工具栏
│ └─ sv-editor.vue // 编辑器主体
├─ changelog.md
├─ package.json
└─ readme.md
```
### 七、基本使用
#### sv-editor 编辑器主体
`符合uni_modules规范无需引入直接使用`
1. props
| 属性名 | 类型 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| eid | String | 'sv-editor' | 编辑器id唯一禁止重复多编辑器实例时必填 |
| placeholder | String | '写点什么吧 ~' | 占位字样 |
| readOnly | Boolean | false | 是否只读 |
| maxlength | Number | -1 | 最大字数限制,<=0时表示不限 |
| hideMax | Boolean | false | 是否关闭最大字数显示 |
2. emits
| 事件名 | 参数 | 说明 |兼容性 |
| :--- | :--- | :--- | :--- |
| ready | ctx 当前编辑器上下文实例 | 编辑器初始化完成时触发 | 通用 |
| input | { ctx, html, text } | 编辑器内容改变时触发 | 通用 |
| focus | { ctx, event } | 编辑器聚焦时触发 | 通用 |
| blur | { ctx, event } | 编辑器失去焦点时触发 | 通用 |
| statuschange | { ctx, event } | 通过 Context 方法改变编辑器内样式时触发,返回选区已设置的样式 | 通用 |
| overmax | { ctx } | 超过最大字数限制时回调 | 通用 |
| epaste <br/> [(*特殊扩展)](#特殊扩展) | { ctx, id, text, html, range } | 粘贴回调事件 | H5、APP |
- statuschange 事件还提供 uni.$emit('E_EDITOR_STATUSCHANGE', { ctx, event }) 抛出,你可以通过 uni.$on('E_EDITOR_STATUSCHANGE') 进行监听但是不要忘记在适当的地方off关掉
- epaste [(*特殊扩展)](#特殊扩展) 事件还提供 uni.$emit('E_EDITOR_PASTE', { ctx, id, text, html, range }) 抛出,你可以通过 uni.$on('E_EDITOR_PASTE') 进行监听但是不要忘记在适当的地方off关掉
#### sv-editor-toolbar 编辑器工具栏
`与编辑器本体分离,按需引入使用`
1. props
| 属性名 | 类型 | 默认值 | 说明 |
| :--- | :--- | :--- | :--- |
| tools | Array | [] 默认空数组即为全工具,可选 [详见 toolList](#toolList) | 工具栏列表,例如 ['style', ...] |
| styleTools| Array | [] 默认空数组即为全工具,可选 [详见 styleToolList](#styleToolList) | 样式工具列表,例如 ['header', ...] |
| moreTools | Array | [] 默认空数组即为全工具,可选 [详见 moreToolList](#moreToolList) | 更多功能列表,例如 ['image', ...] |
注意:
- 此处 toolList 等为全列表,详见 `uni_modules/sv-editor/components/common/tool-list.js` 文件。
- 若只想使用部分工具以及修改顺序则给组件对应的props属性例如 `:tools="['style', 'undo', 'redo']"` 即可只使用该三项工具且顺序以该数组顺序排序。
- 关于图标,本插件内置了 [阿里巴巴矢量图标库](https://www.iconfont.cn/) 的字体图标,如需使用其他图标,请自行替换。
2. emits
| 事件名 | 参数 | 说明 |
| :--- | :--- | :--- |
| toolMoreItem | { name, value } | 点击更多功能面板子项 |
| moreItemConfirm | { link, text, file } | 点击更多功能弹窗确认后回调 |
| keyboardChange | { height } | 键盘高度变化 |
| changeMorePop | true 打开 / false 关闭 | 更多功能弹窗打开/关闭 |
| tapTool | { name, value } | 点击工具栏 |
| changeTool | 工具name | 工具栏改变 |
| tapStyle | { name, value } | 点击样式工具 |
| tapEmoji | { name, value } | 点击Emoji表情 |
| backspace | | 触发编辑器实例主动使用backspace后回调 |
##### toolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 样式 | style | | icon-zitiyanse |
| 表情 | emoji | | icon-xiaolian |
| 撤销 | undo | | icon-shangyibu1 |
| 重做 | redo | | icon-xiayibu1 |
| 更多 | more | | icon-icon_tianjia |
| 扩展 | setting | | icon-bianji |
##### styleToolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 标题 | header | 2 | icon-zitibiaoti |
| 分割线 | divider | | icon-fengexian |
| 粗体 | bold | | icon-zitijiacu |
| 斜体 | italic | | icon-zitixieti |
| 下划线 | underline | | icon-zitixiahuaxian |
| 删除线 | strike | | icon-zitishanchuxian|
| 左对齐 | align | left | icon-zuoduiqi |
| 居中 | align | center | icon-juzhongduiqi |
| 右对齐 | align | right | icon-youduiqi |
| 有序列表 | list | ordered | icon-youxupailie |
| 无序列表 | list | bullet | icon-wuxupailie |
| 上标 | script | super | icon-zitishangbiao |
| 左缩进 | indent | +1 | icon-zuosuojin |
| 右缩进 | indent | -1 | icon-yousuojin |
| 下标 | script | sub | icon-ziti-xiabiao |
| 文字颜色 | color | | icon-wenziyanse |
| 背景颜色 | backgroundColor | | icon-beijingyanse' |
| 清除格式 | removeformat | | icon-qingchugeshi |
- 以上为插件内置样式工具,更多详见 [支持设置的样式列表](https://uniapp.dcloud.net.cn/api/media/editor-context.html#editorcontext-format)
- 缩进时需要在解析插件此处以mp-html为例中添加如下缩进样式以供识别
```
// uni_modules/mp-html/components/mp-html/node/node.vue
// 不要管插件内原始的样式代码
<style>...</style>
// 直接在该vue文件最底下添加如下scss样式
<style lang="scss">
@for $i from 1 through 10 {
.ql-indent-#{$i} {
// 默认一个缩进为2个em单位此处对应
text-indent: #{$i * 2}em;
}
}
</style>
```
##### moreToolList
| title | name | value | icon |
| :--- | :--- | :--- | :--- |
| 添加图片 | image | popup | icon-charutupian |
| 添加视频 | video | popup | icon-shexiangji |
| 添加链接 | link | popup | icon-charulianjie |
| 添加附件 | attachment| popup | icon-huixingzhen |
| 提及 | at | popup | icon-at |
| 话题 | topic | popup | icon-huati |
| 清空 | clear | button| icon-shanchu |
*在微信小程序中,生成的 a 标签的 href 属性会被自动抹去,因此在微信小程序中是无法点击超链接跳转的,这点目前微信小程序官方固件不支持,暂时也没啥好的办法*
##### emojiToolList
emoji默认列表
##### colorList
调色板默认颜色列表
#### api 合集
1. [editorContext 官方api](https://uniapp.dcloud.net.cn/api/media/editor-context.html)
2. 本插件编辑器实例 `editorCtx` 中,你可以直接通过富文本实例调用
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| focus | | | 主动聚焦 | H5、App |
| backspace | | | 退格会触发sv-editor-toolbar的backspace回调函数 | H5、App(Android) |
| createVideoThumbnail <br/> [(*特殊扩展)](#特殊扩展)| url:string 视频地址 | 封面图地址 Promise | 以视频资源地址,直接生成视频封面图(需要保证视频资源正常可以播放) | H5、App(Android) |
| createCoverThumbnail <br/> [(*特殊扩展)](#特殊扩展)| url:string 图片资源 | 封面图地址 Promise | 若后端返回视频封面但是没有播放图标,可以用此方法在图片中央叠加播放图标,用于作为视频封面 | 通用 |
| changeInputMode | type:string 模式可选none/remove | | 修改输入模式该api是取消键盘闪烁的关键none时将禁止键盘弹出remove时将移除该限制 | H5、App |
| changeInput | | | 主动触发input回调事件 | 通用 |
| getLastContent | | { html, text... } 内容对象 Promise | 获取富文本当前最新内容 | 通用 |
| exportHtml | html:string 要导出的富文本 | 处理后的富文本 String | 富文本导出若富文本携带视频则会自动解析为video标签 | 通用 |
| initHtml | html:string 初始化的富文本 <br/> customCallback 详见补充说明 | | 富文本内容初始化若富文本携带video标签将会自动进行解析转换 | 通用 |
- initHtml 在微信小程序端会导致聚焦滚动,建议先将编辑器 v-show=false待 initHtml 内容初始化完成后再 true。也正是因为微信小程序端会聚焦滚动所以 editorEID 在初始阶段会默认保持最后一个实例 eid需要手动重新聚焦
- initHtml 第二个参数 customCallback 和 api: replaceVideoWithImageRender 一致customCallback 为自定义处理封面回调自带参数为视频地址需要return封面图片资源若无有效返回则走默认封面处理建议配合后端生成视频封面以兼容各端。
3. `uni_modules/sv-editor/components/common/store.js` 文件中,插件内全局状态仓库,你可以按需引入后通过 store.state 与 store.actions 来访问变量
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| getEditor | eid | eid编辑器实例 | 获取指定eid的编辑器实例 | 通用 |
| setEditor | eid, ctx | | 设置eid编辑器实例 | 通用 |
| getEID | | 当前编辑器eid | 获取当前编辑器eid | 通用 |
| setEID | 当前编辑器eid | | 设置当前编辑器eid | 通用 |
| getFormats | | 编辑器样式格式 | 获取编辑器样式格式 | 通用 |
| setFormats | 编辑器样式格式 | | 设置编辑器样式格式 | 通用 |
| getReadOnly | | 是否只读 Boolean | 获取编辑器是否只读 | 通用 |
| setReadOnly | 是否只读 Boolean | | 设置编辑器是否只读 | 通用 |
4. `uni_modules/sv-editor/components/common/utils.js` 文件中,需要按需引入,实用工具
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| addImage | (uploadFunc必填, options) | Array/Promise 上传的文件 | 添加图片 | 通用 |
| addVideo | (uploadFunc必填, options) | Array/Promise 上传的文件 | 添加视频 | 通用 |
| addLink | (options, callback) | | 添加链接 | 通用 |
| addAttachment | (uploadFunc必填, options, callback) | Object/Promise 上传的文件 | 添加附件 | 通用 |
| addAt | (options, callback) | | 添加提及 | 通用 |
| addTopic | (options, callback) | | 添加话题 | 通用 |
| insertLink | (editorCtx必填, options, callback) | | 插入链接母本:添加链接、添加附件、添加提及、添加话题均基于此 | 通用 |
| noKeyboardEffect | (callback必填, options) | | 核心:消除键盘影响,但是微信小程序只能通过编辑器失焦的方式关闭键盘(依然会闪一下) | 通用 |
5. `uni_modules/sv-editor/components/common/parse.js` 文件中,需要按需引入,正则解析工具
| 方法名 | 参数 | 返回值 | 说明 | 兼容性 |
| :--- | :--- | :--- | :--- | :--- |
| replaceVideoWithImageRender| richText:string 要进行处理的富文本字符串 <br/> customCallback 自定义处理封面回调需要return处理后的封面图片自带参数为视频地址 | 处理结果 Promise | 带有视频的富文本逆向转换可通过customCallback回调函数自定义处理封面 | 通用 |
| parseHtmlWithVideo | richText:string 要进行处理的富文本字符串 | 处理结果 String | 将含有封面占位图形式的视频富文本转换成正常视频的富文本 | 通用 |
| parseImagesAndVideos | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有图片和视频 | 通用 |
| parseImages | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有图片 | 通用 |
| parseVideos | richText:string 要进行处理的富文本字符串 | 处理结果 Array < Object >| 解析当前富文本中所有视频 | 通用 |
6. `uni_modules/sv-editor/components/common/config.js` 配置文件
| 参数 | 说明 |
| :--- | :--- |
| video_thumbnail | 视频默认封面默认封面图可能会失效原图在示例工程根目录下static文件夹中建议自行替换 |
| video_playicon | 视频封面播放图标(默认三角播放图标) |
7. 具体使用代码案例请 `使用HBuilderX导入示例项目` 导入示例工程参考
### 八、特殊扩展
**本插件提供部分额外特殊扩展功能,具体如下:**
| 功能 | 说明 | 类型 | 兼容 |
| :--- | :--- | :--- | :--- |
| 粘贴保留格式 | 粘贴时尽可能的保留原有格式(并非完全复制) | 固有功能 | H5、APP |
| epaste | 粘贴回调事件 | 事件 | H5、APP |
| createVideoThumbnail| 以视频资源地址,直接生成视频封面图(需要保证视频资源正常可以播放) | api | H5、APP |
| createCoverThumbnail| 若后端返回视频封面但是没有播放图标,可以用此方法在图片中央叠加播放图标,用于作为视频封面 | api | 通用 |
| 待补充 ... | | | |
- createCoverThumbnail 在iOS端可能会报 `the operation is insecure` 的错这是iOS更加严格的安全策略导致的本地file://协议也会导致跨域,从而污染了画布
制作不易特殊扩展功能限时免费开放感谢支持Thanks♪(・ω・)ノ
使用方式:将插件内 backup 文件夹下的文件复制并粘贴进 plugins 文件夹下并覆盖原文件
### 九、结语
本插件免费开源(除特殊扩展外),如若借鉴源码还请注明出处,未经授权禁止转载售卖等侵犯版权行为,谢谢!
如若商用,望您可以联系作者本人,留下您的项目名,我希望能以方式此推广,谢谢!
感谢您使用本插件,如果在使用过程中遇到任何问题,欢迎在评论区留言,或在 [Gitee](https://gitee.com/Sonve/sv-editor) 上提交issue我会尽快回复您。
制作不易,还望五星好评,若能在 [Gitee](https://gitee.com/Sonve/sv-editor) 上点个 ⭐star不胜感激Thanks♪(・ω・)ノ
欢迎加群讨论Q群
① [852637893](https://qm.qq.com/cgi-bin/qm/qr?k=R7DHSqqDI4-xRCfwdUB2e3NrTytHpcVe&jump_from=webapi&authKey=2IpufavBOSPOLdncCt7EFnbmbWrUHg1c8iqNEdTzG8zCvnKb8/0aaLXF4HJzlp2R)
② [816646292](https://qm.qq.com/cgi-bin/qm/qr?k=ndZIUqx0xctbq8oDQVTiDir7AUO5jq9X&jump_from=webapi&authKey=fgk45wWObUUvig7FIuFUuM+0IFLvOJI7LMc1d4qNbWAIfehakai/ZfckYfAGLPne)

View File

@ -0,0 +1,41 @@
## 2.8.62025-03-17
1.`新增` 聊天记录模式流式输出类似chatGPT回答演示demo。
2.`新增` z-paging及其公共子组件支持`HBuilderX`代码文档提示。
3.`新增` props`virtual-in-swiper-slot`用以解决vue3+(微信小程序或QQ小程序)中使用非内置列表写法时若z-paging在`swiper-item`中存在的无法获取slot插入的cell高度进而导致虚拟列表失败的问题。
4.`新增` `@scrolltolower`和@`scrolltoupper`支持nvue。
5.`修复``v2.8.1`引出的方法`scrollIntoViewById`在微信小程序+vue3中无效的问题。
6.`修复``v2.8.1`引出的在子组件内使用z-paging虚拟列表无效的问题。
7.`修复` 在微信小程序中基础库版本较高时`wx.getSystemInfoSync is deprecated`警告。
8.`优化` 提升下拉刷新在鸿蒙Next中的性能。
9.`优化` `@scrolltolower``@scrolltoupper`在倒置的聊天记录模式下的触发逻辑。
10.`优化` 其他细节调整。
## 2.8.52025-02-09
1.`新增` 方法`scrollToX`支持控制x轴滚动到指定位置。
2.`修复` 快手小程序中报错`await isn't allowed in non-async function`的问题。
3.`修复` 在iOS+nvue中设置了`:loading-more-enabled="false"`后,调用`scrollToBottom`无法滚动到底部的问题。
4.`修复` 在支付宝小程序+页面滚动中,数据为空时空数据图未居中的问题。
5.`优化` fetch types修改。
## 2.8.42024-12-02
1.`修复` 在虚拟列表+vue2中顶部占位采用transformY方案在虚拟列表+vue3中顶部占位采用view占位方案。以解决在vue2+微信小程序+安卓+兼容模式中,可能出现的虚拟列表闪动的问题。
2.`修复` 在列表渲染时(尤其是在虚拟列表中)偶现的【点击加载更多】闪现的问题。
3.`优化` 统一在RefresherStatus枚举中Loading取值。
4.`优化` `defaultPageNo`&`defaultPageSize`修改为只允许number类型。
5.`优化` 提升兼容性&细节优化。
## 2.8.32024-11-27
1.`修复` `doInsertVirtualListItem`插入数据无效的问题。
2.`优化` 提升兼容性&细节优化。
## 2.8.22024-11-25
1.`优化` types中`ZPagingRef``ZPagingInstance`支持泛型。
## 2.8.12024-11-24
1.`新增` 完整的`props``slots``methods``events`的typescript types声明可在ts中获得绝佳的代码提示体验。
2.`新增` `virtual-cell-id-prefix`虚拟列表cell id的前缀适用于一个页面有多个虚拟列表的情况用以区分不同虚拟列表cell的id。
3.`修复` 在vue3+(微信小程序或QQ小程序)中,使用非内置列表写法时,若`z-paging``swiper-item`标签内的情况下存在的无法获取slot插入的cell高度的问题。
4.`修复` 在虚拟列表中分页数据小于1页时插入新数据虚拟列表未生效的问题。
5.`修复` 在虚拟列表中调用`refresh`cell的index计算不正确的问题。
6.`修复` 在快手小程序中内容较少或空数据时`z-paging`未能铺满全屏的问题。
7.`优化` `events`中的参数涉及枚举的部分统一由之前的number类型修改为string类型展示更直观涉及的events`@query`中的`from`参数;`@refresherStatusChange`中的`status`参数;`@loadingStatusChange`中的`status`参数;`slot=refresher`中的`refresherStatus`参数;`slot=chatLoading`中的`loadingMoreStatus`参数。更新版本请特别留意!
## 2.8.02024-10-21
1.`新增` 全面支持鸿蒙Next。
2.`修复` 设置了`refresher-complete-delay`在下拉刷新期间调用reload导致的无法再次下拉刷新的问题。
3.`优化` 废弃虚拟列表transformY顶部占位方案修改为空view占位。解决因使用旧方案导致的vue3中可能出现的虚拟列表闪动问题。提升虚拟列表的兼容性。

View File

@ -0,0 +1,47 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- z-paging-cell用于在nvue中使用cell包裹vue中使用view包裹 -->
<template>
<!-- #ifdef APP-NVUE -->
<cell :style="[cellStyle]" @touchstart="onTouchstart">
<slot />
</cell>
<!-- #endif -->
<!-- #ifndef APP-NVUE -->
<view :style="[cellStyle]" @touchstart="onTouchstart">
<slot />
</view>
<!-- #endif -->
</template>
<script>
/**
* z-paging-cell 组件
* @description 用于兼容 nvue vue 中的 cell 渲染因为在 nvue z-paging 内置的是 list因此列表 item 必须使用 cell 包住 vue 中不能使用 cell否则会报组件找不到的错误此子组件为了兼容这两种情况内部作了条件编译处理
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-cell
* @notice 以下为 z-paging-cell 的配置项
* @property {Object} cellStyle cell 样式默认为 {}
* @example <z-paging-cell :cellStyle="{ backgroundColor: '#f0f0f0' }"></z-paging-cell>
*/
export default {
name: "z-paging-cell",
props: {
//cellStyle
cellStyle: {
type: Object,
default: function() {
return {}
}
}
},
methods: {
onTouchstart(e) {
this.$emit('touchstart', e);
}
}
}
</script>

View File

@ -0,0 +1,209 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- 空数据占位view此组件支持easycom规范可以在项目中直接引用 -->
<template>
<view :class="{'zp-container':true,'zp-container-fixed':emptyViewFixed}" :style="[finalEmptyViewStyle]" @click="emptyViewClick">
<view class="zp-main">
<image v-if="!emptyViewImg.length" :class="{'zp-main-image-rpx':unit==='rpx','zp-main-image-px':unit==='px'}" :style="[emptyViewImgStyle]" :src="emptyImg" />
<image v-else :class="{'zp-main-image-rpx':unit==='rpx','zp-main-image-px':unit==='px'}" mode="aspectFit" :style="[emptyViewImgStyle]" :src="emptyViewImg" />
<text class="zp-main-title" :class="{'zp-main-title-rpx':unit==='rpx','zp-main-title-px':unit==='px'}" :style="[emptyViewTitleStyle]">{{emptyViewText}}</text>
<text v-if="showEmptyViewReload" :class="{'zp-main-error-btn':true,'zp-main-error-btn-rpx':unit==='rpx','zp-main-error-btn-px':unit==='px'}" :style="[emptyViewReloadStyle]" @click.stop="reloadClick">{{emptyViewReloadText}}</text>
</view>
</view>
</template>
<script>
import zStatic from '../z-paging/js/z-paging-static'
/**
* z-paging-empty-view 空数据组件
* @description 通用的 z-paging 空数据组件
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-empty-view
* @property {Boolean} emptyViewFixed 空数据图片是否铺满 z-paging默认为 false若设置为 true则为填充满 z-paging 的剩余部分
* @property {String} emptyViewText 空数据图描述文字默认为 '没有数据哦~'
* @property {String} emptyViewImg 空数据图图片默认使用 z-paging 内置的图片 (建议使用绝对路径开头不要添加 "@"请以 "/" 开头)
* @property {String} emptyViewReloadText 空数据图点击重新加载文字默认为 '重新加载'
* @property {Object} emptyViewStyle 空数据图样式可设置空数据 view top : empty-view-style="{'top':'100rpx'}" (如果空数据图不是 fixed 布局则此处是 margin-top)默认为 {}
* @property {Object} emptyViewImgStyle 空数据图 img 样式默认为 {}
* @property {Object} emptyViewTitleStyle 空数据图描述文字样式默认为 {}
* @property {Object} emptyViewReloadStyle 空数据图重新加载按钮样式默认为 {}
* @property {Boolean} showEmptyViewReload 是否显示空数据图重新加载按钮(无数据时)默认为 false
* @property {Boolean} isLoadFailed 是否是加载失败默认为 false
* @property {String} unit 空数据图中布局的单位默认为 'rpx'
* @event {Function} reload 点击了重新加载按钮
* @event {Function} viewClick 点击了空数据图 view
* @example <z-paging-empty-view empty-view-text="暂无数据" />
*/
export default {
name: "z-paging-empty-view",
data() {
return {
};
},
props: {
//
emptyViewText: {
type: String,
default: '没有数据哦~'
},
//
emptyViewImg: {
type: String,
default: ''
},
//
showEmptyViewReload: {
type: Boolean,
default: false
},
//
emptyViewReloadText: {
type: String,
default: '重新加载'
},
//
isLoadFailed: {
type: Boolean,
default: false
},
//
emptyViewStyle: {
type: Object,
default: function() {
return {}
}
},
// img
emptyViewImgStyle: {
type: Object,
default: function() {
return {}
}
},
//
emptyViewTitleStyle: {
type: Object,
default: function() {
return {}
}
},
//
emptyViewReloadStyle: {
type: Object,
default: function() {
return {}
}
},
// z-index
emptyViewZIndex: {
type: Number,
default: 9
},
// 使fixedz-paging
emptyViewFixed: {
type: Boolean,
default: true
},
// rpx
unit: {
type: String,
default: 'rpx'
}
},
computed: {
emptyImg() {
return this.isLoadFailed ? zStatic.base64Error : zStatic.base64Empty;
},
finalEmptyViewStyle(){
this.emptyViewStyle['z-index'] = this.emptyViewZIndex;
return this.emptyViewStyle;
}
},
methods: {
// reload
reloadClick() {
this.$emit('reload');
},
// view
emptyViewClick() {
this.$emit('viewClick');
}
}
}
</script>
<style scoped>
.zp-container{
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
justify-content: center;
}
.zp-container-fixed {
/* #ifndef APP-NVUE */
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
}
.zp-main{
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
padding: 50rpx 0rpx;
}
.zp-main-image-rpx {
width: 240rpx;
height: 240rpx;
}
.zp-main-image-px {
width: 120px;
height: 120px;
}
.zp-main-title {
color: #aaaaaa;
text-align: center;
}
.zp-main-title-rpx {
font-size: 28rpx;
margin-top: 10rpx;
padding: 0rpx 20rpx;
}
.zp-main-title-px {
font-size: 14px;
margin-top: 5px;
padding: 0px 10px;
}
.zp-main-error-btn {
border: solid 1px #dddddd;
color: #aaaaaa;
}
.zp-main-error-btn-rpx {
font-size: 28rpx;
padding: 8rpx 24rpx;
border-radius: 6rpx;
margin-top: 50rpx;
}
.zp-main-error-btn-px {
font-size: 14px;
padding: 4px 12px;
border-radius: 3px;
margin-top: 25px;
}
</style>

View File

@ -0,0 +1,160 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- 滑动切换选项卡swiper-item此组件支持easycom规范可以在项目中直接引用 -->
<template>
<view class="zp-swiper-item-container">
<z-paging ref="paging" :fixed="false"
:auto="false" :useVirtualList="useVirtualList" :useInnerList="useInnerList" :cellKeyName="cellKeyName" :innerListStyle="innerListStyle"
:preloadPage="preloadPage" :cellHeightMode="cellHeightMode" :virtualScrollFps="virtualScrollFps" :virtualListCol="virtualListCol"
@query="_queryList" @listChange="_updateList">
<slot />
<template #header>
<slot name="header"/>
</template>
<template #cell="{item,index}">
<slot name="cell" :item="item" :index="index"/>
</template>
<template #footer>
<slot name="footer"/>
</template>
</z-paging>
</view>
</template>
<script>
import zPaging from '../z-paging/z-paging'
/**
* z-paging-swiper-item 组件
* @description swiper+list极简写法中使用到实际上就是对普通的swiper+list中swiper-item的包装封装用以简化写法但个性化配置局限较多
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-swiper-item
* @notice 以下为 z-paging-swiper-item 的配置项
* @property {Number} tabIndex 当前组件的 index也就是当前组件是 swiper 中的第几个默认为 0
* @property {Number} currentIndex 当前 swiper 切换到第几个 index默认为 0
* @property {Boolean} useVirtualList 是否使用虚拟列表默认为 false
* @property {Boolean} useInnerList 是否在 z-paging 内部循环渲染列表内置列表默认为 false useVirtualList true则此项恒为 true
* @property {String} cellKeyName 内置列表 cell key 名称 nvue 有效 nvue 中开启 useInnerList 时必须填此项默认为 ''
* @property {Object} innerListStyle innerList 样式默认为 {}
* @property {Number|String} preloadPage 预加载的列表可视范围列表高度页数默认为 12此数值越大则虚拟列表中加载的 dom 越多内存消耗越大会维持在一个稳定值但增加预加载页面数量可缓解快速滚动短暂白屏问题
* @property {String} cellHeightMode 虚拟列表 cell 高度模式默认为 'fixed'也就是每个 cell 高度完全相同将以第一个 cell 高度为准进行计算可选值dynamic即代表高度是动态非固定的dynamic性能低于fixed
* @property {Number|String} virtualListCol 虚拟列表列数默认为 1常用于每行有多列的情况例如每行有 2 列数据需要将此值设置为 2
* @property {Number|String} virtualScrollFps 虚拟列表 scroll 取样帧率默认为 60过高可能出现卡顿等问题
* @example <z-paging-swiper-item ref="swiperItem" :tabIndex="index" :currentIndex="current" @query="queryList" @updateList="updateList"></z-paging-swiper-item>
*/
export default {
name: "z-paging-swiper-item",
components: { zPaging },
data() {
return {
firstLoaded: false
}
},
props: {
// indexswiper
tabIndex: {
type: Number,
default: function() {
return 0
}
},
// swiperindex
currentIndex: {
type: Number,
default: function() {
return 0
}
},
// 使
useVirtualList: {
type: Boolean,
default: false
},
// z-paging()use-virtual-listtruetrue
useInnerList: {
type: Boolean,
default: false
},
// cellkeynvuenvueuse-inner-list
cellKeyName: {
type: String,
default: ''
},
// innerList
innerListStyle: {
type: Object,
default: function() {
return {};
}
},
// ()1212celldom()
preloadPage: {
type: [Number, String],
default: 12
},
// cellfixedcellcelldynamicdynamicfixed
cellHeightMode: {
type: String,
default: 'fixed'
},
// 122
virtualListCol: {
type: [Number, String],
default: 1
},
// scroll60
virtualScrollFps: {
type: [Number, String],
default: 60
},
},
watch: {
currentIndex: {
handler(newVal, oldVal) {
if (newVal === this.tabIndex) {
// item
if (!this.firstLoaded) {
this.$nextTick(()=>{
let delay = 5;
// #ifdef MP-TOUTIAO
delay = 100;
// #endif
setTimeout(() => {
this.$refs.paging.reload().catch(() => {});
}, delay);
})
}
}
},
immediate: true
}
},
methods: {
reload(data) {
return this.$refs.paging.reload(data);
},
complete(data) {
this.firstLoaded = true;
return this.$refs.paging.complete(data);
},
_queryList(pageNo, pageSize, from) {
this.$emit('query', pageNo, pageSize, from);
},
_updateList(list) {
this.$emit('updateList', list);
}
}
}
</script>
<style scoped>
.zp-swiper-item-container {
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
}
</style>

View File

@ -0,0 +1,176 @@
<!-- z-paging -->
<!-- github地址:https://github.com/SmileZXLee/uni-z-paging -->
<!-- dcloud地址:https://ext.dcloud.net.cn/plugin?id=3935 -->
<!-- 反馈QQ群790460711 -->
<!-- 滑动切换选项卡swiper容器此组件支持easycom规范可以在项目中直接引用 -->
<template>
<view :class="fixed?'zp-swiper-container zp-swiper-container-fixed':'zp-swiper-container'" :style="[finalSwiperStyle]">
<!-- #ifndef APP-PLUS -->
<view v-if="cssSafeAreaInsetBottom===-1" class="zp-safe-area-inset-bottom"></view>
<!-- #endif -->
<slot v-if="zSlots.top" name="top" />
<view class="zp-swiper-super">
<view v-if="zSlots.left" :class="{'zp-swiper-left':true,'zp-absoulte':isOldWebView}">
<slot name="left" />
</view>
<view :class="{'zp-swiper':true,'zp-absoulte':isOldWebView}" :style="[swiperContentStyle]">
<slot />
</view>
<view v-if="zSlots.right" :class="{'zp-swiper-right':true,'zp-absoulte zp-right':isOldWebView}">
<slot name="right" />
</view>
</view>
<slot v-if="zSlots.bottom" name="bottom" />
</view>
</template>
<script>
import commonLayoutModule from '../z-paging/js/modules/common-layout'
/**
* z-paging-swiper 组件
* @description swiper 中使用 z-paging 左右滑动切换列表在根节点使用 z-paging-swiper其相当于一个 view 容器默认铺满全屏可免计算高度直接插入 swiper 的视图容器
* @tutorial https://z-paging.zxlee.cn/api/sub-components/main.html#z-paging-swiper
* @property {Boolean} fixed 是否使用 fixed 布局默认为 true
* @property {Boolean} safeAreaInsetBottom 是否开启底部安全区域适配默认为 false
* @property {Object} swiperStyle z-paging-swiper 样式默认为 {}
* @example <z-paging-swiper :safeAreaInsetBottom="true"></z-paging-swiper>
*/
export default {
name: "z-paging-swiper",
mixins: [commonLayoutModule],
data() {
return {
swiperContentStyle: {}
};
},
props: {
// 使fixed
fixed: {
type: Boolean,
default: true
},
//
safeAreaInsetBottom: {
type: Boolean,
default: false
},
// z-paging-swiper
swiperStyle: {
type: Object,
default: function() {
return {};
},
}
},
mounted() {
this.$nextTick(() => {
this.systemInfo = this._getSystemInfoSync();
setTimeout(this.updateFixedLayout, 100)
})
// #ifndef APP-PLUS
this._getCssSafeAreaInsetBottom();
// #endif
this.updateLeftAndRightWidth();
this.swiperContentStyle = { 'flex': '1' };
// #ifndef APP-NVUE
this.swiperContentStyle = { width: '100%',height: '100%' };
// #endif
},
computed: {
finalSwiperStyle() {
const swiperStyle = { ...this.swiperStyle };
if (!this.systemInfo) return swiperStyle;
const windowTop = this.windowTop;
const windowBottom = this.systemInfo.windowBottom;
if (this.fixed) {
if (windowTop && !swiperStyle.top) {
swiperStyle.top = windowTop + 'px';
}
if (!swiperStyle.bottom) {
let bottom = windowBottom || 0;
bottom += this.safeAreaInsetBottom ? this.safeAreaBottom : 0;
if (bottom > 0) {
swiperStyle.bottom = bottom + 'px';
}
}
}
return swiperStyle;
}
},
methods: {
// slot="left"slot="right"slot="left"slot="right"
updateLeftAndRightWidth() {
if (!this.isOldWebView) return;
this.$nextTick(() => this._updateLeftAndRightWidth(this.swiperContentStyle, 'zp-swiper'));
}
}
}
</script>
<style scoped>
.zp-swiper-container {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
flex: 1;
}
.zp-swiper-container-fixed {
position: fixed;
/* #ifndef APP-NVUE */
height: auto;
width: auto;
/* #endif */
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.zp-safe-area-inset-bottom {
position: absolute;
/* #ifndef APP-PLUS */
height: env(safe-area-inset-bottom);
/* #endif */
}
.zp-swiper-super {
flex: 1;
overflow: hidden;
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
}
.zp-swiper-left,.zp-swiper-right{
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
}
.zp-swiper {
flex: 1;
/* #ifndef APP-NVUE */
height: 100%;
width: 100%;
/* #endif */
}
.zp-absoulte {
/* #ifndef APP-NVUE */
position: absolute;
top: 0;
width: auto;
/* #endif */
}
.zp-swiper-item {
height: 100%;
}
</style>

View File

@ -0,0 +1,182 @@
<!-- [z-paging]上拉加载更多view -->
<template>
<view class="zp-l-container" :class="{'zp-l-container-rpx':c.unit==='rpx','zp-l-container-px':c.unit==='px'}" :style="[c.customStyle]" @click="doClick">
<template v-if="!c.hideContent">
<!-- 底部加载更多没有更多数据分割线 -->
<text v-if="c.showNoMoreLine&&finalStatus===M.NoMore" :class="{'zp-l-line-rpx':c.unit==='rpx','zp-l-line-px':c.unit==='px'}" :style="[{backgroundColor:zTheme.line[ts]},c.noMoreLineCustomStyle]" />
<!-- 底部加载更多loading -->
<!-- #ifndef APP-NVUE -->
<image v-if="finalStatus===M.Loading&&!!c.loadingIconCustomImage"
:src="c.loadingIconCustomImage" :style="[c.iconCustomStyle]" :class="{'zp-l-line-loading-custom-image':true,'zp-l-line-loading-custom-image-animated':c.loadingAnimated,'zp-l-line-loading-custom-image-rpx':c.unit==='rpx','zp-l-line-loading-custom-image-px':c.unit==='px'}" />
<image v-if="finalStatus===M.Loading&&finalLoadingIconType==='flower'&&!c.loadingIconCustomImage.length"
:class="{'zp-line-loading-image':true,'zp-line-loading-image-rpx':c.unit==='rpx','zp-line-loading-image-px':c.unit==='px'}" :style="[c.iconCustomStyle]" :src="zTheme.flower[ts]" />
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<!-- 在nvue中底部加载更多loading使用系统自带的 -->
<view>
<loading-indicator v-if="finalStatus===M.Loading&&finalLoadingIconType!=='circle'" :class="{'zp-line-loading-image-rpx':c.unit==='rpx','zp-line-loading-image-px':c.unit==='px'}" :style="[{color:zTheme.indicator[ts]}]" :animating="true" />
</view>
<!-- #endif -->
<!-- 底部加载更多文字 -->
<text v-if="finalStatus===M.Loading&&finalLoadingIconType==='circle'&&!c.loadingIconCustomImage.length"
class="zp-l-circle-loading-view" :class="{'zp-l-circle-loading-view-rpx':c.unit==='rpx','zp-l-circle-loading-view-px':c.unit==='px'}" :style="[{borderColor:zTheme.circleBorder[ts],borderTopColor:zTheme.circleBorderTop[ts]},c.iconCustomStyle]" />
<text v-if="!c.isChat||(!c.chatDefaultAsLoading&&finalStatus===M.Default)||finalStatus===M.Fail" :class="{'zp-l-text-rpx':c.unit==='rpx','zp-l-text-px':c.unit==='px'}" :style="[{color:zTheme.title[ts]},c.titleCustomStyle]">{{ownLoadingMoreText}}</text>
<!-- 底部加载更多没有更多数据分割线 -->
<text v-if="c.showNoMoreLine&&finalStatus===M.NoMore" :class="{'zp-l-line-rpx':c.unit==='rpx','zp-l-line-px':c.unit==='px'}" :style="[{backgroundColor:zTheme.line[ts]},c.noMoreLineCustomStyle]" />
</template>
</view>
</template>
<script>
import zStatic from '../js/z-paging-static'
import Enum from '../js/z-paging-enum'
export default {
name: 'z-paging-load-more',
data() {
return {
M: Enum.More,
zTheme: {
title: { white: '#efefef', black: '#a4a4a4' },
line: { white: '#efefef', black: '#eeeeee' },
circleBorder: { white: '#aaaaaa', black: '#c8c8c8' },
circleBorderTop: { white: '#ffffff', black: '#444444' },
flower: { white: zStatic.base64FlowerWhite, black: zStatic.base64Flower },
indicator: { white: '#eeeeee', black: '#777777' }
}
};
},
props: ['zConfig'],
computed: {
ts() {
return this.c.defaultThemeStyle;
},
//
c() {
return this.zConfig || {};
},
//
ownLoadingMoreText() {
return {
[this.M.Default]: this.c.defaultText,
[this.M.Loading]: this.c.loadingText,
[this.M.NoMore]: this.c.noMoreText,
[this.M.Fail]: this.c.failText,
}[this.finalStatus];
},
//
finalStatus() {
if (this.c.defaultAsLoading && this.c.status === this.M.Default) return this.M.Loading;
return this.c.status;
},
// icon
finalLoadingIconType() {
// #ifdef APP-NVUE
return 'flower';
// #endif
return this.c.loadingIconType;
}
},
methods: {
//
doClick() {
this.$emit('doClick');
}
}
}
</script>
<style scoped>
@import "../css/z-paging-static.css";
.zp-l-container {
/* #ifndef APP-NVUE */
clear: both;
display: flex;
/* #endif */
flex-direction: row;
align-items: center;
justify-content: center;
}
.zp-l-container-rpx {
height: 80rpx;
font-size: 27rpx;
}
.zp-l-container-px {
height: 40px;
font-size: 14px;
}
.zp-l-line-loading-custom-image {
color: #a4a4a4;
}
.zp-l-line-loading-custom-image-rpx {
margin-right: 8rpx;
width: 28rpx;
height: 28rpx;
}
.zp-l-line-loading-custom-image-px {
margin-right: 4px;
width: 14px;
height: 14px;
}
.zp-l-line-loading-custom-image-animated{
/* #ifndef APP-NVUE */
animation: loading-circle 1s linear infinite;
/* #endif */
}
.zp-l-circle-loading-view {
border: 3rpx solid #dddddd;
border-radius: 50%;
/* #ifndef APP-NVUE */
animation: loading-circle 1s linear infinite;
/* #endif */
/* #ifdef APP-NVUE */
width: 30rpx;
height: 30rpx;
/* #endif */
}
.zp-l-circle-loading-view-rpx {
margin-right: 8rpx;
width: 23rpx;
height: 23rpx;
}
.zp-l-circle-loading-view-px {
margin-right: 4px;
width: 12px;
height: 12px;
}
.zp-l-text-rpx {
font-size: 30rpx;
margin: 0rpx 6rpx;
}
.zp-l-text-px {
font-size: 15px;
margin: 0px 3px;
}
.zp-l-line-rpx {
height: 1px;
width: 100rpx;
margin: 0rpx 10rpx;
}
.zp-l-line-px {
height: 1px;
width: 50px;
margin: 0rpx 5px;
}
/* #ifndef APP-NVUE */
@keyframes loading-circle {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
/* #endif */
</style>

View File

@ -0,0 +1,214 @@
<!-- [z-paging]下拉刷新view -->
<template>
<view style="height: 100%;">
<view :class="showUpdateTime?'zp-r-container zp-r-container-padding':'zp-r-container'">
<view class="zp-r-left">
<!-- 非加载中(继续下拉刷新松手立即刷新状态图片) -->
<image v-if="status!==R.Loading" :class="leftImageClass" :style="[leftImageStyle,imgStyle]" :src="leftImageSrc" />
<!-- 加载状态图片 -->
<!-- #ifndef APP-NVUE -->
<image v-else :class="{'zp-line-loading-image':refreshingAnimated,'zp-r-left-image':true,'zp-r-left-image-pre-size-rpx':unit==='rpx','zp-r-left-image-pre-size-px':unit==='px'}" :style="[leftImageStyle,imgStyle]" :src="leftImageSrc" />
<!-- #endif -->
<!-- 在nvue中加载状态loading使用系统loading -->
<!-- #ifdef APP-NVUE -->
<view v-else :style="[{'margin-right':showUpdateTime?addUnit(18,unit):addUnit(12, unit)}]">
<loading-indicator :class="isIos?{'zp-loading-image-ios-rpx':unit==='rpx','zp-loading-image-ios-px':unit==='px'}:{'zp-loading-image-android-rpx':unit==='rpx','zp-loading-image-android-px':unit==='px'}"
:style="[{color:zTheme.indicator[ts]},imgStyle]" :animating="true" />
</view>
<!-- #endif -->
</view>
<!-- 右侧文字内容 -->
<view class="zp-r-right">
<!-- 右侧下拉刷新状态文字 -->
<text class="zp-r-right-text" :style="[rightTextStyle,titleStyle]">{{currentTitle}}</text>
<!-- 右侧下拉刷新时间文字 -->
<text v-if="showUpdateTime&&refresherTimeText.length" class="zp-r-right-text" :class="{'zp-r-right-time-text-rpx':unit==='rpx','zp-r-right-time-text-px':unit==='px'}" :style="[{color:zTheme.title[ts]},updateTimeStyle]">
{{refresherTimeText}}
</text>
</view>
</view>
</view>
</template>
<script>
import zStatic from '../js/z-paging-static'
import u from '../js/z-paging-utils'
import Enum from '../js/z-paging-enum'
export default {
name: 'z-paging-refresh',
data() {
return {
R: Enum.Refresher,
refresherTimeText: '',
zTheme: {
title: { white: '#efefef', black: '#555555' },
arrow: { white: zStatic.base64ArrowWhite, black: zStatic.base64Arrow },
flower: { white: zStatic.base64FlowerWhite, black: zStatic.base64Flower },
success: { white: zStatic.base64SuccessWhite, black: zStatic.base64Success },
indicator: { white: '#eeeeee', black: '#777777' }
}
};
},
props: ['status', 'defaultThemeStyle', 'defaultText', 'pullingText', 'refreshingText', 'completeText', 'goF2Text', 'defaultImg', 'pullingImg',
'refreshingImg', 'completeImg', 'refreshingAnimated', 'showUpdateTime', 'updateTimeKey', 'imgStyle', 'titleStyle', 'updateTimeStyle', 'updateTimeTextMap', 'unit', 'isIos'
],
computed: {
ts() {
return this.defaultThemeStyle;
},
// Map
statusTextMap() {
this.updateTime();
const { R, defaultText, pullingText, refreshingText, completeText, goF2Text } = this;
return {
[R.Default]: defaultText,
[R.ReleaseToRefresh]: pullingText,
[R.Loading]: refreshingText,
[R.Complete]: completeText,
[R.GoF2]: goF2Text,
};
},
//
currentTitle() {
return this.statusTextMap[this.status] || this.defaultText;
},
// class
leftImageClass() {
const preSizeClass = `zp-r-left-image-pre-size-${this.unit}`;
if (this.status === this.R.Complete) return preSizeClass;
return `zp-r-left-image ${preSizeClass} ${this.status === this.R.Default ? 'zp-r-arrow-down' : 'zp-r-arrow-top'}`;
},
// style
leftImageStyle() {
const showUpdateTime = this.showUpdateTime;
const size = showUpdateTime ? u.addUnit(36, this.unit) : u.addUnit(34, this.unit);
return {width: size,height: size,'margin-right': showUpdateTime ? u.addUnit(20, this.unit) : u.addUnit(9, this.unit)};
},
// src
leftImageSrc() {
const R = this.R;
const status = this.status;
if (status === R.Default) {
if (!!this.defaultImg) return this.defaultImg;
return this.zTheme.arrow[this.ts];
} else if (status === R.ReleaseToRefresh) {
if (!!this.pullingImg) return this.pullingImg;
if (!!this.defaultImg) return this.defaultImg;
return this.zTheme.arrow[this.ts];
} else if (status === R.Loading) {
if (!!this.refreshingImg) return this.refreshingImg;
return this.zTheme.flower[this.ts];;
} else if (status === R.Complete) {
if (!!this.completeImg) return this.completeImg;
return this.zTheme.success[this.ts];;
} else if (status === R.GoF2) {
return this.zTheme.arrow[this.ts];
}
return '';
},
// style
rightTextStyle() {
let stl = {};
// #ifdef APP-NVUE
const textHeight = this.showUpdateTime ? u.addUnit(40, this.unit) : u.addUnit(80, this.unit);
stl = {'height': textHeight, 'line-height': textHeight}
// #endif
stl['color'] = this.zTheme.title[this.ts];
stl['font-size'] = u.addUnit(30, this.unit);
return stl;
}
},
methods: {
//
addUnit(value, unit) {
return u.addUnit(value, unit);
},
//
updateTime() {
if (this.showUpdateTime) {
this.refresherTimeText = u.getRefesrherFormatTimeByKey(this.updateTimeKey, this.updateTimeTextMap);
}
}
}
}
</script>
<style scoped>
@import "../css/z-paging-static.css";
.zp-r-container {
/* #ifndef APP-NVUE */
display: flex;
height: 100%;
/* #endif */
flex-direction: row;
justify-content: center;
align-items: center;
}
.zp-r-container-padding {
/* #ifdef APP-NVUE */
padding: 7px 0rpx;
/* #endif */
}
.zp-r-left {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
align-items: center;
overflow: hidden;
/* #ifdef MP-ALIPAY */
margin-top: -4rpx;
/* #endif */
}
.zp-r-left-image {
transition-duration: .2s;
transition-property: transform;
color: #666666;
}
.zp-r-left-image-pre-size-rpx {
/* #ifndef APP-NVUE */
width: 34rpx;
height: 34rpx;
overflow: hidden;
/* #endif */
}
.zp-r-left-image-pre-size-px {
/* #ifndef APP-NVUE */
width: 17px;
height: 17px;
overflow: hidden;
/* #endif */
}
.zp-r-arrow-top {
transform: rotate(0deg);
}
.zp-r-arrow-down {
transform: rotate(180deg);
}
.zp-r-right {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
justify-content: center;
}
.zp-r-right-time-text-rpx {
margin-top: 10rpx;
font-size: 26rpx;
}
.zp-r-right-time-text-px {
margin-top: 5px;
font-size: 13px;
}
</style>

View File

@ -0,0 +1,3 @@
// z-paging全局配置文件注意避免更新时此文件被覆盖若被覆盖可在此文件中右键->点击本地历史记录,找回覆盖前的配置
export default {}

View File

@ -0,0 +1,241 @@
/* [z-paging]公共css*/
.z-paging-content {
position: relative;
flex-direction: column;
/* #ifndef APP-NVUE */
overflow: hidden;
/* #endif */
}
.z-paging-content-full {
/* #ifndef APP-NVUE */
display: flex;
width: 100%;
height: 100%;
/* #endif */
}
.z-paging-content-fixed, .zp-loading-fixed {
position: fixed;
/* #ifndef APP-NVUE */
height: auto;
width: auto;
/* #endif */
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.zp-f2-content {
width: 100%;
position: fixed;
top: 0;
left: 0;
background-color: white;
}
.zp-page-top, .zp-page-bottom {
/* #ifndef APP-NVUE */
width: auto;
/* #endif */
position: fixed;
left: 0;
right: 0;
z-index: 999;
}
.zp-page-left, .zp-page-right {
/* #ifndef APP-NVUE */
height: 100%;
/* #endif */
}
.zp-scroll-view-super {
flex: 1;
overflow: hidden;
position: relative;
}
.zp-view-super {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
}
.zp-scroll-view-container, .zp-scroll-view {
position: relative;
/* #ifndef APP-NVUE */
height: 100%;
width: 100%;
/* #endif */
}
.zp-absoulte {
/* #ifndef APP-NVUE */
position: absolute;
top: 0;
width: auto;
/* #endif */
}
.zp-scroll-view-absolute {
position: absolute;
top: 0;
left: 0;
}
/* #ifndef APP-NVUE */
.zp-scroll-view-hide-scrollbar ::-webkit-scrollbar {
display: none;
-webkit-appearance: none;
width: 0 !important;
height: 0 !important;
background: transparent;
}
/* #endif */
.zp-paging-touch-view {
width: 100%;
height: 100%;
position: relative;
}
.zp-fixed-bac-view {
position: absolute;
width: 100%;
top: 0;
left: 0;
height: 200px;
}
.zp-paging-main {
height: 100%;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
}
.zp-paging-container {
flex: 1;
position: relative;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
}
.zp-chat-record-loading-custom-image {
width: 35rpx;
height: 35rpx;
/* #ifndef APP-NVUE */
animation: loading-flower 1s linear infinite;
/* #endif */
}
.zp-page-bottom-keyboard-placeholder-animate {
transition-property: height;
transition-duration: 0.15s;
/* #ifndef APP-NVUE */
will-change: height;
/* #endif */
}
.zp-custom-refresher-container {
overflow: hidden;
}
.zp-custom-refresher-refresh {
/* #ifndef APP-NVUE */
display: block;
/* #endif */
}
.zp-back-to-top {
z-index: 999;
position: absolute;
bottom: 0rpx;
transition-duration: .3s;
transition-property: opacity;
}
.zp-back-to-top-rpx {
width: 76rpx;
height: 76rpx;
bottom: 0rpx;
right: 25rpx;
}
.zp-back-to-top-px {
width: 38px;
height: 38px;
bottom: 0px;
right: 13px;
}
.zp-back-to-top-show {
opacity: 1;
}
.zp-back-to-top-hide {
opacity: 0;
}
.zp-back-to-top-img {
/* #ifndef APP-NVUE */
width: 100%;
height: 100%;
/* #endif */
/* #ifdef APP-NVUE */
flex: 1;
/* #endif */
z-index: 999;
}
.zp-back-to-top-img-inversion {
transform: rotate(180deg);
}
.zp-empty-view {
/* #ifdef APP-NVUE */
height: 100%;
/* #endif */
flex: 1;
}
.zp-empty-view-center {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
align-items: center;
justify-content: center;
}
.zp-loading-fixed {
z-index: 9999;
}
.zp-safe-area-inset-bottom {
position: absolute;
/* #ifndef APP-PLUS */
height: env(safe-area-inset-bottom);
/* #endif */
}
.zp-n-refresh-container {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
width: 750rpx;
}
.zp-n-list-container{
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: row;
flex: 1;
}

View File

@ -0,0 +1,50 @@
/* [z-paging]公用的静态css资源 */
.zp-line-loading-image {
/* #ifndef APP-NVUE */
animation: loading-flower 1s steps(12) infinite;
/* #endif */
color: #666666;
}
.zp-line-loading-image-rpx {
margin-right: 8rpx;
width: 34rpx;
height: 34rpx;
}
.zp-line-loading-image-px {
margin-right: 4px;
width: 17px;
height: 17px;
}
.zp-loading-image-ios-rpx {
width: 40rpx;
height: 40rpx;
}
.zp-loading-image-ios-px {
width: 20px;
height: 20px;
}
.zp-loading-image-android-rpx {
width: 34rpx;
height: 34rpx;
}
.zp-loading-image-android-px {
width: 17px;
height: 17px;
}
/* #ifndef APP-NVUE */
@keyframes loading-flower {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
to {
-webkit-transform: rotate(1turn);
transform: rotate(1turn);
}
}
/* #endif */

View File

@ -0,0 +1,23 @@
{
"zp.refresher.default": "Pull down to refresh",
"zp.refresher.pulling": "Release to refresh",
"zp.refresher.refreshing": "Refreshing...",
"zp.refresher.complete": "Refresh succeeded",
"zp.refresher.f2": "Refresh to enter 2f",
"zp.loadingMore.default": "Click to load more",
"zp.loadingMore.loading": "Loading...",
"zp.loadingMore.noMore": "No more data",
"zp.loadingMore.fail": "Load failed,click to reload",
"zp.emptyView.title": "No data",
"zp.emptyView.reload": "Reload",
"zp.emptyView.error": "Sorry,load failed",
"zp.refresherUpdateTime.title": "Last update: ",
"zp.refresherUpdateTime.none": "None",
"zp.refresherUpdateTime.today": "Today",
"zp.refresherUpdateTime.yesterday": "Yesterday",
"zp.systemLoading.title": "Loading..."
}

View File

@ -0,0 +1,8 @@
import en from './en.json'
import zhHans from './zh-Hans.json'
import zhHant from './zh-Hant.json'
export default {
en,
'zh-Hans': zhHans,
'zh-Hant': zhHant
}

View File

@ -0,0 +1,23 @@
{
"zp.refresher.default": "继续下拉刷新",
"zp.refresher.pulling": "松开立即刷新",
"zp.refresher.refreshing": "正在刷新...",
"zp.refresher.complete": "刷新成功",
"zp.refresher.f2": "松手进入二楼",
"zp.loadingMore.default": "点击加载更多",
"zp.loadingMore.loading": "正在加载...",
"zp.loadingMore.noMore": "没有更多了",
"zp.loadingMore.fail": "加载失败,点击重新加载",
"zp.emptyView.title": "没有数据哦~",
"zp.emptyView.reload": "重新加载",
"zp.emptyView.error": "很抱歉,加载失败",
"zp.refresherUpdateTime.title": "最后更新:",
"zp.refresherUpdateTime.none": "无",
"zp.refresherUpdateTime.today": "今天",
"zp.refresherUpdateTime.yesterday": "昨天",
"zp.systemLoading.title": "加载中..."
}

View File

@ -0,0 +1,23 @@
{
"zp.refresher.default": "繼續下拉重繪",
"zp.refresher.pulling": "鬆開立即重繪",
"zp.refresher.refreshing": "正在重繪...",
"zp.refresher.complete": "重繪成功",
"zp.refresher.f2": "鬆手進入二樓",
"zp.loadingMore.default": "點擊加載更多",
"zp.loadingMore.loading": "正在加載...",
"zp.loadingMore.noMore": "沒有更多了",
"zp.loadingMore.fail": "加載失敗,點擊重新加載",
"zp.emptyView.title": "沒有數據哦~",
"zp.emptyView.reload": "重新加載",
"zp.emptyView.error": "很抱歉,加載失敗",
"zp.refresherUpdateTime.title": "最後更新:",
"zp.refresherUpdateTime.none": "無",
"zp.refresherUpdateTime.today": "今天",
"zp.refresherUpdateTime.yesterday": "昨天",
"zp.systemLoading.title": "加載中..."
}

View File

@ -0,0 +1,25 @@
// [z-paging]useZPaging hooks
import { onPageScroll, onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';
function useZPaging(paging) {
const cPaging = !!paging ? paging.value || paging : null;
onPullDownRefresh(() => {
if (!cPaging || !cPaging.value) return;
cPaging.value.reload().catch(() => {});
})
onPageScroll(e => {
if (!cPaging || !cPaging.value) return;
cPaging.value.updatePageScrollTop(e.scrollTop);
e.scrollTop < 10 && cPaging.value.doChatRecordLoadMore();
})
onReachBottom(() => {
if (!cPaging || !cPaging.value) return;
cPaging.value.pageReachBottom();
})
}
export default useZPaging

View File

@ -0,0 +1,25 @@
// [z-paging]useZPagingComp hooks
function useZPagingComp(paging) {
const cPaging = !!paging ? paging.value || paging : null;
const reload = () => {
if (!cPaging || !cPaging.value) return;
cPaging.value.reload().catch(() => {});
}
const updatePageScrollTop = scrollTop => {
if (!cPaging || !cPaging.value) return;
cPaging.value.updatePageScrollTop(scrollTop);
}
const doChatRecordLoadMore = () => {
if (!cPaging || !cPaging.value) return;
cPaging.value.doChatRecordLoadMore();
}
const pageReachBottom = () => {
if (!cPaging || !cPaging.value) return;
cPaging.value.pageReachBottom();
}
return { reload, updatePageScrollTop, doChatRecordLoadMore, pageReachBottom };
}
export default useZPagingComp

View File

@ -0,0 +1,125 @@
// [z-paging]点击返回顶部view模块
import u from '.././z-paging-utils'
export default {
props: {
// 自动显示点击返回顶部按钮,默认为否
autoShowBackToTop: {
type: Boolean,
default: u.gc('autoShowBackToTop', false)
},
// 点击返回顶部按钮显示/隐藏的阈值(滚动距离)单位为px默认为400rpx
backToTopThreshold: {
type: [Number, String],
default: u.gc('backToTopThreshold', '400rpx')
},
// 点击返回顶部按钮的自定义图片地址默认使用z-paging内置的图片
backToTopImg: {
type: String,
default: u.gc('backToTopImg', '')
},
// 点击返回顶部按钮返回到顶部时是否展示过渡动画,默认为是
backToTopWithAnimate: {
type: Boolean,
default: u.gc('backToTopWithAnimate', true)
},
// 点击返回顶部按钮与底部的距离注意添加单位px或rpx默认为160rpx
backToTopBottom: {
type: [Number, String],
default: u.gc('backToTopBottom', '160rpx')
},
// 点击返回顶部按钮的自定义样式
backToTopStyle: {
type: Object,
default: u.gc('backToTopStyle', {}),
},
// iOS点击顶部状态栏、安卓双击标题栏时滚动条返回顶部只支持竖向默认为是
enableBackToTop: {
type: Boolean,
default: u.gc('enableBackToTop', true)
},
},
data() {
return {
// 点击返回顶部的class
backToTopClass: 'zp-back-to-top zp-back-to-top-hide',
// 上次点击返回顶部的时间
lastBackToTopShowTime: 0,
// 点击返回顶部显示的class是否在展示中使得按钮展示/隐藏过度效果更自然
showBackToTopClass: false,
}
},
computed: {
backToTopThresholdUnitConverted() {
return u.addUnit(this.backToTopThreshold, this.unit);
},
backToTopBottomUnitConverted() {
return u.addUnit(this.backToTopBottom, this.unit);
},
finalEnableBackToTop() {
return this.usePageScroll ? false : this.enableBackToTop;
},
finalBackToTopThreshold() {
return u.convertToPx(this.backToTopThresholdUnitConverted);
},
finalBackToTopStyle() {
const backToTopStyle = this.backToTopStyle;
if (!backToTopStyle.bottom) {
backToTopStyle.bottom = this.windowBottom + u.convertToPx(this.backToTopBottomUnitConverted) + 'px';
}
if(!backToTopStyle.position){
backToTopStyle.position = this.usePageScroll ? 'fixed': 'absolute';
}
return backToTopStyle;
},
finalBackToTopClass() {
return `${this.backToTopClass} zp-back-to-top-${this.unit}`;
}
},
methods: {
// 点击了返回顶部
_backToTopClick() {
let callbacked = false;
this.$emit('backToTopClick', toTop => {
(toTop === undefined || toTop === true) && this._handleToTop();
callbacked = true;
});
// 如果用户没有禁止默认的返回顶部事件,则触发滚动到顶部
this.$nextTick(() => {
!callbacked && this._handleToTop();
})
},
// 处理滚动到顶部(聊天记录模式中为滚动到底部)
_handleToTop() {
!this.backToTopWithAnimate && this._checkShouldShowBackToTop(0);
!this.useChatRecordMode ? this.scrollToTop(this.backToTopWithAnimate) : this.scrollToBottom(this.backToTopWithAnimate);
},
// 判断是否要显示返回顶部按钮
_checkShouldShowBackToTop(scrollTop) {
if (!this.autoShowBackToTop) {
this.showBackToTopClass = false;
return;
}
if (scrollTop > this.finalBackToTopThreshold) {
if (!this.showBackToTopClass) {
// 记录当前点击返回顶部按钮显示的class生效了
this.showBackToTopClass = true;
this.lastBackToTopShowTime = new Date().getTime();
// 当滚动到需要展示返回顶部的阈值内则延迟300毫秒展示返回到顶部按钮
u.delay(() => {
this.backToTopClass = 'zp-back-to-top zp-back-to-top-show';
}, 300)
}
} else {
// 如果当前点击返回顶部按钮显示的class是生效状态并且滚动小于触发阈值则隐藏返回顶部按钮
if (this.showBackToTopClass) {
this.backToTopClass = 'zp-back-to-top zp-back-to-top-hide';
u.delay(() => {
this.showBackToTopClass = false;
}, new Date().getTime() - this.lastBackToTopShowTime < 500 ? 0 : 300)
}
}
},
}
}

View File

@ -0,0 +1,149 @@
// [z-paging]聊天记录模式模块
import u from '.././z-paging-utils'
export default {
props: {
// 使用聊天记录模式,默认为否
useChatRecordMode: {
type: Boolean,
default: u.gc('useChatRecordMode', false)
},
// 使用聊天记录模式时滚动到顶部后列表垂直移动偏移距离。默认0rpx。单位px暂时无效
chatRecordMoreOffset: {
type: [Number, String],
default: u.gc('chatRecordMoreOffset', '0rpx')
},
// 使用聊天记录模式时是否自动隐藏键盘:在用户触摸列表时候自动隐藏键盘,默认为是
autoHideKeyboardWhenChat: {
type: Boolean,
default: u.gc('autoHideKeyboardWhenChat', true)
},
// 使用聊天记录模式中键盘弹出时是否自动调整slot="bottom"高度,默认为是
autoAdjustPositionWhenChat: {
type: Boolean,
default: u.gc('autoAdjustPositionWhenChat', true)
},
// 使用聊天记录模式中键盘弹出时占位高度偏移距离。默认0rpx。单位px
chatAdjustPositionOffset: {
type: [Number, String],
default: u.gc('chatAdjustPositionOffset', '0rpx')
},
// 使用聊天记录模式中键盘弹出时是否自动滚动到底部,默认为否
autoToBottomWhenChat: {
type: Boolean,
default: u.gc('autoToBottomWhenChat', false)
},
// 使用聊天记录模式中reload时是否显示chatLoading默认为否
showChatLoadingWhenReload: {
type: Boolean,
default: u.gc('showChatLoadingWhenReload', false)
},
// 在聊天记录模式中滑动到顶部状态为默认状态时以加载中的状态展示默认为是。若设置为否则默认会显示【点击加载更多】然后才会显示loading
chatLoadingMoreDefaultAsLoading: {
type: Boolean,
default: u.gc('chatLoadingMoreDefaultAsLoading', true)
},
},
data() {
return {
// 键盘高度
keyboardHeight: 0,
// 键盘高度是否未改变,此时占位高度变化不需要动画效果
isKeyboardHeightChanged: false,
}
},
computed: {
finalChatRecordMoreOffset() {
return u.convertToPx(this.chatRecordMoreOffset);
},
finalChatAdjustPositionOffset() {
return u.convertToPx(this.chatAdjustPositionOffset);
},
// 聊天记录模式旋转180度style
chatRecordRotateStyle() {
let cellStyle;
// 在vue中直接将列表倒置因此在vue的cell中也直接写style="transform: scaleY(-1)"转回来即可。
// #ifndef APP-NVUE
cellStyle = this.useChatRecordMode ? { transform: 'scaleY(-1)' } : {};
// #endif
// 在nvue中需要考虑数据量不满一页的情况因为nvue中的list无法通过flex-end修改不满一页的起始位置会导致不满一页时列表数据从底部开始因此需要特别判断
// 当数据不满一屏的时候,不进行列表倒置
// #ifdef APP-NVUE
cellStyle = this.useChatRecordMode ? { transform: this.isFirstPageAndNoMore ? 'scaleY(1)' : 'scaleY(-1)' } : {};
// #endif
this.$emit('update:cellStyle', cellStyle);
this.$emit('cellStyleChange', cellStyle);
// 在聊天记录模式中,如果列表没有倒置并且当前是第一页,则需要自动滚动到最底部
this.$nextTick(() => {
if (this.isFirstPage && this.isChatRecordModeAndNotInversion) {
this.$nextTick(() => {
// 这里多次触发滚动到底部是为了避免在某些情况下即使是在nextTick但是cell未渲染完毕导致滚动到底部位置不正确的问题
this._scrollToBottom(false);
u.delay(() => {
this._scrollToBottom(false);
u.delay(() => {
this._scrollToBottom(false);
}, 50)
}, 50)
})
}
})
return cellStyle;
},
// 是否是聊天记录列表并且有配置transform
isChatRecordModeHasTransform() {
return this.useChatRecordMode && this.chatRecordRotateStyle && this.chatRecordRotateStyle.transform;
},
// 是否是聊天记录列表并且列表未倒置
isChatRecordModeAndNotInversion() {
return this.isChatRecordModeHasTransform && this.chatRecordRotateStyle.transform === 'scaleY(1)';
},
// 是否是聊天记录列表并且列表倒置
isChatRecordModeAndInversion() {
return this.isChatRecordModeHasTransform && this.chatRecordRotateStyle.transform === 'scaleY(-1)';
},
// 最终的聊天记录模式中底部安全区域的高度,如果开启了底部安全区域并且键盘未弹出,则添加底部区域高度
chatRecordModeSafeAreaBottom() {
return this.safeAreaInsetBottom && !this.keyboardHeight ? this.safeAreaBottom : 0;
}
},
mounted() {
// 监听键盘高度变化H5、百度小程序、抖音小程序、飞书小程序不支持
// #ifndef H5 || MP-BAIDU || MP-TOUTIAO
if (this.useChatRecordMode) {
uni.onKeyboardHeightChange(this._handleKeyboardHeightChange);
}
// #endif
},
methods: {
// 添加聊天记录
addChatRecordData(data, toBottom = true, toBottomWithAnimate = true) {
if (!this.useChatRecordMode) return;
this.isTotalChangeFromAddData = true;
this.addDataFromTop(data, toBottom, toBottomWithAnimate);
},
// 手动触发滚动到顶部加载更多,聊天记录模式时有效
doChatRecordLoadMore() {
this.useChatRecordMode && this._onLoadingMore('click');
},
// 处理键盘高度变化
_handleKeyboardHeightChange(res) {
this.$emit('keyboardHeightChange', res);
if (this.autoAdjustPositionWhenChat) {
this.isKeyboardHeightChanged = true;
this.keyboardHeight = res.height > 0 ? res.height + this.finalChatAdjustPositionOffset : res.height;
}
if (this.autoToBottomWhenChat && this.keyboardHeight > 0) {
u.delay(() => {
this.scrollToBottom(false);
u.delay(() => {
this.scrollToBottom(false);
})
})
}
}
}
}

View File

@ -0,0 +1,152 @@
// [z-paging]通用布局相关模块
import u from '.././z-paging-utils'
// #ifdef APP-NVUE
const weexDom = weex.requireModule('dom');
// #endif
export default {
data() {
return {
systemInfo: null,
cssSafeAreaInsetBottom: -1,
isReadyDestroy: false,
}
},
computed: {
// 顶部可用距离
windowTop() {
if (!this.systemInfo) return 0;
// 暂时修复vue3中隐藏系统导航栏后windowTop获取不正确的问题具体bug详见https://ask.dcloud.net.cn/question/141634
// 感谢litangyuhttps://github.com/SmileZXLee/uni-z-paging/issues/25
// #ifdef VUE3 && H5
const pageHeadNode = document.getElementsByTagName("uni-page-head");
if (!pageHeadNode.length) return 0;
// #endif
return this.systemInfo.windowTop || 0;
},
// 底部安全区域高度
safeAreaBottom() {
if (!this.systemInfo) return 0;
let safeAreaBottom = 0;
// #ifdef APP-PLUS
safeAreaBottom = this.systemInfo.safeAreaInsets.bottom || 0 ;
// #endif
// #ifndef APP-PLUS
safeAreaBottom = Math.max(this.cssSafeAreaInsetBottom, 0);
// #endif
return safeAreaBottom;
},
// 是否是比较老的webview在一些老的webview中需要进行一些特殊处理
isOldWebView() {
// #ifndef APP-NVUE || MP-KUAISHOU
try {
const systemInfos = u.getSystemInfoSync(true).system.split(' ');
const deviceType = systemInfos[0];
const version = parseInt(systemInfos[1]);
if ((deviceType === 'iOS' && version <= 10) || (deviceType === 'Android' && version <= 6)) {
return true;
}
} catch(e) {
return false;
}
// #endif
return false;
},
// 当前组件的$slots兼容不同平台
zSlots() {
// #ifdef VUE2
// #ifdef MP-ALIPAY
return this.$slots;
// #endif
return this.$scopedSlots || this.$slots;
// #endif
return this.$slots;
},
},
beforeDestroy() {
this.isReadyDestroy = true;
},
// #ifdef VUE3
unmounted() {
this.isReadyDestroy = true;
},
// #endif
methods: {
// 更新fixed模式下z-paging的布局
updateFixedLayout() {
this.fixed && this.$nextTick(() => {
this.systemInfo = u.getSystemInfoSync();
})
},
// 获取节点尺寸
_getNodeClientRect(select, inDom = true, scrollOffset = false) {
if (this.isReadyDestroy) {
return Promise.resolve(false);
};
// nvue中获取节点信息
// #ifdef APP-NVUE
select = select.replace(/[.|#]/g, '');
const ref = this.$refs[select];
return new Promise((resolve, reject) => {
if (ref) {
weexDom.getComponentRect(ref, option => {
resolve(option && option.result ? [option.size] : false);
})
} else {
resolve(false);
}
});
return;
// #endif
// vue中获取节点信息
//#ifdef MP-ALIPAY
inDom = false;
//#endif
/*
inDom可能是truefalse也可能是具体的dom节点
如果inDom不为false则使用uni.createSelectorQuery().in()进行查询如果inDom为true则in中的是this否则in中的为具体的dom
如果inDom为false则使用uni.createSelectorQuery()进行查询
*/
let res = !!inDom ? uni.createSelectorQuery().in(inDom === true ? this : inDom) : uni.createSelectorQuery();
scrollOffset ? res.select(select).scrollOffset() : res.select(select).boundingClientRect();
return new Promise((resolve, reject) => {
res.exec(data => {
resolve((data && data != '' && data != undefined && data.length) ? data : false);
});
});
},
// 获取slot="left"和slot="right"宽度并且更新布局
_updateLeftAndRightWidth(targetStyle, parentNodePrefix) {
this.$nextTick(() => {
let delayTime = 0;
// #ifdef MP-BAIDU
delayTime = 10;
// #endif
setTimeout(() => {
['left','right'].map(position => {
this._getNodeClientRect(`.${parentNodePrefix}-${position}`).then(res => {
this.$set(targetStyle, position, res ? res[0].width + 'px' : '0px');
});
})
}, delayTime)
})
},
// 通过获取css设置的底部安全区域占位view高度设置bottom距离直接通过systemInfo在部分平台上无法获取到底部安全区域
_getCssSafeAreaInsetBottom(success) {
this._getNodeClientRect('.zp-safe-area-inset-bottom').then(res => {
this.cssSafeAreaInsetBottom = res ? res[0].height : -1;
res && success && success();
});
},
// 同步获取系统信息兼容不同平台供z-paging-swiper使用
_getSystemInfoSync(useCache = false) {
return u.getSystemInfoSync(useCache);
}
}
}

View File

@ -0,0 +1,736 @@
// [z-paging]数据处理模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
import interceptor from '../z-paging-interceptor'
export default {
props: {
// 自定义初始的pageNo默认为1
defaultPageNo: {
type: Number,
default: u.gc('defaultPageNo', 1),
observer: function(newVal) {
this.pageNo = newVal;
},
},
// 自定义pageSize默认为10
defaultPageSize: {
type: Number,
default: u.gc('defaultPageSize', 10),
validator: (value) => {
if (value <= 0) u.consoleErr('default-page-size必须大于0');
return value > 0;
}
},
// 为保证数据一致设置当前tab切换时的标识key并在complete中传递相同key若二者不一致则complete将不会生效
dataKey: {
type: [Number, String, Object],
default: u.gc('dataKey', null),
},
// 使用缓存若开启将自动缓存第一页的数据默认为否。请注意因考虑到切换tab时不同tab数据不同的情况默认仅会缓存组件首次加载时第一次请求到的数据后续的下拉刷新操作不会更新缓存。
useCache: {
type: Boolean,
default: u.gc('useCache', false)
},
// 使用缓存时缓存的key用于区分不同列表的缓存数据useCache为true时必须设置否则缓存无效
cacheKey: {
type: String,
default: u.gc('cacheKey', null)
},
// 缓存模式默认仅会缓存组件首次加载时第一次请求到的数据可设置为always即代表总是缓存每次列表刷新(下拉刷新、调用reload等)都会更新缓存
cacheMode: {
type: String,
default: u.gc('cacheMode', Enum.CacheMode.Default)
},
// 自动注入的list名可自动修改父view(包含ref="paging")中对应name的list值
autowireListName: {
type: String,
default: u.gc('autowireListName', '')
},
// 自动注入的query名可自动调用父view(包含ref="paging")中的query方法
autowireQueryName: {
type: String,
default: u.gc('autowireQueryName', '')
},
// 获取分页数据Function功能与@query类似。若设置了fetch则@query将不再触发
fetch: {
type: Function,
default: null
},
// fetch的附加参数fetch配置后有效
fetchParams: {
type: Object,
default: u.gc('fetchParams', null)
},
// z-paging mounted后自动调用reload方法(mounted后自动调用接口),默认为是
auto: {
type: Boolean,
default: u.gc('auto', true)
},
// 用户下拉刷新时是否触发reload方法默认为是
reloadWhenRefresh: {
type: Boolean,
default: u.gc('reloadWhenRefresh', true)
},
// reload时自动滚动到顶部默认为是
autoScrollToTopWhenReload: {
type: Boolean,
default: u.gc('autoScrollToTopWhenReload', true)
},
// reload时立即自动清空原list默认为是若立即自动清空则在reload之后、请求回调之前页面是空白的
autoCleanListWhenReload: {
type: Boolean,
default: u.gc('autoCleanListWhenReload', true)
},
// 列表刷新时自动显示下拉刷新view默认为否
showRefresherWhenReload: {
type: Boolean,
default: u.gc('showRefresherWhenReload', false)
},
// 列表刷新时自动显示加载更多view且为加载中状态默认为否
showLoadingMoreWhenReload: {
type: Boolean,
default: u.gc('showLoadingMoreWhenReload', false)
},
// 组件created时立即触发reload(可解决一些情况下先看到页面再看到loading的问题)auto为true时有效。为否时将在mounted+nextTick后触发reload默认为否
createdReload: {
type: Boolean,
default: u.gc('createdReload', false)
},
// 本地分页时上拉加载更多延迟时间单位为毫秒默认200毫秒
localPagingLoadingTime: {
type: [Number, String],
default: u.gc('localPagingLoadingTime', 200)
},
// 自动拼接complete中传过来的数组(使用聊天记录模式时无效)
concat: {
type: Boolean,
default: u.gc('concat', true)
},
// 请求失败是否触发reject默认为是
callNetworkReject: {
type: Boolean,
default: u.gc('callNetworkReject', true)
},
// 父组件v-model所绑定的list的值
value: {
type: Array,
default: function() {
return [];
}
},
// #ifdef VUE3
modelValue: {
type: Array,
default: function() {
return [];
}
}
// #endif
},
data (){
return {
currentData: [],
totalData: [],
realTotalData: [],
totalLocalPagingList: [],
dataPromiseResultMap: {
reload: null,
complete: null,
localPaging: null
},
isSettingCacheList: false,
pageNo: 1,
currentRefreshPageSize: 0,
isLocalPaging: false,
isAddedData: false,
isTotalChangeFromAddData: false,
privateConcat: true,
myParentQuery: -1,
firstPageLoaded: false,
pagingLoaded: false,
loaded: false,
isUserReload: true,
fromEmptyViewReload: false,
queryFrom: '',
listRendering: false,
isHandlingRefreshToPage: false,
isFirstPageAndNoMore: false,
totalDataChangeThrow: true
}
},
computed: {
pageSize() {
return this.defaultPageSize;
},
finalConcat() {
return this.concat && this.privateConcat;
},
finalUseCache() {
if (this.useCache && !this.cacheKey) {
u.consoleErr('use-cache为true时必须设置cache-key否则缓存无效');
}
return this.useCache && !!this.cacheKey;
},
finalCacheKey() {
return this.cacheKey ? `${c.cachePrefixKey}-${this.cacheKey}` : null;
},
isFirstPage() {
return this.pageNo === this.defaultPageNo;
}
},
watch: {
totalData(newVal, oldVal) {
this._totalDataChange(newVal, oldVal, this.totalDataChangeThrow);
this.totalDataChangeThrow = true;
},
currentData(newVal, oldVal) {
this._currentDataChange(newVal, oldVal);
},
useChatRecordMode(newVal, oldVal) {
if (newVal) {
this.nLoadingMoreFixedHeight = false;
}
},
value: {
handler(newVal) {
// 当v-model绑定的数据源被更改时此时数据源改变不emit input事件避免循环调用
if (newVal !== this.totalData) {
this.totalDataChangeThrow = false;
this.totalData = newVal;
}
},
immediate: true
},
// #ifdef VUE3
modelValue: {
handler(newVal) {
// 当v-model绑定的数据源被更改时此时数据源改变不emit input事件避免循环调用
if (newVal !== this.totalData) {
this.totalDataChangeThrow = false;
this.totalData = newVal;
}
},
immediate: true
}
// #endif
},
methods: {
// 请求结束(成功或者失败)调用此方法将请求的结果传递给z-paging处理第一个参数为请求结果数组第二个参数为是否成功(默认为是)
complete(data, success = true) {
this.customNoMore = -1;
return this.addData(data, success);
},
//【保证数据一致】请求结束(成功或者失败)调用此方法将请求的结果传递给z-paging处理第一个参数为请求结果数组第二个参数为dataKey需与:data-key绑定的一致第三个参数为是否成功(默认为是)
completeByKey(data, dataKey = null, success = true) {
if (dataKey !== null && this.dataKey !== null && dataKey !== this.dataKey) {
this.isFirstPage && this.endRefresh();
return new Promise(resolve => resolve());
}
this.customNoMore = -1;
return this.addData(data, success);
},
//【通过total判断是否有更多数据】请求结束(成功或者失败)调用此方法将请求的结果传递给z-paging处理第一个参数为请求结果数组第二个参数为total(列表总数),第三个参数为是否成功(默认为是)
completeByTotal(data, total, success = true) {
if (total == 'undefined') {
this.customNoMore = -1;
} else {
const dataTypeRes = this._checkDataType(data, success, false);
data = dataTypeRes.data;
success = dataTypeRes.success;
if (total >= 0 && success) {
return new Promise((resolve, reject) => {
this.$nextTick(() => {
let nomore = false;
const realTotalDataCount = this.pageNo == this.defaultPageNo ? 0 : this.realTotalData.length;
const dataLength = this.privateConcat ? data.length : 0;
let exceedCount = realTotalDataCount + dataLength - total;
// 没有更多数据了
if (exceedCount >= 0) {
nomore = true;
// 仅截取total内部分的数据
exceedCount = this.defaultPageSize - exceedCount;
if (this.privateConcat && exceedCount > 0 && exceedCount < data.length) {
data = data.splice(0, exceedCount);
}
}
this.completeByNoMore(data, nomore, success).then(res => resolve(res)).catch(() => reject());
})
});
}
}
return this.addData(data, success);
},
//【自行判断是否有更多数据】请求结束(成功或者失败)调用此方法将请求的结果传递给z-paging处理第一个参数为请求结果数组第二个参数为是否没有更多数据第三个参数为是否成功(默认是是)
completeByNoMore(data, nomore, success = true) {
if (nomore != 'undefined') {
this.customNoMore = nomore == true ? 1 : 0;
}
return this.addData(data, success);
},
// 请求结束且请求失败时调用,支持传入请求失败原因
completeByError(errorMsg) {
this.customerEmptyViewErrorText = errorMsg;
return this.complete(false);
},
// 与上方complete方法功能一致新版本中设置服务端回调数组请使用complete方法
addData(data, success = true) {
if (!this.fromCompleteEmit) {
this.disabledCompleteEmit = true;
this.fromCompleteEmit = false;
}
const currentTimeStamp = u.getTime();
const disTime = currentTimeStamp - this.requestTimeStamp;
let minDelay = this.minDelay;
if (this.isFirstPage && this.finalShowRefresherWhenReload) {
minDelay = Math.max(400, minDelay);
}
const addDataDalay = (this.requestTimeStamp > 0 && disTime < minDelay) ? minDelay - disTime : 0;
this.$nextTick(() => {
u.delay(() => {
this._addData(data, success, false);
}, this.delay > 0 ? this.delay : addDataDalay)
})
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.complete = { resolve, reject };
});
},
// 从顶部添加数据不会影响分页的pageNo和pageSize
addDataFromTop(data, toTop = true, toTopWithAnimate = true) {
// 数据是否拼接到顶部,如果是聊天记录模式并且列表没有倒置,则应该拼接在底部
let addFromTop = !this.isChatRecordModeAndNotInversion;
data = Object.prototype.toString.call(data) !== '[object Array]' ? [data] : (addFromTop ? data.reverse() : data);
// #ifndef APP-NVUE
this.finalUseVirtualList && this._setCellIndex(data, 'top')
// #endif
this.totalData = addFromTop ? [...data, ...this.totalData] : [...this.totalData, ...data];
if (toTop) {
u.delay(() => this.useChatRecordMode ? this.scrollToBottom(toTopWithAnimate) : this.scrollToTop(toTopWithAnimate));
}
},
// 重新设置列表数据调用此方法不会影响pageNo和pageSize也不会触发请求。适用场景当需要删除列表中某一项时将删除对应项后的数组通过此方法传递给z-paging。(当出现类似的需要修改列表数组的场景时请使用此方法请勿直接修改page中:list.sync绑定的数组)
resetTotalData(data) {
this.isTotalChangeFromAddData = true;
data = Object.prototype.toString.call(data) !== '[object Array]' ? [data] : data;
this.totalData = data;
},
// 设置本地分页数据,请求结束(成功或者失败)调用此方法将请求的结果传递给z-paging作分页处理若调用了此方法则上拉加载更多时内部会自动分页不会触发@query所绑定的事件
setLocalPaging(data, success = true) {
this.isLocalPaging = true;
this.$nextTick(() => {
this._addData(data, success, true);
})
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.localPaging = { resolve, reject };
});
},
// 重新加载分页数据pageNo会恢复为默认值相当于下拉刷新的效果(animate为true时会展示下拉刷新动画默认为false)
reload(animate = this.showRefresherWhenReload) {
if (animate) {
this.privateShowRefresherWhenReload = animate;
this.isUserPullDown = true;
}
if (!this.showLoadingMoreWhenReload) {
this.listRendering = true;
}
this.$nextTick(() => {
this._preReload(animate, false);
})
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.reload = { resolve, reject };
});
},
// 刷新列表数据pageNo和pageSize不会重置列表数据会重新从服务端获取。必须保证@query绑定的方法中的pageNo和pageSize和传给服务端的一致
refresh() {
return this._handleRefreshWithDisPageNo(this.pageNo - this.defaultPageNo + 1);
},
// 刷新列表数据至指定页例如pageNo=5时则代表刷新列表至第5页此时pageNo会变为5列表会展示前5页的数据。必须保证@query绑定的方法中的pageNo和pageSize和传给服务端的一致
refreshToPage(pageNo) {
this.isHandlingRefreshToPage = true;
return this._handleRefreshWithDisPageNo(pageNo + this.defaultPageNo - 1);
},
// 手动更新列表缓存数据将自动截取v-model绑定的list中的前pageSize条覆盖缓存请确保在list数据更新到预期结果后再调用此方法
updateCache() {
if (this.finalUseCache && this.totalData.length) {
this._saveLocalCache(this.totalData.slice(0, Math.min(this.totalData.length, this.pageSize)));
}
},
// 清空分页数据
clean() {
this._reload(true);
this._addData([], true, false);
},
// 清空分页数据
clear() {
this.clean();
},
// reload之前的一些处理
_preReload(animate = this.showRefresherWhenReload, isFromMounted = true, retryCount = 0) {
const showRefresher = this.finalRefresherEnabled && this.useCustomRefresher;
// #ifndef APP-NVUE
// 如果获取slot="refresher"高度失败则不触发reload直到获取slot="refresher"高度成功
if (this.customRefresherHeight === -1 && showRefresher) {
u.delay(() => {
retryCount ++;
// 如果重试次数是10的倍数(也就是每500毫秒)尝试重新获取一下slot="refresher"高度
// 此举是为了解决在某些特殊情况下z-paging组件mounted了但是未展示在用户面前比如在tabbar页面中未切换到对应tabbar但是通过代码让z-paging展示了此时控制台会报Error: Not FoundPage因为这时候去获取dom节点信息获取不到
// 当用户在某个时刻让此z-paging展示在面前时即可顺利获取到slot="refresher"高度,递归停止
if (retryCount % 10 === 0) {
this._updateCustomRefresherHeight();
}
this._preReload(animate, isFromMounted, retryCount);
}, c.delayTime / 2);
return;
}
// #endif
this.isUserReload = true;
this.loadingType = Enum.LoadingType.Refresher;
if (animate) {
this.privateShowRefresherWhenReload = animate;
// #ifndef APP-NVUE
if (this.useCustomRefresher) {
this._doRefresherRefreshAnimate();
} else {
this.refresherTriggered = true;
}
// #endif
// #ifdef APP-NVUE
this.refresherStatus = Enum.Refresher.Loading;
this.refresherRevealStackCount ++;
u.delay(() => {
this._getNodeClientRect('zp-n-refresh-container', false).then((node) => {
if (node) {
let nodeHeight = node[0].height;
this.nShowRefresherReveal = true;
this.nShowRefresherRevealHeight = nodeHeight;
u.delay(() => {
this._nDoRefresherEndAnimation(0, -nodeHeight, false, false);
u.delay(() => {
this._nDoRefresherEndAnimation(nodeHeight, 0);
}, 10)
}, 10)
}
this._reload(false, isFromMounted);
this._doRefresherLoad(false);
});
}, this.pagingLoaded ? 10 : 100)
return;
// #endif
} else {
this._refresherEnd(false, false, false, false);
}
this._reload(false, isFromMounted);
},
// 重新加载分页数据
_reload(isClean = false, isFromMounted = false, isUserPullDown = false) {
this.isAddedData = false;
this.insideOfPaging = -1;
this.cacheScrollNodeHeight = -1;
this.pageNo = this.defaultPageNo;
this._cleanRefresherEndTimeout();
!this.privateShowRefresherWhenReload && !isClean && this._startLoading(true);
this.firstPageLoaded = true;
this.isTotalChangeFromAddData = false;
if (!this.isSettingCacheList) {
this.totalData = [];
}
if (!isClean) {
this._emitQuery(this.pageNo, this.defaultPageSize, isUserPullDown ? Enum.QueryFrom.UserPullDown : Enum.QueryFrom.Reload);
let delay = 0;
// #ifdef MP-TOUTIAO
delay = 5;
// #endif
u.delay(this._callMyParentQuery, delay);
if (!isFromMounted && this.autoScrollToTopWhenReload) {
let checkedNRefresherLoading = true;
// #ifdef APP-NVUE
checkedNRefresherLoading = !this.nRefresherLoading;
// #endif
checkedNRefresherLoading && this._scrollToTop(false);
}
}
// #ifdef APP-NVUE
this.$nextTick(() => {
this.nShowBottom = this.realTotalData.length > 0;
})
// #endif
},
// 处理服务端返回的数组
_addData(data, success, isLocal) {
this.isAddedData = true;
this.fromEmptyViewReload = false;
this.isTotalChangeFromAddData = true;
this.refresherTriggered = false;
this._endSystemLoadingAndRefresh();
const tempIsUserPullDown = this.isUserPullDown;
if (this.showRefresherUpdateTime && this.isFirstPage) {
u.setRefesrherTime(u.getTime(), this.refresherUpdateTimeKey);
this.$refs.refresh && this.$refs.refresh.updateTime();
}
if (!isLocal && tempIsUserPullDown && this.isFirstPage) {
this.isUserPullDown = false;
}
this.listRendering = true;
this.$nextTick(() => {
u.delay(() => this.listRendering = false);
})
let dataTypeRes = this._checkDataType(data, success, isLocal);
data = dataTypeRes.data;
success = dataTypeRes.success;
let delayTime = c.delayTime;
if (this.useChatRecordMode) delayTime = 0;
this.loadingForNow = false;
u.delay(() => {
this.pagingLoaded = true;
this.$nextTick(()=>{
!isLocal && this._refresherEnd(delayTime > 0, true, tempIsUserPullDown);
})
})
if (this.isFirstPage) {
this.isLoadFailed = !success;
this.$emit('isLoadFailedChange', this.isLoadFailed);
if (this.finalUseCache && success && (this.cacheMode === Enum.CacheMode.Always ? true : this.isSettingCacheList)) {
this._saveLocalCache(data);
}
}
this.isSettingCacheList = false;
if (success) {
if (!(this.privateConcat === false && !this.isHandlingRefreshToPage && this.loadingStatus === Enum.More.NoMore)) {
this.loadingStatus = Enum.More.Default;
}
if (isLocal) {
// 如果当前是本地分页则必然是由setLocalPaging方法触发此时直接本地加载第一页数据即可。后续本地分页加载更多方法由滚动到底部加载更多事件处理
this.totalLocalPagingList = data;
const localPageNo = this.defaultPageNo;
const localPageSize = this.queryFrom !== Enum.QueryFrom.Refresh ? this.defaultPageSize : this.currentRefreshPageSize;
this._localPagingQueryList(localPageNo, localPageSize, 0, res => {
u.delay(() => {
this.completeByTotal(res, this.totalLocalPagingList.length);;
}, 0)
})
} else {
// 如果当前不是本地分页,则按照正常分页逻辑进行数据处理&emit数据
let dataChangeDelayTime = 0;
// #ifdef APP-NVUE
if (this.privateShowRefresherWhenReload && this.finalNvueListIs === 'waterfall') {
dataChangeDelayTime = 150;
}
// #endif
u.delay(() => {
this._currentDataChange(data, this.currentData);
this._callDataPromise(true, this.totalData);
}, dataChangeDelayTime)
}
if (this.isHandlingRefreshToPage) {
this.isHandlingRefreshToPage = false;
this.pageNo = this.defaultPageNo + Math.ceil(data.length / this.pageSize) - 1;
if (data.length % this.pageSize !== 0) {
this.customNoMore = 1;
}
}
} else {
this._currentDataChange(data, this.currentData);
this._callDataPromise(false);
this.loadingStatus = Enum.More.Fail;
this.isHandlingRefreshToPage = false;
if (this.loadingType === Enum.LoadingType.LoadMore) {
this.pageNo --;
}
}
},
// 所有数据改变时调用
_totalDataChange(newVal, oldVal, eventThrow=true) {
if ((!this.isUserReload || !this.autoCleanListWhenReload) && this.firstPageLoaded && !newVal.length && oldVal.length) {
return;
}
this._doCheckScrollViewShouldFullHeight(newVal);
if(!this.realTotalData.length && !newVal.length){
eventThrow = false;
}
this.realTotalData = newVal;
// emit列表更新事件
if (eventThrow) {
this.$emit('input', newVal);
// #ifdef VUE3
this.$emit('update:modelValue', newVal);
// #endif
this.$emit('update:list', newVal);
this.$emit('listChange', newVal);
this._callMyParentList(newVal);
}
this.firstPageLoaded = false;
this.isTotalChangeFromAddData = false;
this.$nextTick(() => {
u.delay(()=>{
// emit z-paging内容区域高度改变事件
this._getNodeClientRect('.zp-paging-container-content').then(res => {
res && this.$emit('contentHeightChanged', res[0].height);
});
}, c.delayTime * (this.isIos ? 1 : 3))
// #ifdef APP-NVUE
// 在nvue中延时600毫秒展示底部加载更多避免底部加载更多太早加载闪一下的问题
u.delay(() => {
this.nShowBottom = true;
}, c.delayTime * 6, 'nShowBottomDelay');
// #endif
})
},
// 当前数据改变时调用
_currentDataChange(newVal, oldVal) {
newVal = [...newVal];
// #ifndef APP-NVUE
this.finalUseVirtualList && this._setCellIndex(newVal, 'bottom');
// #endif
if (this.isFirstPage && this.finalConcat) {
this.totalData = [];
}
// customNoMore-1代表交由z-paging自行判断1代表没有更多了0代表还有更多数据
if (this.customNoMore !== -1) {
// 如果customNoMore等于1 或者 customNoMore不是0并且新增数组长度为0(也就是不是明确的还有更多数据并且新增的数组长度为0),则没有更多数据了
if (this.customNoMore === 1 || (this.customNoMore !== 0 && !newVal.length)) {
this.loadingStatus = Enum.More.NoMore;
}
} else {
// 如果新增的数据数组长度为0 或者 新增的数组长度小于默认的pageSize则没有更多数据了
if (!newVal.length || (newVal.length && newVal.length < this.defaultPageSize)) {
this.loadingStatus = Enum.More.NoMore;
}
}
if (!this.totalData.length) {
// #ifdef APP-NVUE
// 如果在聊天记录模式+nvue中并且数据不满一页时需要将列表倒序因为此时没有将列表旋转180度数组中第0条数据应当在最底下显示
if (this.useChatRecordMode && this.finalConcat && this.isFirstPage && this.loadingStatus === Enum.More.NoMore) {
newVal.reverse();
}
// #endif
this.totalData = newVal;
} else {
if (this.finalConcat) {
const currentScrollTop = this.oldScrollTop;
this.totalData = [...this.totalData, ...newVal];
// 此处是为了解决在微信小程序中,在某些情况下滚动到底部加载更多后滚动位置直接变为最底部的问题,因此需要通过代码强制滚动回加载更多前的位置
// #ifdef MP-WEIXIN
if (!this.isIos && !this.refresherOnly && !this.usePageScroll && newVal.length) {
this.loadingMoreTimeStamp = u.getTime();
this.$nextTick(() => {
this.scrollToY(currentScrollTop);
})
}
// #endif
} else {
this.totalData = newVal;
}
}
this.privateConcat = true;
},
// 根据pageNo处理refresh操作
_handleRefreshWithDisPageNo(pageNo) {
if (!this.isHandlingRefreshToPage && !this.realTotalData.length) return this.reload();
if (pageNo >= 1) {
this.loading = true;
this.privateConcat = false;
const totalPageSize = pageNo * this.pageSize;
this.currentRefreshPageSize = totalPageSize;
// 如果调用refresh时是本地分页则在组件内部自己处理分页逻辑不emit query相关事件
if (this.isLocalPaging && this.isHandlingRefreshToPage) {
this._localPagingQueryList(this.defaultPageNo, totalPageSize, 0, res => {
this.complete(res);
})
} else {
// emit query相关事件
this._emitQuery(this.defaultPageNo, totalPageSize, Enum.QueryFrom.Refresh);
this._callMyParentQuery(this.defaultPageNo, totalPageSize);
}
}
return new Promise((resolve, reject) => {
this.dataPromiseResultMap.reload = { resolve, reject };
});
},
// 本地分页请求
_localPagingQueryList(pageNo, pageSize, localPagingLoadingTime, callback) {
pageNo = Math.max(1, pageNo);
pageSize = Math.max(1, pageSize);
const totalPagingList = [...this.totalLocalPagingList];
const pageNoIndex = (pageNo - 1) * pageSize;
const finalPageNoIndex = Math.min(totalPagingList.length, pageNoIndex + pageSize);
const resultPagingList = totalPagingList.splice(pageNoIndex, finalPageNoIndex - pageNoIndex);
u.delay(() => callback(resultPagingList), localPagingLoadingTime)
},
// 存储列表缓存数据
_saveLocalCache(data) {
uni.setStorageSync(this.finalCacheKey, data);
},
// 通过缓存数据填充列表数据
_setListByLocalCache() {
this.totalData = uni.getStorageSync(this.finalCacheKey) || [];
this.isSettingCacheList = true;
},
// 修改父view的list
_callMyParentList(newVal) {
if (this.autowireListName.length) {
const myParent = u.getParent(this.$parent);
if (myParent && myParent[this.autowireListName]) {
myParent[this.autowireListName] = newVal;
}
}
},
// 调用父view的query
_callMyParentQuery(customPageNo = 0, customPageSize = 0) {
if (this.autowireQueryName) {
if (this.myParentQuery === -1) {
const myParent = u.getParent(this.$parent);
if (myParent && myParent[this.autowireQueryName]) {
this.myParentQuery = myParent[this.autowireQueryName];
}
}
if (this.myParentQuery !== -1) {
customPageSize > 0 ? this.myParentQuery(customPageNo, customPageSize) : this.myParentQuery(this.pageNo, this.defaultPageSize);
}
}
},
// emit query事件
_emitQuery(pageNo, pageSize, from){
this.queryFrom = from;
this.requestTimeStamp = u.getTime();
const [lastItem] = this.realTotalData.slice(-1);
if (this.fetch) {
const fetchParams = interceptor._handleFetchParams({pageNo, pageSize, from, lastItem: lastItem || null}, this.fetchParams);
const fetchResult = this.fetch(fetchParams);
if (!interceptor._handleFetchResult(fetchResult, this, fetchParams)) {
u.isPromise(fetchResult) ? fetchResult.then(res => {
this.complete(res);
}).catch(err => {
this.complete(false);
}) : this.complete(fetchResult)
}
} else {
this.$emit('query', ...interceptor._handleQuery(pageNo, pageSize, from, lastItem || null));
}
},
// 触发数据改变promise
_callDataPromise(success, totalList) {
for (const key in this.dataPromiseResultMap) {
const obj = this.dataPromiseResultMap[key];
if (!obj) continue;
success ? obj.resolve({ totalList, noMore: this.loadingStatus === Enum.More.NoMore }) : this.callNetworkReject && obj.reject(`z-paging-${key}-error`);
}
},
// 检查complete data的类型
_checkDataType(data, success, isLocal) {
const dataType = Object.prototype.toString.call(data);
if (dataType === '[object Boolean]') {
success = data;
data = [];
} else if (dataType !== '[object Array]') {
data = [];
if (dataType !== '[object Undefined]' && dataType !== '[object Null]') {
u.consoleErr(`${isLocal ? 'setLocalPaging' : 'complete'}参数类型不正确第一个参数类型必须为Array!`);
}
}
return { data, success };
},
}
}

View File

@ -0,0 +1,144 @@
// [z-paging]空数据图view模块
import u from '.././z-paging-utils'
export default {
props: {
// 是否强制隐藏空数据图,默认为否
hideEmptyView: {
type: Boolean,
default: u.gc('hideEmptyView', false)
},
// 空数据图描述文字,默认为“没有数据哦~”
emptyViewText: {
type: [String, Object],
default: u.gc('emptyViewText', null)
},
// 是否显示空数据图重新加载按钮(无数据时),默认为否
showEmptyViewReload: {
type: Boolean,
default: u.gc('showEmptyViewReload', false)
},
// 加载失败时是否显示空数据图重新加载按钮,默认为是
showEmptyViewReloadWhenError: {
type: Boolean,
default: u.gc('showEmptyViewReloadWhenError', true)
},
// 空数据图点击重新加载文字,默认为“重新加载”
emptyViewReloadText: {
type: [String, Object],
default: u.gc('emptyViewReloadText', null)
},
// 空数据图图片默认使用z-paging内置的图片
emptyViewImg: {
type: String,
default: u.gc('emptyViewImg', '')
},
// 空数据图“加载失败”描述文字,默认为“很抱歉,加载失败”
emptyViewErrorText: {
type: [String, Object],
default: u.gc('emptyViewErrorText', null)
},
// 空数据图“加载失败”图片默认使用z-paging内置的图片
emptyViewErrorImg: {
type: String,
default: u.gc('emptyViewErrorImg', '')
},
// 空数据图样式
emptyViewStyle: {
type: Object,
default: u.gc('emptyViewStyle', {})
},
// 空数据图容器样式
emptyViewSuperStyle: {
type: Object,
default: u.gc('emptyViewSuperStyle', {})
},
// 空数据图img样式
emptyViewImgStyle: {
type: Object,
default: u.gc('emptyViewImgStyle', {})
},
// 空数据图描述文字样式
emptyViewTitleStyle: {
type: Object,
default: u.gc('emptyViewTitleStyle', {})
},
// 空数据图重新加载按钮样式
emptyViewReloadStyle: {
type: Object,
default: u.gc('emptyViewReloadStyle', {})
},
// 空数据图片是否铺满z-paging默认为否即填充满z-paging内列表(滚动区域)部分。若设置为否则为填铺满整个z-paging
emptyViewFixed: {
type: Boolean,
default: u.gc('emptyViewFixed', false)
},
// 空数据图片是否垂直居中默认为是若设置为否即为从空数据容器顶部开始显示。emptyViewFixed为false时有效
emptyViewCenter: {
type: Boolean,
default: u.gc('emptyViewCenter', true)
},
// 加载中时是否自动隐藏空数据图,默认为是
autoHideEmptyViewWhenLoading: {
type: Boolean,
default: u.gc('autoHideEmptyViewWhenLoading', true)
},
// 用户下拉列表触发下拉刷新加载中时是否自动隐藏空数据图,默认为是
autoHideEmptyViewWhenPull: {
type: Boolean,
default: u.gc('autoHideEmptyViewWhenPull', true)
},
// 空数据view的z-index默认为9
emptyViewZIndex: {
type: Number,
default: u.gc('emptyViewZIndex', 9)
},
},
data() {
return {
customerEmptyViewErrorText: ''
}
},
computed: {
finalEmptyViewImg() {
return this.isLoadFailed ? this.emptyViewErrorImg : this.emptyViewImg;
},
finalShowEmptyViewReload() {
return this.isLoadFailed ? this.showEmptyViewReloadWhenError : this.showEmptyViewReload;
},
// 是否展示空数据图
showEmpty() {
if (this.refresherOnly || this.hideEmptyView || this.realTotalData.length) return false;
if (this.autoHideEmptyViewWhenLoading) {
if (this.isAddedData && !this.firstPageLoaded && !this.loading) return true;
} else {
return true;
}
return !this.autoHideEmptyViewWhenPull && !this.isUserReload;
},
},
methods: {
// 点击了空数据view重新加载按钮
_emptyViewReload() {
let callbacked = false;
this.$emit('emptyViewReload', reload => {
if (reload === undefined || reload === true) {
this.fromEmptyViewReload = true;
this.reload().catch(() => {});
}
callbacked = true;
});
// 如果用户没有禁止默认的点击重新加载刷新列表事件,则触发列表重新刷新
this.$nextTick(() => {
if (!callbacked) {
this.fromEmptyViewReload = true;
this.reload().catch(() => {});
}
})
},
// 点击了空数据view
_emptyViewClick() {
this.$emit('emptyViewClick');
},
}
}

View File

@ -0,0 +1,113 @@
// [z-paging]i18n模块
import { initVueI18n } from '@dcloudio/uni-i18n'
import messages from '../../i18n/index.js'
const { t } = initVueI18n(messages)
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import interceptor from '../z-paging-interceptor'
export default {
computed: {
finalLanguage() {
try {
const local = uni.getLocale();
const language = this.systemInfo.appLanguage;
return local === 'auto' ? interceptor._handleLanguage2Local(language, this._language2Local(language)) : local;
} catch (e) {
// 如果获取系统本地语言异常则默认返回中文uni.getLocale在部分低版本HX或者cli中可能报找不到的问题
return 'zh-Hans';
}
},
// 最终的下拉刷新默认状态的文字
finalRefresherDefaultText() {
return this._getI18nText('zp.refresher.default', this.refresherDefaultText);
},
// 最终的下拉刷新下拉中的文字
finalRefresherPullingText() {
return this._getI18nText('zp.refresher.pulling', this.refresherPullingText);
},
// 最终的下拉刷新中文字
finalRefresherRefreshingText() {
return this._getI18nText('zp.refresher.refreshing', this.refresherRefreshingText);
},
// 最终的下拉刷新完成文字
finalRefresherCompleteText() {
return this._getI18nText('zp.refresher.complete', this.refresherCompleteText);
},
// 最终的下拉刷新上次更新时间文字
finalRefresherUpdateTimeTextMap() {
return {
title: t('zp.refresherUpdateTime.title'),
none: t('zp.refresherUpdateTime.none'),
today: t('zp.refresherUpdateTime.today'),
yesterday: t('zp.refresherUpdateTime.yesterday')
};
},
// 最终的继续下拉进入二楼文字
finalRefresherGoF2Text() {
return this._getI18nText('zp.refresher.f2', this.refresherGoF2Text);
},
// 最终的底部加载更多默认状态文字
finalLoadingMoreDefaultText() {
return this._getI18nText('zp.loadingMore.default', this.loadingMoreDefaultText);
},
// 最终的底部加载更多加载中文字
finalLoadingMoreLoadingText() {
return this._getI18nText('zp.loadingMore.loading', this.loadingMoreLoadingText);
},
// 最终的底部加载更多没有更多数据文字
finalLoadingMoreNoMoreText() {
return this._getI18nText('zp.loadingMore.noMore', this.loadingMoreNoMoreText);
},
// 最终的底部加载更多加载失败文字
finalLoadingMoreFailText() {
return this._getI18nText('zp.loadingMore.fail', this.loadingMoreFailText);
},
// 最终的空数据图title
finalEmptyViewText() {
return this.isLoadFailed ? this.finalEmptyViewErrorText : this._getI18nText('zp.emptyView.title', this.emptyViewText);
},
// 最终的空数据图reload title
finalEmptyViewReloadText() {
return this._getI18nText('zp.emptyView.reload', this.emptyViewReloadText);
},
// 最终的空数据图加载失败文字
finalEmptyViewErrorText() {
return this.customerEmptyViewErrorText || this._getI18nText('zp.emptyView.error', this.emptyViewErrorText);
},
// 最终的系统loading title
finalSystemLoadingText() {
return this._getI18nText('zp.systemLoading.title', this.systemLoadingText);
},
},
methods: {
// 获取当前z-paging的语言
getLanguage() {
return this.finalLanguage;
},
// 获取国际化转换后的文本
_getI18nText(key, value) {
const dataType = Object.prototype.toString.call(value);
if (dataType === '[object Object]') {
const nextValue = value[this.finalLanguage];
if (nextValue) return nextValue;
} else if (dataType === '[object String]') {
return value;
}
return t(key);
},
// 系统language转i18n local
_language2Local(language) {
const formatedLanguage = language.toLowerCase().replace(new RegExp('_', ''), '-');
if (formatedLanguage.indexOf('zh') !== -1) {
if (formatedLanguage === 'zh' || formatedLanguage === 'zh-cn' || formatedLanguage.indexOf('zh-hans') !== -1) {
return 'zh-Hans';
}
return 'zh-Hant';
}
if (formatedLanguage.indexOf('en') !== -1) return 'en';
return language;
}
}
}

View File

@ -0,0 +1,374 @@
// [z-paging]滚动到底部加载更多模块
import u from '.././z-paging-utils'
import Enum from '.././z-paging-enum'
export default {
props: {
// 自定义底部加载更多样式
loadingMoreCustomStyle: {
type: Object,
default: u.gc('loadingMoreCustomStyle', {})
},
// 自定义底部加载更多文字样式
loadingMoreTitleCustomStyle: {
type: Object,
default: u.gc('loadingMoreTitleCustomStyle', {})
},
// 自定义底部加载更多加载中动画样式
loadingMoreLoadingIconCustomStyle: {
type: Object,
default: u.gc('loadingMoreLoadingIconCustomStyle', {})
},
// 自定义底部加载更多加载中动画图标类型可选flower或circle默认为flower
loadingMoreLoadingIconType: {
type: String,
default: u.gc('loadingMoreLoadingIconType', 'flower')
},
// 自定义底部加载更多加载中动画图标图片
loadingMoreLoadingIconCustomImage: {
type: String,
default: u.gc('loadingMoreLoadingIconCustomImage', '')
},
// 底部加载更多加载中view是否展示旋转动画默认为是
loadingMoreLoadingAnimated: {
type: Boolean,
default: u.gc('loadingMoreLoadingAnimated', true)
},
// 是否启用加载更多数据(含滑动到底部加载更多数据和点击加载更多数据),默认为是
loadingMoreEnabled: {
type: Boolean,
default: u.gc('loadingMoreEnabled', true)
},
// 是否启用滑动到底部加载更多数据,默认为是
toBottomLoadingMoreEnabled: {
type: Boolean,
default: u.gc('toBottomLoadingMoreEnabled', true)
},
// 滑动到底部状态为默认状态时,以加载中的状态展示,默认为否。若设置为是,可避免滚动到底部看到默认状态然后立刻变为加载中状态的问题,但分页数量未超过一屏时,不会显示【点击加载更多】
loadingMoreDefaultAsLoading: {
type: Boolean,
default: u.gc('loadingMoreDefaultAsLoading', false)
},
// 滑动到底部"默认"文字,默认为【点击加载更多】
loadingMoreDefaultText: {
type: [String, Object],
default: u.gc('loadingMoreDefaultText', null)
},
// 滑动到底部"加载中"文字,默认为【正在加载...】
loadingMoreLoadingText: {
type: [String, Object],
default: u.gc('loadingMoreLoadingText', null)
},
// 滑动到底部"没有更多"文字,默认为【没有更多了】
loadingMoreNoMoreText: {
type: [String, Object],
default: u.gc('loadingMoreNoMoreText', null)
},
// 滑动到底部"加载失败"文字,默认为【加载失败,点击重新加载】
loadingMoreFailText: {
type: [String, Object],
default: u.gc('loadingMoreFailText', null)
},
// 当没有更多数据且分页内容未超出z-paging时是否隐藏没有更多数据的view默认为否
hideNoMoreInside: {
type: Boolean,
default: u.gc('hideNoMoreInside', false)
},
// 当没有更多数据且分页数组长度少于这个值时隐藏没有更多数据的view默认为0代表不限制。
hideNoMoreByLimit: {
type: Number,
default: u.gc('hideNoMoreByLimit', 0)
},
// 是否显示默认的加载更多text默认为是
showDefaultLoadingMoreText: {
type: Boolean,
default: u.gc('showDefaultLoadingMoreText', true)
},
// 是否显示没有更多数据的view
showLoadingMoreNoMoreView: {
type: Boolean,
default: u.gc('showLoadingMoreNoMoreView', true)
},
// 是否显示没有更多数据的分割线,默认为是
showLoadingMoreNoMoreLine: {
type: Boolean,
default: u.gc('showLoadingMoreNoMoreLine', true)
},
// 自定义底部没有更多数据的分割线样式
loadingMoreNoMoreLineCustomStyle: {
type: Object,
default: u.gc('loadingMoreNoMoreLineCustomStyle', {})
},
// 当分页未满一屏时,是否自动加载更多,默认为否(nvue无效)
insideMore: {
type: Boolean,
default: u.gc('insideMore', false)
},
// 距底部/右边多远时单位px触发 scrolltolower 事件默认为100rpx
lowerThreshold: {
type: [Number, String],
default: u.gc('lowerThreshold', '100rpx')
},
},
data() {
return {
M: Enum.More,
// 底部加载更多状态
loadingStatus: Enum.More.Default,
// 在渲染之后的底部加载更多状态
loadingStatusAfterRender: Enum.More.Default,
// 底部加载更多时间戳
loadingMoreTimeStamp: 0,
// 底部加载更多slot
loadingMoreDefaultSlot: null,
// 是否展示底部加载更多
showLoadingMore: false,
// 是否是开发者自定义的加载更多,-1代表交由z-paging自行判断1代表没有更多了0代表还有更多数据
customNoMore: -1,
}
},
computed: {
// 底部加载更多配置
zLoadMoreConfig() {
return {
status: this.loadingStatusAfterRender,
defaultAsLoading: this.loadingMoreDefaultAsLoading || (this.useChatRecordMode && this.chatLoadingMoreDefaultAsLoading),
defaultThemeStyle: this.finalLoadingMoreThemeStyle,
customStyle: this.loadingMoreCustomStyle,
titleCustomStyle: this.loadingMoreTitleCustomStyle,
iconCustomStyle: this.loadingMoreLoadingIconCustomStyle,
loadingIconType: this.loadingMoreLoadingIconType,
loadingIconCustomImage: this.loadingMoreLoadingIconCustomImage,
loadingAnimated: this.loadingMoreLoadingAnimated,
showNoMoreLine: this.showLoadingMoreNoMoreLine,
noMoreLineCustomStyle: this.loadingMoreNoMoreLineCustomStyle,
defaultText: this.finalLoadingMoreDefaultText,
loadingText: this.finalLoadingMoreLoadingText,
noMoreText: this.finalLoadingMoreNoMoreText,
failText: this.finalLoadingMoreFailText,
hideContent: !this.loadingMoreDefaultAsLoading && this.listRendering,
unit: this.unit,
isChat: this.useChatRecordMode,
chatDefaultAsLoading: this.chatLoadingMoreDefaultAsLoading
};
},
// 最终的底部加载更多主题
finalLoadingMoreThemeStyle() {
return this.loadingMoreThemeStyle.length ? this.loadingMoreThemeStyle : this.defaultThemeStyle;
},
// 最终的底部加载更多触发阈值
finalLowerThreshold() {
return u.convertToPx(this.lowerThreshold);
},
// 是否显示默认状态下的底部加载更多
showLoadingMoreDefault() {
return this._showLoadingMore('Default');
},
// 是否显示加载中状态下的底部加载更多
showLoadingMoreLoading() {
return this._showLoadingMore('Loading');
},
// 是否显示没有更多了状态下的底部加载更多
showLoadingMoreNoMore() {
return this._showLoadingMore('NoMore');
},
// 是否显示加载失败状态下的底部加载更多
showLoadingMoreFail() {
return this._showLoadingMore('Fail');
},
// 是否显示自定义状态下的底部加载更多
showLoadingMoreCustom() {
return this._showLoadingMore('Custom');
},
// 底部加载更多固定高度
loadingMoreFixedHeight() {
return u.addUnit('80rpx', this.unit);
},
},
methods: {
// 页面滚动到底部时通知z-paging进行进一步处理
pageReachBottom() {
!this.useChatRecordMode && this.toBottomLoadingMoreEnabled && this._onLoadingMore('toBottom');
},
// 手动触发上拉加载更多(非必须,可依据具体需求使用)
doLoadMore(type) {
this._onLoadingMore(type);
},
// 通过@scroll事件检测是否滚动到了底部(顺带检测下是否滚动到了顶部)
_checkScrolledToBottom(scrollDiff, checked = false) {
// 如果当前scroll-view高度未获取则获取其高度
if (this.cacheScrollNodeHeight === -1) {
// 获取当前scroll-view高度
this._getNodeClientRect('.zp-scroll-view').then((res) => {
if (res) {
const scrollNodeHeight = res[0].height;
// 缓存当前scroll-view高度如果获取过了不再获取
this.cacheScrollNodeHeight = scrollNodeHeight;
// // scrollDiff - this.cacheScrollNodeHeight = 当前滚动区域的顶部与内容底部的距离 - scroll-view高度 = 当前滚动区域的底部与内容底部的距离(也就是最终的与底部的距离)
if (scrollDiff - scrollNodeHeight <= this.finalLowerThreshold) {
// 如果与底部的距离小于阈值,则判断为滚动到了底部,触发滚动到底部事件
this._onLoadingMore('toBottom');
}
}
});
} else {
// scrollDiff - this.cacheScrollNodeHeight = 当前滚动区域的顶部与内容底部的距离 - scroll-view高度 = 当前滚动区域的底部与内容底部的距离(也就是最终的与底部的距离)
if (scrollDiff - this.cacheScrollNodeHeight <= this.finalLowerThreshold) {
// 如果与底部的距离小于阈值,则判断为滚动到了底部,触发滚动到底部事件
this._onLoadingMore('toBottom');
} else if (scrollDiff - this.cacheScrollNodeHeight <= 500 && !checked) {
// 如果与底部的距离小于500px则获取当前滚动的位置延迟150毫秒重复上述步骤再次检测(避免@scroll触发时获取的scrollTop不正确导致的其他问题此时获取的scrollTop不一定可信)。防止因为部分性能较差安卓设备@scroll采样率过低导致的滚动到底部但是依然没有触发的问题
u.delay(() => {
this._getNodeClientRect('.zp-scroll-view', true, true).then((res) => {
if (res) {
this.oldScrollTop = res[0].scrollTop;
const newScrollDiff = res[0].scrollHeight - this.oldScrollTop;
this._checkScrolledToBottom(newScrollDiff, true);
}
})
}, 150, 'checkScrolledToBottomDelay')
}
// 检测一下是否已经滚动到了顶部了因为在安卓中滚动到顶部时scrollTop不一定为0(和滚动到底部一样的原因)所以需要在scrollTop小于150px时通过获取.zp-scroll-view的scrollTop再判断一下
if (this.oldScrollTop <= 150 && this.oldScrollTop !== 0) {
u.delay(() => {
// 这里再判断一下是否确实已经滚动到顶部了如果已经滚动到顶部了则不用再判断了再次判断的原因是可能150毫秒之后oldScrollTop才是0
if (this.oldScrollTop !== 0) {
this._getNodeClientRect('.zp-scroll-view', true, true).then((res) => {
// 如果150毫秒后.zp-scroll-view的scrollTop为0则认为已经滚动到了顶部了
if (res && res[0].scrollTop === 0 && this.oldScrollTop !== 0) {
this._onScrollToUpper();
}
})
}
}, 150, 'checkScrolledToTopDelay')
}
}
},
// 触发加载更多时调用,from:toBottom-滑动到底部触发click-点击加载更多触发
_onLoadingMore(from = 'click') {
// 如果是ios并且是滚动到底部的则在滚动到底部时候尝试将列表设置为禁止滚动然后设置为允许滚动以禁止底部bounce的效果
if (this.isIos && from === 'toBottom' && !this.scrollToBottomBounceEnabled && this.scrollEnable) {
this.scrollEnable = false;
this.$nextTick(() => {
this.scrollEnable = true;
})
}
// emit scrolltolower
this._emitScrollEvent('scrolltolower');
// 如果是只使用下拉刷新 或者 禁用底部加载更多 或者 底部加载更多不是默认状态或加载失败状态 或者 是加载中状态 或者 空数据图已经展示了则return不触发内部加载更多逻辑
if (this.refresherOnly || !this.loadingMoreEnabled || !(this.loadingStatus === Enum.More.Default || this.loadingStatus === Enum.More.Fail) || this.loading || this.showEmpty) return;
// #ifdef MP-WEIXIN
if (!this.isIos && !this.refresherOnly && !this.usePageScroll) {
const currentTimestamp = u.getTime();
// 在非ios平台+scroll-view中节流处理
if (this.loadingMoreTimeStamp > 0 && currentTimestamp - this.loadingMoreTimeStamp < 100) {
this.loadingMoreTimeStamp = 0;
return;
}
}
// #endif
// 处理加载更多数据
this._doLoadingMore();
},
// 处理开始加载更多
_doLoadingMore() {
if (this.pageNo >= this.defaultPageNo && this.loadingStatus !== Enum.More.NoMore) {
this.pageNo ++;
this._startLoading(false);
if (this.isLocalPaging) {
// 如果是本地分页,则在组件内部对数据进行分页处理,不触发@query事件
this._localPagingQueryList(this.pageNo, this.defaultPageSize, this.localPagingLoadingTime, res => {
this.completeByTotal(res, this.totalLocalPagingList.length);
this.queryFrom = Enum.QueryFrom.LoadMore;
})
} else {
// emit @query相关加载更多事件
this._emitQuery(this.pageNo, this.defaultPageSize, Enum.QueryFrom.LoadMore);
this._callMyParentQuery();
}
// 设置当前加载状态为底部加载更多状态
this.loadingType = Enum.LoadingType.LoadMore;
}
},
// (预处理)判断当没有更多数据且分页内容未超出z-paging时是否显示没有更多数据的view
_preCheckShowNoMoreInside(newVal, scrollViewNode, pagingContainerNode) {
if (this.loadingStatus === Enum.More.NoMore && this.hideNoMoreByLimit > 0 && newVal.length) {
this.showLoadingMore = newVal.length > this.hideNoMoreByLimit;
} else if ((this.loadingStatus === Enum.More.NoMore && this.hideNoMoreInside && newVal.length) || (this.insideMore && this.insideOfPaging !== false && newVal.length)) {
this.$nextTick(() => {
this._checkShowNoMoreInside(newVal, scrollViewNode, pagingContainerNode);
})
if (this.insideMore && this.insideOfPaging !== false && newVal.length) {
this.showLoadingMore = newVal.length;
}
} else {
this.showLoadingMore = newVal.length;
}
},
// 判断当没有更多数据且分页内容未超出z-paging时是否显示没有更多数据的view
async _checkShowNoMoreInside(totalData, oldScrollViewNode, oldPagingContainerNode) {
try {
const scrollViewNode = oldScrollViewNode || await this._getNodeClientRect('.zp-scroll-view');
// 在页面滚动模式下
if (this.usePageScroll) {
if (scrollViewNode) {
// 获取滚动内容总高度
const scrollViewTotalH = scrollViewNode[0].top + scrollViewNode[0].height;
// 如果滚动内容总高度小于窗口高度则认为内容未超出z-paging
this.insideOfPaging = scrollViewTotalH < this.windowHeight;
// 如果需要没有更多数据时隐藏底部加载更多view并且内容未超过z-paging则隐藏底部加载更多
if (this.hideNoMoreInside) {
this.showLoadingMore = !this.insideOfPaging;
}
// 如果需要内容未超过z-paging时自动加载更多则触发加载更多
this._updateInsideOfPaging();
}
} else {
// 在scroll-view滚动模式下
const pagingContainerNode = oldPagingContainerNode || await this._getNodeClientRect('.zp-paging-container-content');
// 获取滚动内容总高度
const pagingContainerH = pagingContainerNode ? pagingContainerNode[0].height : 0;
// 获取z-paging内置scroll-view高度
const scrollViewH = scrollViewNode ? scrollViewNode[0].height : 0;
// 如果滚动内容总高度小于z-paging内置scroll-view高度则认为内容未超出z-paging
this.insideOfPaging = pagingContainerH < scrollViewH;
if (this.hideNoMoreInside) {
this.showLoadingMore = !this.insideOfPaging;
}
// 如果需要内容未超过z-paging时自动加载更多则触发加载更多
this._updateInsideOfPaging();
}
} catch (e) {
// 如果发生了异常判断totalData数组长度为0则认为内容未超出z-paging
this.insideOfPaging = !totalData.length;
if (this.hideNoMoreInside) {
this.showLoadingMore = !this.insideOfPaging;
}
// 如果需要内容未超过z-paging时自动加载更多则触发加载更多
this._updateInsideOfPaging();
}
},
// 是否要展示上拉加载更多view
_showLoadingMore(type) {
if (!this.showLoadingMoreWhenReload && (!(this.loadingStatus === Enum.More.Default ? this.nShowBottom : true) || !this.realTotalData.length)) return false;
if (((!this.showLoadingMoreWhenReload || this.isUserPullDown || this.loadingStatus !== Enum.More.Loading) && !this.showLoadingMore) ||
(!this.loadingMoreEnabled && (!this.showLoadingMoreWhenReload || this.isUserPullDown || this.loadingStatus !== Enum.More.Loading)) || this.refresherOnly) {
return false;
}
if (this.useChatRecordMode && type !== 'Loading') return false;
if (!this.zSlots) return false;
if (type === 'Custom') {
return this.showDefaultLoadingMoreText && !(this.loadingStatus === Enum.More.NoMore && !this.showLoadingMoreNoMoreView);
}
const res = this.loadingStatus === Enum.More[type] && this.zSlots[`loadingMore${type}`] && (type === 'NoMore' ? this.showLoadingMoreNoMoreView : true);
if (res) {
// #ifdef APP-NVUE
if (!this.isIos) {
this.nLoadingMoreFixedHeight = false;
}
// #endif
}
return res;
},
}
}

Some files were not shown because too many files have changed in this diff Show More