59 Commits
Author SHA1 Message Date
YunaiV d1681ec2d5 V2.2.0 版本发布 2024-08-02 23:07:09 +08:00
YunaiV eae8f3c174 【代码优化】SYSTEM:微信小程序的订阅 2024-08-01 13:08:01 +08:00
YunaiV 7f5970d741 【代码优化】SYSTEM:微信小程序的订阅 2024-07-31 23:46:08 +08:00
YunaiV ececc6f67c 【代码优化】SYSTEM:微信小程序的订阅 2024-07-31 19:48:13 +08:00
芋道源码 77306a54e7 !65 【新增】订阅消息:拼团结果通知消息订阅
Merge pull request !65 from puhui999/master
2024-07-31 11:31:08 +00:00
puhui999 bd07762a10 【新增】订阅消息:拼团结果通知消息订阅 2024-07-31 16:22:51 +08:00
芋道源码 1b385d728a !64 【新增】订阅消息:订单发货消息订阅
Merge pull request !64 from puhui999/master
2024-07-31 05:51:17 +00:00
puhui999 8b73d488ca 【新增】订阅消息:订单发货消息订阅 2024-07-31 12:51:49 +08:00
芋道源码 59c6613102 !63 【新增】订阅消息:订单发货、充值退款消息订阅
Merge pull request !63 from puhui999/master
2024-07-30 15:14:12 +00:00
puhui999 9add242540 【新增】订阅消息:订单发货、充值退款消息订阅 2024-07-30 17:44:55 +08:00
YunaiV d97cc41f42 Merge branch 'downey' of https://gitee.com/downeyin/yudao-mall-uniapp into develop 2024-07-28 20:06:40 +08:00
YunaiV dfe19ca710 【代码修复】cloneDeep 深拷贝对列表的处理错误 2024-07-28 19:41:07 +08:00
YunaiV d716bc4619 【代码修复】修复在微信小程序下,切换手机 + 密码登录失败的问题 2024-07-26 13:14:25 +08:00
YunaiV 0b9e8a39aa 【代码修复】下单时,备注未传递给后端的问题 2024-07-26 09:38:29 +08:00
downeyin 20f937a4ed feat: 增加租户ID自定义配置支持 2024-07-26 00:11:07 +08:00
puhui999 a2b0942d88 【优化】根据代码评审优化小程序的订阅消息 2024-07-25 17:40:29 +08:00
YunaiV f67e6a7f54 【代码修复】login.vue 微信登录绑定过慢,导致登录失败 2024-07-25 08:48:45 +08:00
芋道源码 3d7f63868f !61 【新增】接入微信订阅消息
Merge pull request !61 from puhui999/master
2024-07-24 11:36:54 +00:00
puhui999 8977504147 【新增】接入微信订阅消息 2024-07-24 17:17:44 +08:00
YunaiV 0b9d7a8808 【代码修复】分销的提现的类型不正确的问题 2024-07-22 21:58:05 +08:00
芋道源码 1879a0b464 !60 新增在线客服,基于 WebSocket 实现实时通信
Merge pull request !60 from 芋道源码/develop
2024-07-22 10:00:11 +00:00
YunaiV f4cc8def91 【代码优化】分销相关的注释 2024-07-22 17:56:09 +08:00
芋道源码 2e423e9748 !58 复制订单信息失败,字段错误
Merge pull request !58 from WCH/N/A
2024-07-17 15:40:41 +00:00
芋道源码 9fc054a301 !59 update pages/order/aftersale/return-delivery.vue.
Merge pull request !59 from zhaowg/master
2024-07-17 15:38:18 +00:00
YunaiV 2ea027a6e0 【修复】商品详情页,未登录状态下,需要登录的问题 2024-07-17 23:35:39 +08:00
芋道源码 508bd9cbd7 !56 完善 mall 客服,实现 WebSocket 封装
Merge pull request !56 from puhui999/master
2024-07-17 14:37:56 +00:00
puhui999 d221f277a1 客服:重构消息列表(引入 z-paging) 2024-07-17 16:45:03 +08:00
zhaowg e289c10270 update pages/order/aftersale/return-delivery.vue.
修改1:进入该页面报: Cannot read properties of undefined (reading 'name'),原因是在{{ state.expresses[state.expressIndex].name }}这行代码,onLoad异步加载后端快递公司列表,在state.expresses为空数组时,直接读取name字段导致报错。
修改方式:增加v-if判断。

修改2:物流公司右侧增加箭头图标。icon-jiantou替换为_icon-forward。



Signed-off-by: zhaowg <suolong.hzw@foxmail.com>
2024-07-17 03:35:24 +00:00
puhui999 4c6a8077aa 客服:重构消息列表(引入 z-paging) 2024-07-16 16:23:49 +08:00
puhui999 c8feb58a46 客服:重构消息列表渲染逻辑和样式 2024-07-15 18:05:59 +08:00
puhui999 a95a04085b 客服:重构消息列表渲染逻辑和样式 2024-07-15 11:53:35 +08:00
puhui999 7baec78c02 完善 websocket 封装 2024-07-12 17:43:08 +08:00
puhui999 9399a3e223 websocket 初步封装 2024-07-11 17:19:21 +08:00
puhui999 39440c1e67 客服:完善订单消息发送 2024-07-09 17:34:18 +08:00
puhui999 b630d26d4b 客服:完善商品消息发送 2024-07-09 16:17:56 +08:00
WCH 72628f7e1c 复制订单信息失败,字段错误
Signed-off-by: WCH <wchhm8050@163.com>
2024-07-08 09:13:27 +00:00
puhui999 d9efd4e2e6 客服:完善图片消息发送 2024-07-02 17:32:36 +08:00
puhui999 166030e3b8 客服:使用轮训模拟 websocket 2024-07-02 17:10:17 +08:00
puhui999 a4a8548b5a 客服:根据代码评审进行 fix 2024-06-24 16:07:04 +08:00
puhui999 9c03966e12 Merge remote-tracking branch 'refs/remotes/yudao/master' 2024-06-17 17:34:14 +08:00
puhui999 793252e4aa 客服:完善消息发送(支持文本和表情)、聊天消息获取 2024-06-17 17:33:30 +08:00
puhui999 760dad0436 客服:聊天消息区域抽离封装 2024-06-12 17:05:14 +08:00
puhui999 3355f5e853 客服:组件初步抽离 2024-06-07 11:29:01 +08:00
芋道源码 6550441bc9 !55 解析分享参数,绑定推广员
Merge pull request !55 from puhui999/master
2024-05-27 13:57:30 +00:00
puhui999 561529b01e Merge remote-tracking branch 'refs/remotes/yudao/master' 2024-05-27 16:13:57 +08:00
puhui999 4d92eaf5f9 fix: 修复二次生成海报时 image 组件会闪一下的 bug 2024-05-27 16:13:33 +08:00
puhui999 950acf5275 解析分享参数,绑定推广员 2024-05-27 15:56:50 +08:00
puhui999 f00a8052ea 海报:修复海报保存报错 2024-05-27 15:26:05 +08:00
芋道源码 c3b1ff4b92 !54 海报:完善小程序码(添加前缀 data:image/png;base64,)
Merge pull request !54 from puhui999/master
2024-05-26 01:52:49 +00:00
puhui999 6b8a4fec29 Merge remote-tracking branch 'refs/remotes/yudao/master' 2024-05-25 22:20:45 +08:00
puhui999 2dfe61503f 海报:完善小程序码(添加前缀 data:image/png;base64,) 2024-05-25 22:20:19 +08:00
芋道源码 521553e2f5 !53 完善用户、商品、拼团海报生成
Merge pull request !53 from puhui999/master
2024-05-18 13:10:17 +00:00
puhui999 f46bfc6ee1 海报:完善拼团分享海报 2024-05-16 16:53:03 +08:00
puhui999 03512800b2 海报:完善用户分享海报 2024-05-16 16:29:14 +08:00
puhui999 ccd033b15a 海报:完善海报生成展示组件 2024-05-16 16:15:56 +08:00
puhui999 dabefbe21c 海报:完善商品海报分享 2024-05-14 23:23:31 +08:00
puhui999 8963ebc6af 海报:移除 qs-canvas 库,采用社区活跃的 lime-painter 来实现海报生成 2024-05-13 18:12:42 +08:00
puhui999 004294ae7c 海报:完善小程序码获取和海报生成 2024-05-13 16:22:55 +08:00
puhui999 8815972188 fix: 移除多余权限配置 2024-05-11 09:27:53 +08:00
113 changed files with 13381 additions and 2593 deletions

No files matched your search

+7 -1
View File
@@ -11,6 +11,9 @@ SHOPRO_DEV_BASE_URL = http://127.0.0.1:48080
# 后端接口前缀(一般不建议调整)
SHOPRO_API_PATH = /app-api
# 后端 websocket 接口前缀
SHOPRO_WEBSOCKET_PATH = /infra/ws
# 开发环境运行端口
SHOPRO_DEV_PORT = 3000
@@ -18,4 +21,7 @@ SHOPRO_DEV_PORT = 3000
SHOPRO_STATIC_URL = https://file.sheepjs.com
# 是否开启直播 1 开启直播 | 0 关闭直播 (小程序官方后台未审核开通直播权限时请勿开启)
SHOPRO_MPLIVE_ON = 0
SHOPRO_MPLIVE_ON = 0
# 租户ID 默认 1
SHOPRO_TENANT_ID = 1
-1
View File
@@ -5,7 +5,6 @@ deploy.sh
.hbuilderx/
.vscode/
**/.DS_Store
.env
yarn.lock
package-lock.json
*.keystore
+1 -2
View File
@@ -57,7 +57,6 @@
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
"<uses-permission android:name=\"android.permission.SEND_SMS\"/>",
"<uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
@@ -185,7 +184,7 @@
"versionCode": 100
},
"mp-weixin": {
"appid": "wx63c280fe3248a3e7",
"appid": "wx66186af0759f47c9",
"setting": {
"urlCheck": false,
"minified": true,
+1 -3
View File
@@ -2,7 +2,7 @@
"id": "shopro",
"name": "shopro",
"displayName": "芋道商城",
"version": "2.1.0",
"version": "2.2.0",
"description": "芋道商城,一套代码,同时发行到iOS、Android、H5、微信小程序多个平台,请使用手机扫码快速体验强大功能",
"scripts": {
"prettier": "prettier --write \"{pages,sheep}/**/*.{js,json,tsx,css,less,scss,vue,html,md}\""
@@ -88,13 +88,11 @@
}
},
"dependencies": {
"@hyoga/uni-socket.io": "^1.0.1",
"dayjs": "^1.11.7",
"lodash": "^4.17.21",
"luch-request": "^3.0.8",
"pinia": "^2.0.33",
"pinia-plugin-persist-uni": "^1.2.0",
"qs-canvas": "^1.0.11",
"weixin-js-sdk": "^1.6.0"
},
"devDependencies": {
+1 -1
View File
@@ -652,4 +652,4 @@
}
]
}
}
}
+8 -50
View File
@@ -1,23 +1,15 @@
<template>
<view class="goods ss-flex">
<image class="image" :src="sheep.$url.cdn(goodsData.image)" mode="aspectFill"> </image>
<view class="ss-flex-1">
<view class="title ss-line-2">
{{ goodsData.title }}
</view>
<view v-if="goodsData.subtitle" class="subtitle ss-line-1">
{{ goodsData.subtitle }}
</view>
<view class="price ss-m-t-8">
¥{{ isArray(goodsData.price) ? goodsData.price[0] : goodsData.price }}
</view>
</view>
</view>
<s-goods-item
:title="goodsData.spuName"
:img="goodsData.picUrl"
:price="goodsData.price"
:skuText="goodsData.introduction"
priceColor="#FF3000"
:titleWidth="400"
/>
</template>
<script setup>
import sheep from '@/sheep';
import { isArray } from 'lodash';
const props = defineProps({
goodsData: {
@@ -27,37 +19,3 @@
});
</script>
<style lang="scss" scoped>
.goods {
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
.image {
width: 116rpx;
height: 116rpx;
flex-shrink: 0;
margin-right: 20rpx;
}
.title {
height: 64rpx;
line-height: 32rpx;
font-size: 26rpx;
font-weight: 500;
color: #333;
}
.subtitle {
font-size: 24rpx;
font-weight: 400;
color: #999;
}
.price {
font-size: 26rpx;
font-weight: 500;
color: #ff3000;
}
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<template>
<view class="send-wrap ss-flex">
<view class="left ss-flex ss-flex-1">
<uni-easyinput
class="ss-flex-1 ss-p-l-22"
:inputBorder="false"
:clearable="false"
v-model="message"
placeholder="请输入你要咨询的问题"
></uni-easyinput>
</view>
<text class="sicon-basic bq" @tap.stop="onTools('emoji')"></text>
<text
v-if="!message"
class="sicon-edit"
:class="{ 'is-active': toolsMode === 'tools' }"
@tap.stop="onTools('tools')"
></text>
<button v-if="message" class="ss-reset-button send-btn" @tap="sendMessage">
发送
</button>
</view>
</template>
<script setup>
import { computed } from 'vue';
/**
* 消息发送组件
*/
const props = defineProps({
// 消息
modelValue: {
type: String,
default: '',
},
// 工具模式
toolsMode: {
type: String,
default: '',
},
});
const emits = defineEmits(['update:modelValue', 'onTools', 'sendMessage']);
const message = computed({
get() {
return props.modelValue;
},
set(newValue) {
emits(`update:modelValue`, newValue);
}
});
// 打开工具菜单
function onTools(mode) {
emits('onTools', mode);
}
// 发送消息
function sendMessage() {
emits('sendMessage');
}
</script>
<style scoped lang="scss">
.send-wrap {
padding: 18rpx 20rpx;
background: #fff;
.left {
height: 64rpx;
border-radius: 32rpx;
background: var(--ui-BG-1);
}
.bq {
font-size: 50rpx;
margin-left: 10rpx;
}
.sicon-edit {
font-size: 50rpx;
margin-left: 10rpx;
transform: rotate(0deg);
transition: all linear 0.2s;
&.is-active {
transform: rotate(45deg);
}
}
.send-btn {
width: 100rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
font-size: 26rpx;
color: #fff;
margin-left: 11rpx;
}
}
</style>
+94
View File
@@ -0,0 +1,94 @@
<template>
<!-- 聊天虚拟列表 -->
<z-paging ref="pagingRef" v-model="messageList" use-chat-record-mode use-virtual-list
cell-height-mode="dynamic" default-page-size="20" :auto-clean-list-when-reload="false"
safe-area-inset-bottom bottom-bg-color="#f8f8f8" :back-to-top-style="backToTopStyle"
:auto-show-back-to-top="showNewMessageTip" @backToTopClick="onBackToTopClick"
@scrolltoupper="onScrollToUpper" @query="queryList">
<template #top>
<!-- 撑一下顶部导航 -->
<view style="height: 45px"></view>
</template>
<!-- style="transform: scaleY(-1)"必须写,否则会导致列表倒置!!! -->
<!-- 注意不要直接在chat-item组件标签上设置style,因为在微信小程序中是无效的,请包一层view -->
<template #cell="{item,index}">
<view style="transform: scaleY(-1)">
<!-- 消息渲染 -->
<MessageListItem :message="item" :message-index="index" :message-list="messageList"></MessageListItem>
</view>
</template>
<!-- 底部聊天输入框 -->
<template #bottom>
<slot name="bottom"></slot>
</template>
<!-- 查看最新消息 -->
<template #backToTop>
<text>有新消息</text>
</template>
</z-paging>
</template>
<script setup>
import MessageListItem from '@/pages/chat/components/messageListItem.vue';
import { reactive, ref } from 'vue';
import KeFuApi from '@/sheep/api/promotion/kefu';
import { isEmpty } from '@/sheep/helper/utils';
const messageList = ref([]); // 消息列表
const showNewMessageTip = ref(false); // 显示有新消息提示
const backToTopStyle = reactive({
'width': '100px',
'background-color': '#fff',
'border-radius': '30px',
'box-shadow': '0 2px 4px rgba(0, 0, 0, 0.1)',
'display': 'flex',
'justifyContent': 'center',
'alignItems': 'center',
}); // 返回顶部样式
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
});
const pagingRef = ref(null); // 虚拟列表
const queryList = async (pageNo, pageSize) => {
// 组件加载时会自动触发此方法,因此默认页面加载时会自动触发,无需手动调用
// 这里的pageNo和pageSize会自动计算好,直接传给服务器即可
queryParams.pageNo = pageNo;
queryParams.pageSize = pageSize;
await getMessageList();
};
// 获得消息分页列表
const getMessageList = async () => {
const { data } = await KeFuApi.getKefuMessagePage(queryParams);
if (isEmpty(data.list)) {
return;
}
pagingRef.value.completeByTotal(data.list, data.total);
};
/** 刷新消息列表 */
const refreshMessageList = (message = undefined) => {
if (queryParams.pageNo != 1 && message !== undefined) {
showNewMessageTip.value = true;
// 追加数据
pagingRef.value.addChatRecordData([message], false);
return;
}
pagingRef.value.reload();
};
/** 滚动到最新消息 */
const onBackToTopClick = (event) => {
event(false); // 禁用默认操作
pagingRef.value.scrollToBottom();
};
/** 监听滚动到底部事件(因为 scroll 翻转了顶就是底) */
const onScrollToUpper = () => {
// 若已是第一页则不做处理
if (queryParams.pageNo === 1) {
return;
}
showNewMessageTip.value = false;
// 到底重置消息列表
refreshMessageList();
};
defineExpose({ getMessageList, refreshMessageList });
</script>
+296
View File
@@ -0,0 +1,296 @@
<template>
<view class="chat-box">
<!-- 消息渲染 -->
<view class="message-item ss-flex-col scroll-item">
<view class="ss-flex ss-row-center ss-col-center">
<!-- 日期 -->
<view v-if="message.contentType !== KeFuMessageContentTypeEnum.SYSTEM && showTime(message, messageIndex)"
class="date-message">
{{ formatDate(message.createTime) }}
</view>
<!-- 系统消息 -->
<view v-if="message.contentType === KeFuMessageContentTypeEnum.SYSTEM" class="system-message">
{{ message.content }}
</view>
</view>
<!-- 消息体渲染管理员消息和用户消息并左右展示 -->
<view
v-if="message.contentType !== KeFuMessageContentTypeEnum.SYSTEM"
class="ss-flex ss-col-top"
:class="[
message.senderType === UserTypeEnum.ADMIN
? `ss-row-left`
: message.senderType === UserTypeEnum.MEMBER
? `ss-row-right`
: '',
]"
>
<!-- 客服头像 -->
<image
v-show="message.senderType === UserTypeEnum.ADMIN"
class="chat-avatar ss-m-r-24"
:src="
sheep.$url.cdn(message.senderAvatar) ||
sheep.$url.static('/static/img/shop/chat/default.png')
"
mode="aspectFill"
></image>
<!-- 内容 -->
<template v-if="message.contentType === KeFuMessageContentTypeEnum.TEXT">
<view class="message-box" :class="{'admin': message.senderType === UserTypeEnum.ADMIN}">
<mp-html :content="replaceEmoji(message.content)" />
</view>
</template>
<template v-if="message.contentType === KeFuMessageContentTypeEnum.IMAGE">
<view class="message-box" :class="{'admin': message.senderType === UserTypeEnum.ADMIN}"
:style="{ width: '200rpx' }">
<su-image
class="message-img"
isPreview
:previewList="[sheep.$url.cdn(message.content)]"
:current="0"
:src="sheep.$url.cdn(message.content)"
:height="200"
:width="200"
mode="aspectFill"
></su-image>
</view>
</template>
<template v-if="message.contentType === KeFuMessageContentTypeEnum.PRODUCT">
<GoodsItem
:goodsData="getMessageContent(message)"
@tap="
sheep.$router.go('/pages/goods/index', {
id: getMessageContent(message).id,
})
"
/>
</template>
<template v-if="message.contentType === KeFuMessageContentTypeEnum.ORDER">
<OrderItem
:orderData="getMessageContent(message)"
@tap="
sheep.$router.go('/pages/order/detail', {
id: getMessageContent(message).id,
})
"
/>
</template>
<!-- user头像 -->
<image
v-if="message.senderType === UserTypeEnum.MEMBER"
class="chat-avatar ss-m-l-24"
:src="sheep.$url.cdn(message.senderAvatar) ||
sheep.$url.static('/static/img/shop/chat/default.png')"
mode="aspectFill"
>
</image>
</view>
</view>
</view>
</template>
<script setup>
import { computed, unref } from 'vue';
import dayjs from 'dayjs';
import { KeFuMessageContentTypeEnum, UserTypeEnum } from '@/pages/chat/util/constants';
import { emojiList } from '@/pages/chat/util/emoji';
import sheep from '@/sheep';
import { formatDate } from '@/sheep/util';
import GoodsItem from '@/pages/chat/components/goods.vue';
import OrderItem from '@/pages/chat/components/order.vue';
const props = defineProps({
// 消息
message: {
type: Object,
default: ()=>({}),
},
// 消息索引
messageIndex: {
type: Number,
default: 0,
},
// 消息列表
messageList:{
type: Array,
default: () => [],
}
});
const getMessageContent = computed(() => (item) => JSON.parse(item.content)); // 解析消息内容
//======================= 工具 =======================
const showTime = computed(() => (item, index) => {
if (unref(props.messageList)[index + 1]) {
let dateString = dayjs(unref(props.messageList)[index + 1].createTime).fromNow();
return dateString !== dayjs(unref(item).createTime).fromNow();
}
return false;
});
// 处理表情
function replaceEmoji(data) {
let newData = data;
if (typeof newData !== 'object') {
let reg = /\[(.+?)]/g; // [] 中括号
let zhEmojiName = newData.match(reg);
if (zhEmojiName) {
zhEmojiName.forEach((item) => {
let emojiFile = selEmojiFile(item);
newData = newData.replace(
item,
`<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${sheep.$url.cdn(
'/static/img/chat/emoji/' + emojiFile,
)}"/>`,
);
});
}
}
return newData;
}
function selEmojiFile(name) {
for (let index in emojiList) {
if (emojiList[index].name === name) {
return emojiList[index].file;
}
}
return false;
}
</script>
<style scoped lang="scss">
.message-item {
margin-bottom: 33rpx;
}
.date-message,
.system-message {
width: fit-content;
border-radius: 12rpx;
padding: 8rpx 16rpx;
margin-bottom: 16rpx;
background-color: var(--ui-BG-3);
color: #999;
font-size: 24rpx;
}
.chat-avatar {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
}
.send-status {
color: #333;
height: 80rpx;
margin-right: 8rpx;
display: flex;
align-items: center;
.loading {
width: 32rpx;
height: 32rpx;
-webkit-animation: rotating 2s linear infinite;
animation: rotating 2s linear infinite;
@-webkit-keyframes rotating {
0% {
transform: rotateZ(0);
}
100% {
transform: rotateZ(360deg);
}
}
@keyframes rotating {
0% {
transform: rotateZ(0);
}
100% {
transform: rotateZ(360deg);
}
}
}
.warning {
width: 32rpx;
height: 32rpx;
color: #ff3000;
}
}
.message-box {
max-width: 50%;
font-size: 16px;
line-height: 20px;
white-space: normal;
word-break: break-all;
word-wrap: break-word;
padding: 20rpx;
border-radius: 10rpx;
color: #fff;
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
&.admin {
background: #fff;
color: #333;
}
:deep() {
.imgred {
width: 100%;
}
.imgred,
img {
width: 100%;
}
}
}
:deep() {
.goods,
.order {
max-width: 500rpx;
}
}
.message-img {
width: 100px;
height: 100px;
border-radius: 6rpx;
}
.template-wrap {
// width: 100%;
padding: 20rpx 24rpx;
background: #fff;
border-radius: 10rpx;
.title {
font-size: 26rpx;
font-weight: 500;
color: #333;
margin-bottom: 29rpx;
}
.item {
font-size: 24rpx;
color: var(--ui-BG-Main);
margin-bottom: 16rpx;
&:last-of-type {
margin-bottom: 0;
}
}
}
.error-img {
width: 400rpx;
height: 400rpx;
}
</style>
+80 -88
View File
@@ -1,49 +1,36 @@
<template>
<view class="order">
<view class="top ss-flex ss-row-between">
<span>{{ orderData.order_sn }}</span>
<span>{{ orderData.create_time.split(' ')[1] }}</span>
<view class="bg-white order-list-card-box ss-r-10 ss-m-t-14 ss-m-20"
:key="orderData.id">
<view class="order-card-header ss-flex ss-col-center ss-row-between ss-p-x-20">
<view class="order-no">订单号:{{ orderData.no }}</view>
<view class="order-state ss-font-26" :class="formatOrderColor(orderData)">
{{ formatOrderStatus(orderData) }}
</view>
</view>
<template v-if="from != 'msg'">
<view class="bottom ss-flex" v-for="item in orderData.items" :key="item">
<image class="image" :src="sheep.$url.cdn(item.goods_image)" mode="aspectFill"> </image>
<view class="ss-flex-1">
<view class="title ss-line-2">
{{ item.goods_title }}
</view>
<view v-if="item.goods_num" class="num ss-m-b-10"> 数量:{{ item.goods_num }} </view>
<view class="ss-flex ss-row-between ss-m-t-8">
<span class="price">¥{{ item.goods_price }}</span>
<span class="status">{{ orderData.status_text }}</span>
</view>
<view class="border-bottom" v-for="item in orderData.items" :key="item.id">
<s-goods-item
:img="item.picUrl"
:title="item.spuName"
:skuText="item.properties.map((property) => property.valueName).join(' ')"
:price="item.price"
:num="item.count"
/>
</view>
<view class="pay-box ss-m-t-30 ss-flex ss-row-right ss-p-r-20">
<view class="ss-flex ss-col-center">
<view class="discounts-title pay-color">共 {{ orderData.productCount }} 件商品,总金额:</view>
<view class="discounts-money pay-color">
¥{{ fen2yuan(orderData.payPrice) }}
</view>
</view>
</template>
<template v-else>
<view class="bottom ss-flex" v-for="item in [orderData.items[0]]" :key="item">
<image class="image" :src="sheep.$url.cdn(item.goods_image)" mode="aspectFill"> </image>
<view class="ss-flex-1">
<view class="title title-1 ss-line-1">
{{ item.goods_title }}
</view>
<view class="order-total ss-flex ss-row-between ss-m-t-8">
<span>共{{ orderData.items.length }}件商品</span>
<span>合计 ¥{{ orderData.pay_fee }}</span>
</view>
<view class="ss-flex ss-row-right ss-m-t-8">
<span class="status">{{ orderData.status_text }}</span>
</view>
</view>
</view>
</template>
</view>
</view>
</template>
<script setup>
import sheep from '@/sheep';
import { fen2yuan, formatOrderColor, formatOrderStatus } from '@/sheep/hooks/useGoods';
const props = defineProps({
from: String,
orderData: {
type: Object,
default: {},
@@ -52,71 +39,76 @@
</script>
<style lang="scss" scoped>
.order {
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
.order-list-card-box {
.order-card-header {
height: 80rpx;
.top {
line-height: 40rpx;
font-size: 24rpx;
font-weight: 400;
color: #999;
border-bottom: 1px solid rgba(223, 223, 223, 0.5);
margin-bottom: 20rpx;
}
.bottom {
margin-bottom: 20rpx;
&:last-of-type {
margin-bottom: 0;
}
.image {
flex-shrink: 0;
width: 116rpx;
height: 116rpx;
margin-right: 20rpx;
}
.title {
height: 64rpx;
line-height: 32rpx;
.order-no {
font-size: 26rpx;
font-weight: 500;
color: #333;
}
&.title-1 {
height: 32rpx;
width: 300rpx;
.order-state {}
}
.pay-box {
.discounts-title {
font-size: 24rpx;
line-height: normal;
color: #999999;
}
.discounts-money {
font-size: 24rpx;
line-height: normal;
color: #999;
font-family: OPPOSANS;
}
.pay-color {
color: #333;
}
}
.order-card-footer {
height: 100rpx;
.more-item-box {
padding: 20rpx;
.more-item {
height: 60rpx;
.title {
font-size: 26rpx;
}
}
}
.num {
.more-btn {
color: $dark-9;
font-size: 24rpx;
font-weight: 400;
color: #999;
}
.price {
.content {
width: 154rpx;
color: #333333;
font-size: 26rpx;
font-weight: 500;
color: #ff3000;
}
.status {
font-size: 24rpx;
font-weight: 500;
color: var(--ui-BG-Main);
}
.order-total {
line-height: 28rpx;
font-size: 24rpx;
font-weight: 400;
color: #999;
}
}
}
.warning-color {
color: #faad14;
}
.danger-color {
color: #ff3000;
}
.success-color {
color: #52c41a;
}
.info-color {
color: #999999;
}
</style>
+3 -4
View File
@@ -14,11 +14,11 @@
<view
class="item"
v-for="item in state.pagination.data"
:key="item"
:key="item.id"
@tap="emits('select', { type: mode, data: item })"
>
<template v-if="mode == 'goods'">
<GoodsItem :goodsData="item.goods" />
<GoodsItem :goodsData="item" />
</template>
<template v-if="mode == 'order'">
<OrderItem :orderData="item" />
@@ -32,7 +32,6 @@
<script setup>
import { reactive, watch } from 'vue';
import sheep from '@/sheep';
import _ from 'lodash';
import GoodsItem from './goods.vue';
import OrderItem from './order.vue';
@@ -83,7 +82,7 @@
page,
list_rows,
});
let orderList = _.concat(state.pagination.data, res.data.data);
let orderList = _.concat(state.pagination.data, res.data.list);
state.pagination = {
...res.data,
data: orderList,
+166
View File
@@ -0,0 +1,166 @@
<template>
<su-popup
:show="showTools"
@close="handleClose"
>
<view class="ss-modal-box ss-flex-col">
<slot></slot>
<view class="content ss-flex ss-flex-1">
<template v-if="toolsMode === 'emoji'">
<swiper
class="emoji-swiper"
:indicator-dots="true"
circular
indicator-active-color="#7063D2"
indicator-color="rgba(235, 231, 255, 1)"
:autoplay="false"
:interval="3000"
:duration="1000"
>
<swiper-item v-for="emoji in emojiPage" :key="emoji">
<view class="ss-flex ss-flex-wrap">
<image
v-for="item in emoji" :key="item"
class="emoji-img"
:src="sheep.$url.cdn(`/static/img/chat/emoji/${item.file}`)"
@tap="onEmoji(item)"
>
</image>
</view>
</swiper-item>
</swiper>
</template>
<template v-else>
<view class="image">
<s-uploader
file-mediatype="image"
:imageStyles="{ width: 50, height: 50, border: false }"
@select="imageSelect({ type: 'image', data: $event })"
>
<image
class="icon"
:src="sheep.$url.static('/static/img/shop/chat/image.png')"
mode="aspectFill"
></image>
</s-uploader>
<view>图片</view>
</view>
<view class="goods" @tap="onShowSelect('goods')">
<image
class="icon"
:src="sheep.$url.static('/static/img/shop/chat/goods.png')"
mode="aspectFill"
></image>
<view>商品</view>
</view>
<view class="order" @tap="onShowSelect('order')">
<image
class="icon"
:src="sheep.$url.static('/static/img/shop/chat/order.png')"
mode="aspectFill"
></image>
<view>订单</view>
</view>
</template>
</view>
</view>
</su-popup>
</template>
<script setup>
/**
* 聊天工具
*/
import { emojiPage } from '@/pages/chat/util/emoji';
import sheep from '@/sheep';
const props = defineProps({
// 工具模式
toolsMode: {
type: String,
default: '',
},
// 控制工具菜单弹出
showTools: {
type: Boolean,
default: () => false,
},
});
const emits = defineEmits(['onEmoji', 'imageSelect', 'onShowSelect', 'close']);
// 关闭弹出工具菜单
function handleClose() {
emits('close');
}
// 选择表情
function onEmoji(emoji) {
emits('onEmoji', emoji);
}
// 选择图片
function imageSelect(val) {
emits('imageSelect', val);
}
// 选择商品或订单
function onShowSelect(mode) {
emits('onShowSelect', mode);
}
</script>
<style scoped lang="scss">
.content {
width: 100%;
align-content: space-around;
border-top: 1px solid #dfdfdf;
padding: 20rpx 0 0;
.emoji-swiper {
width: 100%;
height: 280rpx;
padding: 0 20rpx;
.emoji-img {
width: 50rpx;
height: 50rpx;
display: inline-block;
margin: 10rpx;
}
}
.image,
.goods,
.order {
width: 33.3%;
height: 280rpx;
text-align: center;
font-size: 24rpx;
color: #333;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.icon {
width: 50rpx;
height: 50rpx;
margin-bottom: 21rpx;
}
}
:deep() {
.uni-file-picker__container {
justify-content: center;
}
.file-picker__box {
display: none;
&:last-of-type {
display: flex;
}
}
}
}
</style>
+97 -780
View File
@@ -1,278 +1,19 @@
<template>
<s-layout class="chat-wrap" title="客服" navbar="inner">
<div class="status">
{{ socketState.isConnect ? customerServiceInfo.title : '网络已断开,请检查网络后刷新重试' }}
</div>
<s-layout class="chat-wrap" :title="!isReconnecting ? '连接客服成功' : '会话重连中'" navbar="inner">
<!-- 覆盖头部导航栏背景颜色 -->
<div class="page-bg" :style="{ height: sys_navBar + 'px' }"></div>
<view class="chat-box" :style="{ height: pageHeight + 'px' }">
<scroll-view
:style="{ height: pageHeight + 'px' }"
scroll-y="true"
:scroll-with-animation="false"
:enable-back-to-top="true"
:scroll-into-view="chat.scrollInto"
>
<button
class="loadmore-btn ss-reset-button"
v-if="
chatList.length &&
chatHistoryPagination.lastPage > 1 &&
loadingMap[chatHistoryPagination.loadStatus].title
"
@click="onLoadMore"
>
{{ loadingMap[chatHistoryPagination.loadStatus].title }}
<i
class="loadmore-icon sa-m-l-6"
:class="loadingMap[chatHistoryPagination.loadStatus].icon"
></i>
</button>
<view class="message-item ss-flex-col" v-for="(item, index) in chatList" :key="index">
<view class="ss-flex ss-row-center ss-col-center">
<!-- 日期 -->
<view v-if="item.from !== 'system' && showTime(item, index)" class="date-message">
{{ formatTime(item.date) }}
</view>
<!-- 系统消息 -->
<view v-if="item.from === 'system'" class="system-message">
{{ item.content.text }}
</view>
</view>
<!-- 常见问题 -->
<view v-if="item.mode === 'template' && item.content.list.length" class="template-wrap">
<view class="title">猜你想问</view>
<view
class="item"
v-for="(item, index) in item.content.list"
:key="index"
@click="onTemplateList(item)"
>
* {{ item.title }}
</view>
</view>
<view
v-if="
(item.from === 'customer_service' && item.mode !== 'template') ||
item.from === 'customer'
"
class="ss-flex ss-col-top"
:class="[
item.from === 'customer_service'
? `ss-row-left`
: item.from === 'customer'
? `ss-row-right`
: '',
]"
>
<!-- 客服头像 -->
<image
v-show="item.from === 'customer_service'"
class="chat-avatar ss-m-r-24"
:src="
sheep.$url.cdn(item?.sender?.avatar) ||
sheep.$url.static('/static/img/shop/chat/default.png')
"
mode="aspectFill"
></image>
<!-- 发送状态 -->
<span
v-if="
item.from === 'customer' &&
index == chatData.chatList.length - 1 &&
chatData.isSendSucces !== 0
"
class="send-status"
>
<image
v-if="chatData.isSendSucces == -1"
class="loading"
:src="sheep.$url.static('/static/img/shop/chat/loading.png')"
mode="aspectFill"
></image>
<!-- <image
v-if="chatData.isSendSucces == 1"
class="warning"
:src="sheep.$url.static('/static/img/shop/chat/warning.png')"
mode="aspectFill"
@click="onAgainSendMessage(item)"
></image> -->
</span>
<!-- 内容 -->
<template v-if="item.mode === 'text'">
<view class="message-box" :class="[item.from]">
<div
class="message-text ss-flex ss-flex-wrap"
@click="onRichtext"
v-html="replaceEmoji(item.content.text)"
></div>
</view>
</template>
<template v-if="item.mode === 'image'">
<view class="message-box" :class="[item.from]" :style="{ width: '200rpx' }">
<su-image
class="message-img"
isPreview
:previewList="[sheep.$url.cdn(item.content.url)]"
:current="0"
:src="sheep.$url.cdn(item.content.url)"
:height="200"
:width="200"
mode="aspectFill"
></su-image>
</view>
</template>
<template v-if="item.mode === 'goods'">
<GoodsItem
:goodsData="item.content.item"
@tap="
sheep.$router.go('/pages/goods/index', {
id: item.content.item.id,
})
"
/>
</template>
<template v-if="item.mode === 'order'">
<OrderItem
from="msg"
:orderData="item.content.item"
@tap="
sheep.$router.go('/pages/order/detail', {
id: item.content.item.id,
})
"
/>
</template>
<!-- user头像 -->
<image
v-show="item.from === 'customer'"
class="chat-avatar ss-m-l-24"
:src="sheep.$url.cdn(customerUserInfo.avatar)"
mode="aspectFill"
>
</image>
</view>
</view>
<view id="scrollBottom"></view>
</scroll-view>
</view>
<su-fixed bottom>
<view class="send-wrap ss-flex">
<view class="left ss-flex ss-flex-1">
<uni-easyinput
class="ss-flex-1 ss-p-l-22"
:inputBorder="false"
:clearable="false"
v-model="chat.msg"
placeholder="请输入你要咨询的问题"
></uni-easyinput>
</view>
<text class="sicon-basic bq" @tap.stop="onTools('emoji')"></text>
<text
v-if="!chat.msg"
class="sicon-edit"
:class="{ 'is-active': chat.toolsMode == 'tools' }"
@tap.stop="onTools('tools')"
></text>
<button v-if="chat.msg" class="ss-reset-button send-btn" @tap="onSendMessage">
发送
</button>
</view>
</su-fixed>
<su-popup
:show="chat.showTools"
@close="
chat.showTools = false;
chat.toolsMode = '';
"
>
<view class="ss-modal-box ss-flex-col">
<view class="send-wrap ss-flex">
<view class="left ss-flex ss-flex-1">
<uni-easyinput
class="ss-flex-1 ss-p-l-22"
:inputBorder="false"
:clearable="false"
v-model="chat.msg"
placeholder="请输入你要咨询的问题"
></uni-easyinput>
</view>
<text class="sicon-basic bq" @tap.stop="onTools('emoji')"></text>
<text></text>
<text
v-if="!chat.msg"
class="sicon-edit"
:class="{ 'is-active': chat.toolsMode == 'tools' }"
@tap.stop="onTools('tools')"
></text>
<button v-if="chat.msg" class="ss-reset-button send-btn" @tap="onSendMessage">
发送
</button>
</view>
<view class="content ss-flex ss-flex-1">
<template v-if="chat.toolsMode == 'emoji'">
<swiper
class="emoji-swiper"
:indicator-dots="true"
circular
indicator-active-color="#7063D2"
indicator-color="rgba(235, 231, 255, 1)"
:autoplay="false"
:interval="3000"
:duration="1000"
>
<swiper-item v-for="emoji in emojiPage" :key="emoji">
<view class="ss-flex ss-flex-wrap">
<template v-for="item in emoji" :key="item">
<image
class="emoji-img"
:src="sheep.$url.cdn(`/static/img/chat/emoji/${item.file}`)"
@tap="onEmoji(item)"
>
</image>
</template>
</view>
</swiper-item>
</swiper>
</template>
<template v-else>
<view class="image">
<s-uploader
file-mediatype="image"
:imageStyles="{ width: 50, height: 50, border: false }"
@select="onSelect({ type: 'image', data: $event })"
>
<image
class="icon"
:src="sheep.$url.static('/static/img/shop/chat/image.png')"
mode="aspectFill"
></image>
</s-uploader>
<view>图片</view>
</view>
<view class="goods" @tap="onShowSelect('goods')">
<image
class="icon"
:src="sheep.$url.static('/static/img/shop/chat/goods.png')"
mode="aspectFill"
></image>
<view>商品</view>
</view>
<view class="order" @tap="onShowSelect('order')">
<image
class="icon"
:src="sheep.$url.static('/static/img/shop/chat/order.png')"
mode="aspectFill"
></image>
<view>订单</view>
</view>
</template>
</view>
</view>
</su-popup>
<!-- 聊天区域 -->
<MessageList ref="messageListRef">
<template #bottom>
<message-input v-model="chat.msg" @on-tools="onTools" @send-message="onSendMessage"></message-input>
</template>
</MessageList>
<!-- 聊天工具 -->
<tools-popup :show-tools="chat.showTools" :tools-mode="chat.toolsMode" @close="handleToolsClose"
@on-emoji="onEmoji" @image-select="onSelect" @on-show-select="onShowSelect">
<message-input v-model="chat.msg" @on-tools="onTools" @send-message="onSendMessage"></message-input>
</tools-popup>
<!-- 商品订单选择 -->
<SelectPopup
:mode="chat.selectMode"
:show="chat.showSelect"
@@ -283,99 +24,61 @@
</template>
<script setup>
import MessageList from '@/pages/chat/components/messageList.vue';
import { reactive, ref, toRefs } from 'vue';
import sheep from '@/sheep';
import { computed, reactive, toRefs } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { emojiList, emojiPage } from './emoji.js';
import SelectPopup from './components/select-popup.vue';
import GoodsItem from './components/goods.vue';
import OrderItem from './components/order.vue';
import { useChatWebSocket } from './socket';
const {
socketInit,
state: chatData,
socketSendMsg,
formatChatInput,
socketHistoryList,
onDrop,
onPaste,
getFocus,
// upload,
getUserToken,
// socketTest,
showTime,
formatTime,
} = useChatWebSocket();
const chatList = toRefs(chatData).chatList;
const customerServiceInfo = toRefs(chatData).customerServerInfo;
const chatHistoryPagination = toRefs(chatData).chatHistoryPagination;
const customerUserInfo = toRefs(chatData).customerUserInfo;
const socketState = toRefs(chatData).socketState;
import ToolsPopup from '@/pages/chat/components/toolsPopup.vue';
import MessageInput from '@/pages/chat/components/messageInput.vue';
import SelectPopup from '@/pages/chat/components/select-popup.vue';
import { KeFuMessageContentTypeEnum, WebSocketMessageTypeConstants } from '@/pages/chat/util/constants';
import FileApi from '@/sheep/api/infra/file';
import KeFuApi from '@/sheep/api/promotion/kefu';
import { useWebSocket } from '@/sheep/hooks/useWebSocket';
const sys_navBar = sheep.$platform.navbar;
const chatConfig = computed(() => sheep.$store('app').chat);
const { screenHeight, safeAreaInsets, safeArea, screenWidth } = sheep.$platform.device;
const pageHeight = safeArea.height - 44 - 35 - 50;
const chatStatus = {
online: {
text: '在线',
colorVariate: '#46c55f',
},
offline: {
text: '离线',
colorVariate: '#b5b5b5',
},
busy: {
text: '忙碌',
colorVariate: '#ff0e1b',
},
};
// 加载更多
const loadingMap = {
loadmore: {
title: '查看更多',
icon: 'el-icon-d-arrow-left',
},
nomore: {
title: '没有更多了',
icon: '',
},
loading: {
title: '加载中... ',
icon: 'el-icon-loading',
},
};
const onLoadMore = () => {
chatHistoryPagination.value.page < chatHistoryPagination.value.lastPage && socketHistoryList();
};
const chat = reactive({
msg: '',
scrollInto: '',
showTools: false,
toolsMode: '',
showSelect: false,
selectMode: '',
chatStyle: {
mode: 'inner',
color: '#F8270F',
type: 'color',
alwaysShow: 1,
src: '',
list: {},
},
});
// 发送消息
async function onSendMessage() {
if (!chat.msg) return;
try {
const data = {
contentType: KeFuMessageContentTypeEnum.TEXT,
content: chat.msg,
};
await KeFuApi.sendKefuMessage(data);
await messageListRef.value.refreshMessageList();
chat.msg = '';
} finally {
chat.showTools = false;
}
}
const messageListRef = ref();
//======================= 聊天工具相关 start =======================
function handleToolsClose() {
chat.showTools = false;
chat.toolsMode = '';
}
function onEmoji(item) {
chat.msg += item.name;
}
// 点击工具栏开关
function onTools(mode) {
if (!socketState.value.isConnect) {
sheep.$helper.toast(socketState.value.tip || '您已掉线!请返回重试');
if (isReconnecting.value) {
sheep.$helper.toast('您已掉线!请返回重试');
return;
}
@@ -395,205 +98,77 @@
}
async function onSelect({ type, data }) {
let msg = '';
let msg;
switch (type) {
case 'image':
const { path, fullurl } = await sheep.$api.app.upload(data.tempFiles[0].path, 'default');
const res = await FileApi.uploadFile(data.tempFiles[0].path);
msg = {
from: 'customer',
mode: 'image',
date: new Date().getTime(),
content: {
url: fullurl,
path: path,
},
contentType: KeFuMessageContentTypeEnum.IMAGE,
content: res.data,
};
break;
case 'goods':
msg = {
from: 'customer',
mode: 'goods',
date: new Date().getTime(),
content: {
item: {
id: data.goods.id,
title: data.goods.title,
image: data.goods.image,
price: data.goods.price,
stock: data.goods.stock,
},
},
contentType: KeFuMessageContentTypeEnum.PRODUCT,
content: JSON.stringify(data),
};
break;
case 'order':
msg = {
from: 'customer',
mode: 'order',
date: new Date().getTime(),
content: {
item: {
id: data.id,
order_sn: data.order_sn,
create_time: data.create_time,
pay_fee: data.pay_fee,
items: data.items.filter((item) => ({
goods_id: item.goods_id,
goods_title: item.goods_title,
goods_image: item.goods_image,
goods_price: item.goods_price,
})),
status_text: data.status_text,
},
},
contentType: KeFuMessageContentTypeEnum.ORDER,
content: JSON.stringify(data),
};
break;
}
if (msg) {
socketSendMsg(msg, () => {
scrollBottom();
});
// 发送消息
// scrollBottom();
await KeFuApi.sendKefuMessage(msg);
await messageListRef.value.refreshMessageList();
chat.showTools = false;
chat.showSelect = false;
chat.selectMode = '';
}
}
function onAgainSendMessage(item) {
if (!socketState.value.isConnect) {
sheep.$helper.toast(socketState.value.tip || '您已掉线!请返回重试');
return;
}
if (!item) return;
const data = {
from: 'customer',
mode: 'text',
date: new Date().getTime(),
content: item.content,
};
socketSendMsg(data, () => {
scrollBottom();
});
}
function onSendMessage() {
if (!socketState.value.isConnect) {
sheep.$helper.toast(socketState.value.tip || '您已掉线!请返回重试');
return;
}
if (!chat.msg) return;
const data = {
from: 'customer',
mode: 'text',
date: new Date().getTime(),
content: {
text: chat.msg,
},
};
socketSendMsg(data, () => {
scrollBottom();
});
chat.showTools = false;
// scrollBottom();
setTimeout(() => {
chat.msg = '';
}, 100);
}
// 点击猜你想问
function onTemplateList(e) {
if (!socketState.value.isConnect) {
sheep.$helper.toast(socketState.value.tip || '您已掉线!请返回重试');
return;
}
const data = {
from: 'customer',
mode: 'text',
date: new Date().getTime(),
content: {
text: e.title,
},
customData: {
question_id: e.id,
},
};
socketSendMsg(data, () => {
scrollBottom();
});
// scrollBottom();
}
function onEmoji(item) {
chat.msg += item.name;
}
function selEmojiFile(name) {
for (let index in emojiList) {
if (emojiList[index].name === name) {
return emojiList[index].file;
//======================= 聊天工具相关 end =======================
const { options } = useWebSocket({
// 连接成功
onConnected: async () => {
},
// 收到消息
onMessage: async (data) => {
const type = data.type;
if (!type) {
console.error('未知的消息类型:' + data.value);
return;
}
}
return false;
}
function replaceEmoji(data) {
let newData = data;
if (typeof newData !== 'object') {
let reg = /\[(.+?)\]/g; // [] 中括号
let zhEmojiName = newData.match(reg);
if (zhEmojiName) {
zhEmojiName.forEach((item) => {
let emojiFile = selEmojiFile(item);
newData = newData.replace(
item,
`<img class="chat-img" style="width: 24px;height: 24px;margin: 0 3px;" src="${sheep.$url.cdn(
'/static/img/chat/emoji/' + emojiFile,
)}"/>`,
);
});
// 2.2 消息类型:KEFU_MESSAGE_TYPE
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE) {
// 刷新消息列表
await messageListRef.value.refreshMessageList(JSON.parse(data.content));
return;
}
}
return newData;
}
function scrollBottom() {
let timeout = null;
chat.scrollInto = '';
clearTimeout(timeout);
timeout = setTimeout(() => {
chat.scrollInto = 'scrollBottom';
}, 100);
}
onLoad(async () => {
const { error } = await getUserToken();
if (error === 0) {
socketInit(chatConfig.value, () => {
scrollBottom();
});
} else {
socketState.value.isConnect = false;
}
// 2.3 消息类型:KEFU_MESSAGE_ADMIN_READ
if (type === WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ) {
console.log('管理员已读消息');
}
},
});
const isReconnecting = toRefs(options).isReconnecting; // 重连状态
</script>
<style lang="scss" scoped>
.page-bg {
width: 100%;
position: absolute;
top: 0;
left: 0;
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
background-size: 750rpx 100%;
z-index: 1;
}
<style scoped lang="scss">
.chat-wrap {
// :deep() {
// .ui-navbar-box {
// background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
// }
// }
.page-bg {
width: 100%;
position: absolute;
top: 0;
left: 0;
background-color: var(--ui-BG-Main);
z-index: 1;
}
.status {
position: relative;
@@ -608,263 +183,5 @@
font-weight: 400;
color: var(--ui-BG-Main);
}
.chat-box {
padding: 0 20rpx 0;
.loadmore-btn {
width: 98%;
height: 40px;
font-size: 12px;
color: #8c8c8c;
.loadmore-icon {
transform: rotate(90deg);
}
}
.message-item {
margin-bottom: 33rpx;
}
.date-message,
.system-message {
width: fit-content;
border-radius: 12rpx;
padding: 8rpx 16rpx;
margin-bottom: 16rpx;
background-color: var(--ui-BG-3);
color: #999;
font-size: 24rpx;
}
.chat-avatar {
width: 70rpx;
height: 70rpx;
border-radius: 50%;
}
.send-status {
color: #333;
height: 80rpx;
margin-right: 8rpx;
display: flex;
align-items: center;
.loading {
width: 32rpx;
height: 32rpx;
-webkit-animation: rotating 2s linear infinite;
animation: rotating 2s linear infinite;
@-webkit-keyframes rotating {
0% {
transform: rotateZ(0);
}
100% {
transform: rotateZ(360deg);
}
}
@keyframes rotating {
0% {
transform: rotateZ(0);
}
100% {
transform: rotateZ(360deg);
}
}
}
.warning {
width: 32rpx;
height: 32rpx;
color: #ff3000;
}
}
.message-box {
max-width: 50%;
font-size: 16px;
line-height: 20px;
// max-width: 500rpx;
white-space: normal;
word-break: break-all;
word-wrap: break-word;
padding: 20rpx;
border-radius: 10rpx;
color: #fff;
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
&.customer_service {
background: #fff;
color: #333;
}
:deep() {
.imgred {
width: 100%;
}
.imgred,
img {
width: 100%;
}
}
}
:deep() {
.goods,
.order {
max-width: 500rpx;
}
}
.message-img {
width: 100px;
height: 100px;
border-radius: 6rpx;
}
.template-wrap {
// width: 100%;
padding: 20rpx 24rpx;
background: #fff;
border-radius: 10rpx;
.title {
font-size: 26rpx;
font-weight: 500;
color: #333;
margin-bottom: 29rpx;
}
.item {
font-size: 24rpx;
color: var(--ui-BG-Main);
margin-bottom: 16rpx;
&:last-of-type {
margin-bottom: 0;
}
}
}
.error-img {
width: 400rpx;
height: 400rpx;
}
#scrollBottom {
height: 120rpx;
}
}
.send-wrap {
padding: 18rpx 20rpx;
background: #fff;
.left {
height: 64rpx;
border-radius: 32rpx;
background: var(--ui-BG-1);
}
.bq {
font-size: 50rpx;
margin-left: 10rpx;
}
.sicon-edit {
font-size: 50rpx;
margin-left: 10rpx;
transform: rotate(0deg);
transition: all linear 0.2s;
&.is-active {
transform: rotate(45deg);
}
}
.send-btn {
width: 100rpx;
height: 60rpx;
line-height: 60rpx;
border-radius: 30rpx;
background: linear-gradient(90deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
font-size: 26rpx;
color: #fff;
margin-left: 11rpx;
}
}
}
.content {
width: 100%;
align-content: space-around;
border-top: 1px solid #dfdfdf;
padding: 20rpx 0 0;
.emoji-swiper {
width: 100%;
height: 280rpx;
padding: 0 20rpx;
.emoji-img {
width: 50rpx;
height: 50rpx;
display: inline-block;
margin: 10rpx;
}
}
.image,
.goods,
.order {
width: 33.3%;
height: 280rpx;
text-align: center;
font-size: 24rpx;
color: #333;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.icon {
width: 50rpx;
height: 50rpx;
margin-bottom: 21rpx;
}
}
:deep() {
.uni-file-picker__container {
justify-content: center;
}
.file-picker__box {
display: none;
&:last-of-type {
display: flex;
}
}
}
}
</style>
<style>
.chat-img {
width: 24px;
height: 24px;
margin: 0 3px;
}
.full-img {
object-fit: cover;
width: 100px;
height: 100px;
border-radius: 6px;
}
</style>
-821
View File
@@ -1,821 +0,0 @@
import { reactive, ref, unref } from 'vue';
import sheep from '@/sheep';
// import chat from '@/sheep/api/chat';
import dayjs from 'dayjs';
import io from '@hyoga/uni-socket.io';
export function useChatWebSocket(socketConfig) {
let SocketIo = null;
// chat状态数据
const state = reactive({
chatDotNum: 0, //总状态红点
chatList: [], //会话信息
customerUserInfo: {}, //用户信息
customerServerInfo: {
//客服信息
title: '连接中...',
state: 'connecting',
avatar: null,
nickname: '',
},
socketState: {
isConnect: true, //是否连接成功
isConnecting: false, //重连中,不允许新的socket开启。
tip: '',
},
chatHistoryPagination: {
page: 0, //当前页
list_rows: 10, //每页条数
last_id: 0, //最后条ID
lastPage: 0, //总共多少页
loadStatus: 'loadmore', //loadmore-加载前的状态,loading-加载中的状态,nomore-没有更多的状态
},
templateChatList: [], //猜你想问
chatConfig: {}, // 配置信息
isSendSucces: -1, // 是否发送成功 -1=发送中|0=发送成功|1发送失败
});
/**
* 连接初始化
* @param {Object} config - 配置信息
* @param {Function} callBack -回调函数,有新消息接入,保持底部
*/
const socketInit = (config, callBack) => {
state.chatConfig = config;
if (SocketIo && SocketIo.connected) return; // 如果socket已经连接,返回false
if (state.socketState.isConnecting) return; // 重连中,返回false
// 启动初始化
SocketIo = io(config.chat_domain, {
reconnection: true, // 默认 true 是否断线重连
reconnectionAttempts: 5, // 默认无限次 断线尝试次数
reconnectionDelay: 1000, // 默认 1000,进行下一次重连的间隔。
reconnectionDelayMax: 5000, // 默认 5000, 重新连接等待的最长时间 默认 5000
randomizationFactor: 0.5, // 默认 0.5 [0-1],随机重连延迟时间
timeout: 20000, // 默认 20s
transports: ['websocket', 'polling'], // websocket | polling,
...config,
});
// 监听连接
SocketIo.on('connect', async (res) => {
socketReset(callBack);
// socket连接
// 用户登录
// 顾客登录
console.log('socket:connect');
});
// 监听消息
SocketIo.on('message', (res) => {
if (res.error === 0) {
const { message, sender } = res.data;
state.chatList.push(formatMessage(res.data.message));
// 告诉父级页面
// window.parent.postMessage({
// chatDotNum: ++state.chatDotNum
// })
callBack && callBack();
}
});
// 监听客服接入成功
SocketIo.on('customer_service_access', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: res.data.customer_service.name,
state: 'online',
avatar: res.data.customer_service.avatar,
});
state.chatList.push(formatMessage(res.data.message));
// callBack && callBack()
}
});
// 监听排队等待
SocketIo.on('waiting_queue', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: res.data.title,
state: 'waiting',
avatar: '',
});
// callBack && callBack()
}
});
// 监听没有客服在线
SocketIo.on('no_customer_service', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: '暂无客服在线...',
state: 'waiting',
avatar: '',
});
}
state.chatList.push(formatMessage(res.data.message));
// callBack && callBack()
});
// 监听客服上线
SocketIo.on('customer_service_online', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: res.data.customer_service.name,
state: 'online',
avatar: res.data.customer_service.avatar,
});
}
});
// 监听客服下线
SocketIo.on('customer_service_offline', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: res.data.customer_service.name,
state: 'offline',
avatar: res.data.customer_service.avatar,
});
}
});
// 监听客服忙碌
SocketIo.on('customer_service_busy', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: res.data.customer_service.name,
state: 'busy',
avatar: res.data.customer_service.avatar,
});
}
});
// 监听客服断开链接
SocketIo.on('customer_service_break', (res) => {
if (res.error === 0) {
editCustomerServerInfo({
title: '客服服务结束',
state: 'offline',
avatar: '',
});
state.socketState.isConnect = false;
state.socketState.tip = '当前服务已结束';
}
state.chatList.push(formatMessage(res.data.message));
// callBack && callBack()
});
// 监听自定义错误 custom_error
SocketIo.on('custom_error', (error) => {
editCustomerServerInfo({
title: error.msg,
state: 'offline',
avatar: '',
});
console.log('custom_error:', error);
});
// 监听错误 error
SocketIo.on('error', (error) => {
console.log('error:', error);
});
// 重连失败 connect_error
SocketIo.on('connect_error', (error) => {
console.log('connect_error');
});
// 连接上,但无反应 connect_timeout
SocketIo.on('connect_timeout', (error) => {
console.log(error, 'connect_timeout');
});
// 服务进程销毁 disconnect
SocketIo.on('disconnect', (error) => {
console.log(error, 'disconnect');
});
// 服务重启重连上reconnect
SocketIo.on('reconnect', (error) => {
console.log(error, 'reconnect');
});
// 开始重连reconnect_attempt
SocketIo.on('reconnect_attempt', (error) => {
state.socketState.isConnect = false;
state.socketState.isConnecting = true;
editCustomerServerInfo({
title: `重连中,第${error}次尝试...`,
state: 'waiting',
avatar: '',
});
console.log(error, 'reconnect_attempt');
});
// 重新连接中reconnecting
SocketIo.on('reconnecting', (error) => {
console.log(error, 'reconnecting');
});
// 重新连接错误reconnect_error
SocketIo.on('reconnect_error', (error) => {
console.log('reconnect_error');
});
// 重新连接失败reconnect_failed
SocketIo.on('reconnect_failed', (error) => {
state.socketState.isConnecting = false;
editCustomerServerInfo({
title: `重连失败,请刷新重试~`,
state: 'waiting',
avatar: '',
});
console.log(error, 'reconnect_failed');
// setTimeout(() => {
state.isSendSucces = 1;
// }, 500)
});
};
// 重置socket
const socketReset = (callBack) => {
state.chatList = [];
state.chatHistoryList = [];
state.chatHistoryPagination = {
page: 0,
per_page: 10,
last_id: 0,
totalPage: 0,
};
socketConnection(callBack); // 连接
};
// 退出连接
const socketClose = () => {
SocketIo.emit('customer_logout', {}, (res) => {
console.log('socket:退出', res);
});
};
// 测试事件
const socketTest = () => {
SocketIo.emit('test', {}, (res) => {
console.log('test:test', res);
});
};
// 发送消息
const socketSendMsg = (data, sendMsgCallBack) => {
state.isSendSucces = -1;
state.chatList.push(data);
sendMsgCallBack && sendMsgCallBack();
SocketIo.emit(
'message',
{
message: formatInput(data),
...data.customData,
},
(res) => {
// setTimeout(() => {
state.isSendSucces = res.error;
// }, 500)
// console.log(res, 'socket:send');
// sendMsgCallBack && sendMsgCallBack()
},
);
};
// 连接socket,存入sessionId
const socketConnection = (callBack) => {
SocketIo.emit(
'connection',
{
auth: 'user',
token: uni.getStorageSync('socketUserToken') || '',
session_id: uni.getStorageSync('socketSessionId') || '',
},
(res) => {
if (res.error === 0) {
socketCustomerLogin(callBack);
uni.setStorageSync('socketSessionId', res.data.session_id);
// uni.getStorageSync('socketUserToken') && socketLogin(uni.getStorageSync(
// 'socketUserToken')) // 如果有用户token,绑定
state.customerUserInfo = res.data.chat_user;
state.socketState.isConnect = true;
} else {
editCustomerServerInfo({
title: `服务器异常!`,
state: 'waiting',
avatar: '',
});
state.socketState.isConnect = false;
}
},
);
};
// 用户id,获取token
const getUserToken = async (id) => {
const res = await chat.unifiedToken();
if (res.error === 0) {
uni.setStorageSync('socketUserToken', res.data.token);
// SocketIo && SocketIo.connected && socketLogin(res.data.token)
}
return res;
};
// 用户登录
const socketLogin = (token) => {
SocketIo.emit(
'login',
{
token: token,
},
(res) => {
console.log(res, 'socket:login');
state.customerUserInfo = res.data.chat_user;
},
);
};
// 顾客登录
const socketCustomerLogin = (callBack) => {
SocketIo.emit(
'customer_login',
{
room_id: state.chatConfig.room_id,
},
(res) => {
state.templateChatList = res.data.questions.length ? res.data.questions : [];
state.chatList.push({
from: 'customer_service', // 用户customer右 | 顾客customer_service左 | 系统system中间
mode: 'template', // goods,order,image,text,system
date: new Date().getTime(), //时间
content: {
//内容
list: state.templateChatList,
},
});
res.error === 0 && socketHistoryList(callBack);
},
);
};
// 获取历史消息
const socketHistoryList = (historyCallBack) => {
state.chatHistoryPagination.loadStatus = 'loading';
state.chatHistoryPagination.page += 1;
SocketIo.emit('messages', state.chatHistoryPagination, (res) => {
if (res.error === 0) {
state.chatHistoryPagination.total = res.data.messages.total;
state.chatHistoryPagination.lastPage = res.data.messages.last_page;
state.chatHistoryPagination.page = res.data.messages.current_page;
res.data.messages.data.forEach((item) => {
item.message_type && state.chatList.unshift(formatMessage(item));
});
state.chatHistoryPagination.loadStatus =
state.chatHistoryPagination.page < state.chatHistoryPagination.lastPage
? 'loadmore'
: 'nomore';
if (state.chatHistoryPagination.last_id == 0) {
state.chatHistoryPagination.last_id = res.data.messages.data.length
? res.data.messages.data[0].id
: 0;
}
state.chatHistoryPagination.page === 1 && historyCallBack && historyCallBack();
}
// 历史记录之后,猜你想问
// state.chatList.push({
// from: 'customer_service', // 用户customer右 | 顾客customer_service左 | 系统system中间
// mode: 'template', // goods,order,image,text,system
// date: new Date().getTime(), //时间
// content: { //内容
// list: state.templateChatList
// }
// })
});
};
// 修改客服信息
const editCustomerServerInfo = (data) => {
state.customerServerInfo = {
...state.customerServerInfo,
...data,
};
};
/**
* ================
* 工具函数 ↓
* ===============
*/
/**
* 是否显示时间
* @param {*} item - 数据
* @param {*} index - 索引
*/
const showTime = (item, index) => {
if (unref(state.chatList)[index + 1]) {
let dateString = dayjs(unref(state.chatList)[index + 1].date).fromNow();
if (dateString === dayjs(unref(item).date).fromNow()) {
return false;
} else {
dateString = dayjs(unref(item).date).fromNow();
return true;
}
}
return false;
};
/**
* 格式化时间
* @param {*} time - 时间戳
*/
const formatTime = (time) => {
let diffTime = new Date().getTime() - time;
if (diffTime > 28 * 24 * 60 * 1000) {
return dayjs(time).format('MM/DD HH:mm');
}
if (diffTime > 360 * 28 * 24 * 60 * 1000) {
return dayjs(time).format('YYYY/MM/DD HH:mm');
}
return dayjs(time).fromNow();
};
/**
* 获取焦点
* @param {*} virtualNode - 节点信息 ref
*/
const getFocus = (virtualNode) => {
if (window.getSelection) {
let chatInput = unref(virtualNode);
chatInput.focus();
let range = window.getSelection();
range.selectAllChildren(chatInput);
range.collapseToEnd();
} else if (document.selection) {
let range = document.selection.createRange();
range.moveToElementText(chatInput);
range.collapse(false);
range.select();
}
};
/**
* 文件上传
* @param {Blob} file -文件数据流
* @return {path,fullPath}
*/
const upload = (name, file) => {
return new Promise((resolve, reject) => {
let data = new FormData();
data.append('file', file, name);
data.append('group', 'chat');
ajax({
url: '/upload',
method: 'post',
headers: {
'Content-Type': 'multipart/form-data',
},
data,
success: function (res) {
resolve(res);
},
error: function (err) {
reject(err);
},
});
});
};
/**
* 粘贴到输入框
* @param {*} e - 粘贴内容
* @param {*} uploadHttp - 上传图片地址
*/
const onPaste = async (e) => {
let paste = e.clipboardData || window.clipboardData;
let filesArr = Array.from(paste.files);
filesArr.forEach(async (child) => {
if (child && child.type.includes('image')) {
e.preventDefault(); //阻止默认
let file = child;
const img = await readImg(file);
const blob = await compressImg(img, file.type);
const { data } = await upload(file.name, blob);
let image = `<img class="full-url" src='${data.fullurl}'>`;
document.execCommand('insertHTML', false, image);
} else {
document.execCommand('insertHTML', false, paste.getData('text'));
}
});
};
/**
* 拖拽到输入框
* @param {*} e - 粘贴内容
* @param {*} uploadHttp - 上传图片地址
*/
const onDrop = async (e) => {
e.preventDefault(); //阻止默认
let filesArr = Array.from(e.dataTransfer.files);
filesArr.forEach(async (child) => {
if (child && child.type.includes('image')) {
let file = child;
const img = await readImg(file);
const blob = await compressImg(img, file.type);
const { data } = await upload(file.name, blob);
let image = `<img class="full-url" src='${data.fullurl}' >`;
document.execCommand('insertHTML', false, image);
} else {
ElMessage({
message: '禁止拖拽非图片资源',
type: 'warning',
});
}
});
};
/**
* 解析富文本输入框内容
* @param {*} virtualNode -节点信息
* @param {Function} formatInputCallBack - cb 回调
*/
const formatChatInput = (virtualNode, formatInputCallBack) => {
let res = '';
let elemArr = Array.from(virtualNode.childNodes);
elemArr.forEach((child, index) => {
if (child.nodeName === '#text') {
//如果为文本节点
res += child.nodeValue;
if (
//文本节点的后面是图片,并且不是emoji,分开发送。输入框中的图片和文本表情分开。
elemArr[index + 1] &&
elemArr[index + 1].nodeName === 'IMG' &&
elemArr[index + 1] &&
elemArr[index + 1].name !== 'emoji'
) {
const data = {
from: 'customer',
mode: 'text',
date: new Date().getTime(),
content: {
text: filterXSS(res),
},
};
formatInputCallBack && formatInputCallBack(data);
res = '';
}
} else if (child.nodeName === 'BR') {
res += '<br/>';
} else if (child.nodeName === 'IMG') {
// 有emjio 和 一般图片
// 图片解析后直接发送,不跟文字表情一组
if (child.name !== 'emoji') {
let srcReg = /src=[\'\']?([^\'\']*)[\'\']?/i;
let src = child.outerHTML.match(srcReg);
const data = {
from: 'customer',
mode: 'image',
date: new Date().getTime(),
content: {
url: src[1],
path: src[1].replace(/http:\/\/[^\/]*/, ''),
},
};
formatInputCallBack && formatInputCallBack(data);
} else {
// 非表情图片跟文字一起发送
res += child.outerHTML;
}
} else if (child.nodeName === 'DIV') {
res += `<div style='width:200px; white-space: nowrap;'>${child.outerHTML}</div>`;
}
});
if (res) {
const data = {
from: 'customer',
mode: 'text',
date: new Date().getTime(),
content: {
text: filterXSS(res),
},
};
formatInputCallBack && formatInputCallBack(data);
}
unref(virtualNode).innerHTML = '';
};
/**
* 状态回调
* @param {*} res -接口返回数据
*/
const callBackNotice = (res) => {
ElNotification({
title: 'socket',
message: res.msg,
showClose: true,
type: res.error === 0 ? 'success' : 'warning',
duration: 1200,
});
};
/**
* 格式化发送信息
* @param {Object} message
* @returns obj - 消息对象
*/
const formatInput = (message) => {
let obj = {};
switch (message.mode) {
case 'text':
obj = {
message_type: 'text',
message: message.content.text,
};
break;
case 'image':
obj = {
message_type: 'image',
message: message.content.path,
};
break;
case 'goods':
obj = {
message_type: 'goods',
message: message.content.item,
};
break;
case 'order':
obj = {
message_type: 'order',
message: message.content.item,
};
break;
default:
break;
}
return obj;
};
/**
* 格式化接收信息
* @param {*} message
* @returns obj - 消息对象
*/
const formatMessage = (message) => {
let obj = {};
switch (message.message_type) {
case 'system':
obj = {
from: 'system', // 用户customer左 | 顾客customer_service右 | 系统system中间
mode: 'system', // goods,order,image,text,system
date: message.create_time * 1000, //时间
content: {
//内容
text: message.message,
},
};
break;
case 'text':
obj = {
from: message.sender_identify,
mode: message.message_type,
date: message.create_time * 1000, //时间
sender: message.sender,
content: {
text: message.message,
messageId: message.id,
},
};
break;
case 'image':
obj = {
from: message.sender_identify,
mode: message.message_type,
date: message.create_time * 1000, //时间
sender: message.sender,
content: {
url: sheep.$url.cdn(message.message),
messageId: message.id,
},
};
break;
case 'goods':
obj = {
from: message.sender_identify,
mode: message.message_type,
date: message.create_time * 1000, //时间
sender: message.sender,
content: {
item: message.message,
messageId: message.id,
},
};
break;
case 'order':
obj = {
from: message.sender_identify,
mode: message.message_type,
date: message.create_time * 1000, //时间
sender: message.sender,
content: {
item: message.message,
messageId: message.id,
},
};
break;
default:
break;
}
return obj;
};
/**
* file 转换为 img
* @param {*} file - file 文件
* @returns img - img标签
*/
const readImg = (file) => {
return new Promise((resolve, reject) => {
const img = new Image();
const reader = new FileReader();
reader.onload = function (e) {
img.src = e.target.result;
};
reader.onerror = function (e) {
reject(e);
};
reader.readAsDataURL(file);
img.onload = function () {
resolve(img);
};
img.onerror = function (e) {
reject(e);
};
});
};
/**
* 压缩图片
*@param img -被压缩的img对象
* @param type -压缩后转换的文件类型
* @param mx -触发压缩的图片最大宽度限制
* @param mh -触发压缩的图片最大高度限制
* @returns blob - 文件流
*/
const compressImg = (img, type = 'image/jpeg', mx = 1000, mh = 1000, quality = 1) => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
const { width: originWidth, height: originHeight } = img;
// 最大尺寸限制
const maxWidth = mx;
const maxHeight = mh;
// 目标尺寸
let targetWidth = originWidth;
let targetHeight = originHeight;
if (originWidth > maxWidth || originHeight > maxHeight) {
if (originWidth / originHeight > 1) {
// 宽图片
targetWidth = maxWidth;
targetHeight = Math.round(maxWidth * (originHeight / originWidth));
} else {
// 高图片
targetHeight = maxHeight;
targetWidth = Math.round(maxHeight * (originWidth / originHeight));
}
}
canvas.width = targetWidth;
canvas.height = targetHeight;
context.clearRect(0, 0, targetWidth, targetHeight);
// 图片绘制
context.drawImage(img, 0, 0, targetWidth, targetHeight);
canvas.toBlob(
function (blob) {
resolve(blob);
},
type,
quality,
);
});
};
return {
compressImg,
readImg,
formatMessage,
formatInput,
callBackNotice,
socketInit,
socketSendMsg,
socketClose,
socketHistoryList,
getFocus,
formatChatInput,
onDrop,
onPaste,
upload,
getUserToken,
state,
socketTest,
showTime,
formatTime,
};
}
+19
View File
@@ -0,0 +1,19 @@
export const KeFuMessageContentTypeEnum = {
TEXT: 1, // 文本消息
IMAGE: 2, // 图片消息
VOICE: 3, // 语音消息
VIDEO: 4, // 视频消息
SYSTEM: 5, // 系统消息
// ========== 商城特殊消息 ==========
PRODUCT: 10,// 商品消息
ORDER: 11,// 订单消息"
};
export const UserTypeEnum = {
MEMBER: 1, // 会员 面向 c 端,普通用户
ADMIN: 2, // 管理员 面向 b 端,管理后台
};
// Promotion 的 WebSocket 消息类型枚举类
export const WebSocketMessageTypeConstants = {
KEFU_MESSAGE_TYPE: 'kefu_message_type', // 客服消息类型
KEFU_MESSAGE_ADMIN_READ: 'kefu_message_read_status_change' // 客服消息管理员已读
}
File renamed without changes.
+1 -1
View File
@@ -16,7 +16,7 @@
/>
</button>
</view>
<view class="ss-flex" @tap="sheep.$router.go('/pages/user/wallet/commission')">
<view class="ss-flex" @tap="sheep.$router.go('/pages/commission/wallet')">
<view class="header-title ss-m-r-4">查看明细</view>
<text class="cicon-play-arrow" />
</view>
@@ -61,21 +61,21 @@
title: '钱包余额',
value: '1',
},
{
icon: '/static/img/shop/pay/bank.png',
title: '银行卡转账',
value: '2',
},
{
icon: '/static/img/shop/pay/wechat.png',
title: '微信零钱',
value: '2',
value: '3',
},
{
icon: '/static/img/shop/pay/alipay.png',
title: '支付宝账户',
value: '3',
},
{
icon: '/static/img/shop/pay/bank.png',
title: '银行卡转账',
value: '4',
},
}
];
function onChange(e) {
+13 -4
View File
@@ -1,6 +1,6 @@
<!-- 分销中心 -->
<template>
<s-layout navbar="inner" class="index-wrap" title="分销中心" :bgStyle="bgStyle" onShareAppMessage>
<s-layout navbar="inner" class="index-wrap" title="分销中心" :bgStyle="bgStyle" :onShareAppMessage="shareInfo">
<!-- 分销商信息 -->
<commission-info />
<!-- 账户信息 -->
@@ -16,14 +16,23 @@
</template>
<script setup>
import { reactive } from 'vue';
import { computed } from 'vue';
import commissionInfo from './components/commission-info.vue';
import accountInfo from './components/account-info.vue';
import commissionLog from './components/commission-log.vue';
import commissionMenu from './components/commission-menu.vue';
import commissionAuth from './components/commission-auth.vue';
import sheep from '@/sheep';
const state = reactive({});
const shareInfo = computed(() => {
return sheep.$platform.share.getShareInfo({
params: {
page: '6',
},
}, {
type: 'user',
});
});
const bgStyle = {
color: '#F7D598',
@@ -34,4 +43,4 @@
:deep(.page-main) {
background-size: 100% 100% !important;
}
</style>
</style>
+11 -11
View File
@@ -26,9 +26,9 @@
<view class="bank-list ss-flex ss-col-center" @tap="onAccountSelect(true)">
<view v-if="!state.accountInfo.type" class="empty-text">请选择提现方式</view>
<view v-if="state.accountInfo.type === '1'" class="empty-text">钱包余额</view>
<view v-if="state.accountInfo.type === '2'" class="empty-text">微信零钱</view>
<view v-if="state.accountInfo.type === '3'" class="empty-text">支付宝账户</view>
<view v-if="state.accountInfo.type === '4'" class="empty-text">银行卡转账</view>
<view v-if="state.accountInfo.type === '2'" class="empty-text">银行卡转账</view>
<view v-if="state.accountInfo.type === '3'" class="empty-text">微信零钱</view>
<view v-if="state.accountInfo.type === '4'" class="empty-text">支付宝账户</view>
<text class="cicon-forward" />
</view>
</view>
@@ -61,10 +61,10 @@
/>
</view>
<!-- 收款码 -->
<view class="card-title" v-show="['2', '3'].includes(state.accountInfo.type)">收款码</view>
<view class="card-title" v-show="['3', '4'].includes(state.accountInfo.type)">收款码</view>
<view
class="input-box ss-flex ss-col-center"
v-show="['2', '3'].includes(state.accountInfo.type)"
v-show="['3', '4'].includes(state.accountInfo.type)"
>
<view class="unit" />
<view class="upload-img">
@@ -78,10 +78,10 @@
</view>
</view>
<!-- 持卡人姓名 -->
<view class="card-title" v-show="state.accountInfo.type === '4'">持卡人</view>
<view class="card-title" v-show="state.accountInfo.type === '2'">持卡人</view>
<view
class="input-box ss-flex ss-col-center border-bottom"
v-show="state.accountInfo.type === '4'"
v-show="state.accountInfo.type === '2'"
>
<view class="unit" />
<uni-easyinput
@@ -92,10 +92,10 @@
/>
</view>
<!-- 提现银行 -->
<view class="card-title" v-show="state.accountInfo.type === '4'">提现银行</view>
<view class="card-title" v-show="state.accountInfo.type === '2'">提现银行</view>
<view
class="input-box ss-flex ss-col-center border-bottom"
v-show="state.accountInfo.type === '4'"
v-show="state.accountInfo.type === '2'"
>
<view class="unit" />
<uni-easyinput
@@ -106,10 +106,10 @@
/>
</view>
<!-- 开户地址 -->
<view class="card-title" v-show="state.accountInfo.type === '4'">开户地址</view>
<view class="card-title" v-show="state.accountInfo.type === '2'">开户地址</view>
<view
class="input-box ss-flex ss-col-center border-bottom"
v-show="state.accountInfo.type === '4'"
v-show="state.accountInfo.type === '2'"
>
<view class="unit" />
<uni-easyinput
+18 -22
View File
@@ -99,14 +99,8 @@
</template>
<script setup>
import {
reactive,
computed
} from 'vue';
import {
onLoad,
onPageScroll
} from '@dcloudio/uni-app';
import { reactive, computed } from 'vue';
import { onLoad, onPageScroll } from '@dcloudio/uni-app';
import sheep from '@/sheep';
import CouponApi from '@/sheep/api/promotion/coupon';
import ActivityApi from '@/sheep/api/promotion/activity';
@@ -124,7 +118,8 @@
onPageScroll(() => {});
const state = reactive({
const isLogin = computed(() => sheep.$store('user').isLogin);
const state = reactive({
goodsId: 0,
skeletonLoading: true, // SPU 加载中
goodsInfo: {}, // SPU 信息
@@ -194,20 +189,19 @@
if (isEmpty(state.goodsInfo)) return {};
return sheep.$platform.share.getShareInfo({
title: state.goodsInfo.name,
image: sheep.$url.cdn(state.goodsInfo.image),
desc: state.goodsInfo.subtitle,
image: sheep.$url.cdn(state.goodsInfo.picUrl),
desc: state.goodsInfo.introduction,
params: {
page: '2',
query: state.goodsInfo.id,
},
}, {
type: 'goods', // 商品海报
title: state.goodsInfo.name, // 商品标题
// image: sheep.$url.cdn(state.goodsInfo.image), // 商品主图
title: state.goodsInfo.name, // 商品名称
image: sheep.$url.cdn(state.goodsInfo.picUrl), // 商品主图
price: fen2yuan(state.goodsInfo.price), // 商品价格
original_price: fen2yuan(state.goodsInfo.maretPrice), // 商品原价
}, );
original_price: fen2yuan(state.goodsInfo.marketPrice), // 商品原价
});
});
async function getCoupon() {
@@ -236,12 +230,14 @@
state.goodsInfo = res.data;
// 加载是否收藏
FavoriteApi.isFavoriteExists(state.goodsId, 'goods').then((res) => {
if (res.code !== 0) {
return;
}
state.goodsInfo.favorite = res.data;
});
if (isLogin.value) {
FavoriteApi.isFavoriteExists(state.goodsId, 'goods').then((res) => {
if (res.code !== 0) {
return;
}
state.goodsInfo.favorite = res.data;
});
}
});
// 2. 加载优惠劵信息
@@ -410,4 +406,4 @@
color: #333333;
}
}
</style>
</style>
+1
View File
@@ -53,6 +53,7 @@
// 小程序识别二维码
if (options.scene) {
const sceneParams = decodeURIComponent(options.scene).split('=');
console.log("sceneParams=>",sceneParams);
options[sceneParams[0]] = sceneParams[1];
}
// #endif
+3 -2
View File
@@ -14,13 +14,14 @@
new URLSearchParams(location.search).forEach((value, key) => {
options[key] = value;
});
// 执行登录 or 绑定,注意需要 await 绑定
const event = options.event;
const code = options.code;
const state = options.state;
if (event === 'login') { // 场景一:登录
const res = await sheep.$platform.useProvider().login(code, state);
await sheep.$platform.useProvider().login(code, state);
} else if (event === 'bind') { // 场景二:绑定
sheep.$platform.useProvider().bind(code, state);
await sheep.$platform.useProvider().bind(code, state);
}
// 检测 H5 登录回调
+1 -3
View File
@@ -130,6 +130,7 @@
import TradeConfigApi from '@/sheep/api/trade/config';
import { fen2yuan } from '@/sheep/hooks/useGoods';
import AfterSaleApi from '@/sheep/api/trade/afterSale';
import { WxaSubscribeTemplate } from '@/sheep/util/const';
const form = ref(null);
const state = reactive({
@@ -164,9 +165,6 @@
// 提交表单
async function submit() {
// #ifdef MP
sheep.$platform.useProvider('wechat').subscribeMessage('order_aftersale_change');
// #endif
let data = {
orderItemId: state.itemId,
refundPrice: state.item.payPrice,
+9 -8
View File
@@ -6,14 +6,15 @@
<view class='list borRadius14'>
<view class='item acea-row row-between-wrapper' style="display: flex;align-items: center;">
<view>物流公司</view>
<picker mode='selector' class='num' @change="bindPickerChange" :value="state.expressIndex"
:range="state.expresses" range-key="name">
<view class="picker acea-row row-between-wrapper">
<view class='reason'>{{ state.expresses[state.expressIndex].name }}</view>
<!-- TODO 芋艿:这里样式有问题,少了 > 按钮 -->
<text class='iconfont icon-jiantou' />
</view>
</picker>
<view v-if="state.expresses.length>0" style="flex:1">
<picker mode='selector' class='num' @change="bindPickerChange" :value="state.expressIndex"
:range="state.expresses" range-key="name">
<view class="picker acea-row row-between-wrapper" style="display: flex;justify-content: space-between;">
<view class='reason'>{{ state.expresses[state.expressIndex].name }}</view>
<text class='iconfont _icon-forward' />
</view>
</picker>
</view>
</view>
<view class='item textarea acea-row row-between' style="display: flex;align-items: center;">
<view>物流单号</view>
+3
View File
@@ -166,6 +166,7 @@
import OrderApi from '@/sheep/api/trade/order';
import CouponApi from '@/sheep/api/promotion/coupon';
import { fen2yuan } from '@/sheep/hooks/useGoods';
import { WxaSubscribeTemplate } from '@/sheep/util/const';
const state = reactive({
orderPayload: {},
@@ -216,6 +217,7 @@
const { code, data } = await OrderApi.createOrder({
items: state.orderPayload.items,
couponId: state.orderPayload.couponId,
remark: state.orderPayload.remark,
addressId: state.addressInfo.id,
deliveryType: 1, // TODO 芋艿:需要支持【门店自提】
pointStatus: false, // TODO 芋艿:需要支持【积分选择】
@@ -230,6 +232,7 @@
if (state.orderPayload.items[0].cartId > 0) {
sheep.$store('cart').getList();
}
// 跳转到支付页面
sheep.$router.redirect('/pages/pay/index', {
id: data.payOrderId,
+1 -1
View File
@@ -272,7 +272,7 @@
// 复制
const onCopy = () => {
sheep.$helper.copyText(state.orderInfo.sn);
sheep.$helper.copyText(state.orderInfo.no);
};
// 去支付
+201 -200
View File
@@ -1,259 +1,260 @@
<!-- 充值界面 -->
<template>
<s-layout title="充值" class="withdraw-wrap" navbar="inner">
<view class="wallet-num-box ss-flex ss-col-center ss-row-between" :style="[
<s-layout title="充值" class="withdraw-wrap" navbar="inner">
<view class="wallet-num-box ss-flex ss-col-center ss-row-between" :style="[
{
marginTop: '-' + Number(statusBarHeight + 88) + 'rpx',
paddingTop: Number(statusBarHeight + 108) + 'rpx',
},
]">
<view class="">
<view class="num-title">当前余额(元)</view>
<view class="wallet-num">{{ fen2yuan(userWallet.balance) }}</view>
</view>
<button class="ss-reset-button log-btn" @tap="sheep.$router.go('/pages/pay/recharge-log')">
<view class="">
<view class="num-title">当前余额(元)</view>
<view class="wallet-num">{{ fen2yuan(userWallet.balance) }}</view>
</view>
<button class="ss-reset-button log-btn" @tap="sheep.$router.go('/pages/pay/recharge-log')">
充值记录
</button>
</view>
<view class="recharge-box">
<view class="recharge-card-box">
<view class="input-label ss-m-b-50">充值金额</view>
<view class="input-box ss-flex border-bottom ss-p-b-20">
<view class="unit">¥</view>
<uni-easyinput v-model="state.recharge_money" type="digit" placeholder="请输入充值金额"
</view>
<view class="recharge-box">
<view class="recharge-card-box">
<view class="input-label ss-m-b-50">充值金额</view>
<view class="input-box ss-flex border-bottom ss-p-b-20">
<view class="unit">¥</view>
<uni-easyinput v-model="state.recharge_money" type="digit" placeholder="请输入充值金额"
:inputBorder="false" />
</view>
<view class="face-value-box ss-flex ss-flex-wrap ss-m-y-40">
<button class="ss-reset-button face-value-btn" v-for="item in state.packageList" :key="item.money"
:class="[{ 'btn-active': state.recharge_money === fen2yuan(item.payPrice) }]"
@tap="onCard(item.payPrice)">
<text class="face-value-title">{{ fen2yuan(item.payPrice) }}</text>
<view v-if="item.bonusPrice" class="face-value-tag">
送 {{ fen2yuan(item.bonusPrice) }} 元
</view>
<view class="face-value-box ss-flex ss-flex-wrap ss-m-y-40">
<button class="ss-reset-button face-value-btn" v-for="item in state.packageList" :key="item.money"
:class="[{ 'btn-active': state.recharge_money === fen2yuan(item.payPrice) }]"
@tap="onCard(item.payPrice)">
<text class="face-value-title">{{ fen2yuan(item.payPrice) }}</text>
<view v-if="item.bonusPrice" class="face-value-tag">
送 {{ fen2yuan(item.bonusPrice) }} 元
</view>
</button>
</view>
<button class="ss-reset-button save-btn ui-BG-Main-Gradient ss-m-t-60 ui-Shadow-Main" @tap="onConfirm">
确认充值
</button>
</view>
</view>
</s-layout>
</button>
</view>
<button class="ss-reset-button save-btn ui-BG-Main-Gradient ss-m-t-60 ui-Shadow-Main" @tap="onConfirm">
确认充值
</button>
</view>
</view>
</s-layout>
</template>
<script setup>
import { computed, reactive } from 'vue';
import sheep from '@/sheep';
import { onLoad } from '@dcloudio/uni-app';
import { computed, reactive } from 'vue';
import sheep from '@/sheep';
import { onLoad } from '@dcloudio/uni-app';
import { fen2yuan } from '@/sheep/hooks/useGoods';
import PayWalletApi from '@/sheep/api/pay/wallet';
import { WxaSubscribeTemplate } from '@/sheep/util/const';
const userWallet = computed(() => sheep.$store('user').userWallet);
const statusBarHeight = sheep.$platform.device.statusBarHeight * 2;
const headerBg = sheep.$url.css('/static/img/shop/user/withdraw_bg.png');
const userWallet = computed(() => sheep.$store('user').userWallet);
const statusBarHeight = sheep.$platform.device.statusBarHeight * 2;
const headerBg = sheep.$url.css('/static/img/shop/user/withdraw_bg.png');
const state = reactive({
recharge_money: '', // 输入的充值金额
const state = reactive({
recharge_money: '', // 输入的充值金额
packageList: [],
});
});
// 点击卡片,选择充值金额
function onCard(e) {
state.recharge_money = fen2yuan(e);
}
// 点击卡片,选择充值金额
function onCard(e) {
state.recharge_money = fen2yuan(e);
}
// 获得钱包充值套餐列表
async function getRechargeTabs() {
async function getRechargeTabs() {
const { code, data } = await PayWalletApi.getWalletRechargePackageList();
if (code !== 0) {
return;
}
state.packageList = data;
}
}
// 发起支付
async function onConfirm() {
const { code, data } = await PayWalletApi.createWalletRecharge({
packageId: state.packageList.find((item) => fen2yuan(item.payPrice) === state.recharge_money)?.id,
payPrice: state.recharge_money * 100
});
if (code !== 0) {
return;
}
async function onConfirm() {
const { code, data } = await PayWalletApi.createWalletRecharge({
packageId: state.packageList.find((item) => fen2yuan(item.payPrice) === state.recharge_money)?.id,
payPrice: state.recharge_money * 100,
});
if (code !== 0) {
return;
}
// #ifdef MP
sheep.$platform.useProvider('wechat').subscribeMessage('money_change');
sheep.$platform.useProvider('wechat').subscribeMessage(WxaSubscribeTemplate.PAY_WALLET_RECHARGER_SUCCESS);
// #endif
sheep.$router.go('/pages/pay/index', {
id: data.payOrderId,
orderType: 'recharge'
orderType: 'recharge',
});
}
}
onLoad(() => {
getRechargeTabs();
});
onLoad(() => {
getRechargeTabs();
});
</script>
<style lang="scss" scoped>
:deep() {
.uni-input-input {
font-family: OPPOSANS !important;
}
}
:deep() {
.uni-input-input {
font-family: OPPOSANS !important;
}
}
.wallet-num-box {
padding: 0 40rpx 80rpx;
background: var(--ui-BG-Main) v-bind(headerBg) center/750rpx 100% no-repeat;
border-radius: 0 0 5% 5%;
.wallet-num-box {
padding: 0 40rpx 80rpx;
background: var(--ui-BG-Main) v-bind(headerBg) center/750rpx 100% no-repeat;
border-radius: 0 0 5% 5%;
.num-title {
font-size: 26rpx;
font-weight: 500;
color: $white;
margin-bottom: 20rpx;
}
.num-title {
font-size: 26rpx;
font-weight: 500;
color: $white;
margin-bottom: 20rpx;
}
.wallet-num {
font-size: 60rpx;
font-weight: 500;
color: $white;
font-family: OPPOSANS;
}
.wallet-num {
font-size: 60rpx;
font-weight: 500;
color: $white;
font-family: OPPOSANS;
}
.log-btn {
width: 170rpx;
height: 60rpx;
line-height: 60rpx;
border: 1rpx solid $white;
border-radius: 30rpx;
padding: 0;
font-size: 26rpx;
font-weight: 500;
color: $white;
}
}
.log-btn {
width: 170rpx;
height: 60rpx;
line-height: 60rpx;
border: 1rpx solid $white;
border-radius: 30rpx;
padding: 0;
font-size: 26rpx;
font-weight: 500;
color: $white;
}
}
.recharge-box {
position: relative;
padding: 0 30rpx;
margin-top: -60rpx;
}
.recharge-box {
position: relative;
padding: 0 30rpx;
margin-top: -60rpx;
}
.save-btn {
width: 620rpx;
height: 86rpx;
border-radius: 44rpx;
font-size: 30rpx;
}
.save-btn {
width: 620rpx;
height: 86rpx;
border-radius: 44rpx;
font-size: 30rpx;
}
.recharge-card-box {
width: 690rpx;
background: var(--ui-BG);
border-radius: 20rpx;
padding: 30rpx;
box-sizing: border-box;
.recharge-card-box {
width: 690rpx;
background: var(--ui-BG);
border-radius: 20rpx;
padding: 30rpx;
box-sizing: border-box;
.input-label {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.input-label {
font-size: 30rpx;
font-weight: 500;
color: #333;
}
.unit {
display: flex;
align-items: center;
font-size: 48rpx;
font-weight: 500;
}
.unit {
display: flex;
align-items: center;
font-size: 48rpx;
font-weight: 500;
}
.uni-easyinput__placeholder-class {
font-size: 30rpx;
height: 60rpx;
display: flex;
align-items: center;
}
.uni-easyinput__placeholder-class {
font-size: 30rpx;
height: 60rpx;
display: flex;
align-items: center;
}
:deep(.uni-easyinput__content-input) {
font-size: 48rpx;
}
:deep(.uni-easyinput__content-input) {
font-size: 48rpx;
}
.face-value-btn {
width: 200rpx;
height: 144rpx;
border: 1px solid var(--ui-BG-Main);
border-radius: 10rpx;
position: relative;
z-index: 1;
margin-bottom: 15rpx;
margin-right: 15rpx;
.face-value-btn {
width: 200rpx;
height: 144rpx;
border: 1px solid var(--ui-BG-Main);
border-radius: 10rpx;
position: relative;
z-index: 1;
margin-bottom: 15rpx;
margin-right: 15rpx;
&:nth-of-type(3n) {
margin-right: 0;
}
&:nth-of-type(3n) {
margin-right: 0;
}
.face-value-title {
font-size: 36rpx;
font-weight: 500;
color: var(--ui-BG-Main);
font-family: OPPOSANS;
.face-value-title {
font-size: 36rpx;
font-weight: 500;
color: var(--ui-BG-Main);
font-family: OPPOSANS;
&::after {
content: '元';
font-size: 24rpx;
margin-left: 6rpx;
}
}
&::after {
content: '元';
font-size: 24rpx;
margin-left: 6rpx;
}
}
.face-value-tag {
position: absolute;
z-index: 2;
height: 40rpx;
line-height: 40rpx;
background: var(--ui-BG-Main);
opacity: 0.8;
border-radius: 10rpx 0 20rpx 0;
top: 0;
left: -2rpx;
padding: 0 16rpx;
font-size: 22rpx;
color: $white;
font-family: OPPOSANS;
}
.face-value-tag {
position: absolute;
z-index: 2;
height: 40rpx;
line-height: 40rpx;
background: var(--ui-BG-Main);
opacity: 0.8;
border-radius: 10rpx 0 20rpx 0;
top: 0;
left: -2rpx;
padding: 0 16rpx;
font-size: 22rpx;
color: $white;
font-family: OPPOSANS;
}
&::before {
position: absolute;
content: ' ';
width: 100%;
height: 100%;
background: var(--ui-BG-Main);
opacity: 0.1;
z-index: 0;
left: 0;
top: 0;
}
}
&::before {
position: absolute;
content: ' ';
width: 100%;
height: 100%;
background: var(--ui-BG-Main);
opacity: 0.1;
z-index: 0;
left: 0;
top: 0;
}
}
.btn-active {
z-index: 1;
.btn-active {
z-index: 1;
&::before {
content: '';
background: var(--ui-BG-Main);
opacity: 1;
}
&::before {
content: '';
background: var(--ui-BG-Main);
opacity: 1;
}
.face-value-title {
color: $white;
position: relative;
z-index: 1;
font-family: OPPOSANS;
}
.face-value-title {
color: $white;
position: relative;
z-index: 1;
font-family: OPPOSANS;
}
.face-value-tag {
background: $white;
color: var(--ui-BG-Main);
font-family: OPPOSANS;
}
}
}
</style>
.face-value-tag {
background: $white;
color: var(--ui-BG-Main);
font-family: OPPOSANS;
}
}
}
</style>
+40 -12
View File
@@ -49,9 +49,8 @@
</button>
</view>
<!-- TODO 芋艿:订阅 -->
<!-- #ifdef MP -->
<view class="subscribe-box ss-flex ss-m-t-44">
<view class="subscribe-box ss-flex ss-m-t-44" v-if="showSubscribeBtn && state.orderType === 'goods'">
<image class="subscribe-img" :src="sheep.$url.static('/static/img/shop/order/cargo.png')" />
<view class="subscribe-title ss-m-r-48 ss-m-l-16">获取实时发货信息与订单状态</view>
<view class="subscribe-start" @tap="subscribeMessage">立即订阅</view>
@@ -63,12 +62,13 @@
<script setup>
import { onLoad, onHide, onShow } from '@dcloudio/uni-app';
import { reactive, computed } from 'vue';
import { reactive, computed, ref } from 'vue';
import { isEmpty } from 'lodash';
import sheep from '@/sheep';
import PayOrderApi from '@/sheep/api/pay/order';
import { fen2yuan } from '../../sheep/hooks/useGoods';
import { fen2yuan } from '@/sheep/hooks/useGoods';
import OrderApi from '@/sheep/api/trade/order';
import { WxaSubscribeTemplate } from '@/sheep/util/const';
const state = reactive({
id: 0, // 支付单号
@@ -111,7 +111,16 @@
// 非待支付,可能是已支付,可能是已退款
state.result = 'paid';
// #ifdef MP
subscribeMessage();
uni.showModal({
title: '支付结果',
showCancel: false, // 不要取消按钮
content: "支付成功",
success: () => {
// 订阅只能由用户主动触发,只能包一层 showModal 诱导用户点击
autoSubscribeMessage();
}
})
// #endif
// 特殊:获得商品订单信息
if (state.orderType === 'goods') {
@@ -136,7 +145,6 @@
}
function onOrder() {
// TODO 芋艿:待测试
if (state.orderType === 'recharge') {
sheep.$router.redirect('/pages/pay/recharge-log');
} else {
@@ -144,15 +152,35 @@
}
}
// TODO 芋艿:待测试
// #ifdef MP
const showSubscribeBtn = ref(false) // 默认隐藏
const SUBSCRIBE_BTN_STATUS_STORAGE_KEY = "subscribe_btn_status"
function subscribeMessage() {
let event = ['order_dispatched'];
if (state.tradeOrder.type === 3) {
event.push('groupon_finish');
event.push('groupon_fail');
if (state.orderType !== 'goods') {
return;
}
sheep.$platform.useProvider('wechat').subscribeMessage(event);
const event = [WxaSubscribeTemplate.TRADE_ORDER_DELIVERY];
if (state.tradeOrder.type === 3) {
event.push(WxaSubscribeTemplate.PROMOTION_COMBINATION_SUCCESS);
}
sheep.$platform.useProvider('wechat').subscribeMessage(event, () => {
// 订阅后记录一下订阅状态
uni.removeStorageSync(SUBSCRIBE_BTN_STATUS_STORAGE_KEY);
uni.setStorageSync(SUBSCRIBE_BTN_STATUS_STORAGE_KEY, '已订阅');
// 隐藏订阅按钮
showSubscribeBtn.value = false;
});
}
async function autoSubscribeMessage() {
// 1. 校验是否手动订阅过
const subscribeBtnStatus = uni.getStorageSync(SUBSCRIBE_BTN_STATUS_STORAGE_KEY);
if (!subscribeBtnStatus) {
showSubscribeBtn.value = true;
return
}
// 2. 订阅消息
subscribeMessage()
}
// #endif
+3 -3
View File
@@ -1,4 +1,4 @@
import { baseUrl, apiPath } from '@/sheep/config';
import { baseUrl, apiPath, tenantId } from '@/sheep/config';
const FileApi = {
// 上传文件
@@ -15,8 +15,8 @@ const FileApi = {
name: 'file',
header: {
// Accept: 'text/json',
Accept : '*/*',
'tenant-id' :'1',
Accept: '*/*',
'tenant-id': tenantId,
// Authorization: 'Bearer test247',
},
success: (uploadFileRes) => {
+22
View File
@@ -49,6 +49,28 @@ const SocialApi = {
},
});
},
// 获取订阅消息模板列表
getSubscribeTemplateList: () =>
request({
url: '/member/social-user/get-subscribe-template-list',
method: 'GET',
custom: {
showError: false,
showLoading: false,
},
}),
// 获取微信小程序码
getWxaQrcode: async (path, query) => {
return await request({
url: '/member/social-user/wxa-qrcode',
method: 'POST',
data: {
scene: query,
path,
checkPath: false, // TODO 开发环境暂不检查 path 是否存在
},
});
},
};
export default SocialApi;
-26
View File
@@ -1,32 +1,6 @@
import request from '@/sheep/request';
import { baseUrl, apiPath } from '@/sheep/config';
export default {
// 微信相关
wechat: {
// 小程序订阅消息
subscribeTemplate: (params) =>
request({
url: 'third/wechat/subscribeTemplate',
method: 'GET',
params: {
platform: 'miniProgram',
},
custom: {
showError: false,
showLoading: false,
},
}),
// 获取微信小程序码
getWxacode: (path) =>
`${baseUrl}${apiPath}third/wechat/wxacode?platform=miniProgram&payload=${encodeURIComponent(
JSON.stringify({
path,
}),
)}`,
},
// 苹果相关
apple: {
// 第三方登录
+31
View File
@@ -0,0 +1,31 @@
import request from '@/sheep/request';
const KeFuApi = {
sendKefuMessage: (data) => {
return request({
url: '/promotion/kefu-message/send',
method: 'POST',
data,
custom: {
auth: true,
showLoading: true,
loadingMsg: '发送中',
showSuccess: true,
successMsg: '发送成功',
},
});
},
getKefuMessagePage: (params) => {
return request({
url: '/promotion/kefu-message/page',
method: 'GET',
params,
custom: {
auth: true,
showLoading: false,
},
});
},
};
export default KeFuApi;
+19 -13
View File
@@ -1,6 +1,14 @@
import request from '@/sheep/request';
const BrokerageApi = {
// 绑定分销用户
bindBrokerageUser: (data)=>{
return request({
url: '/trade/brokerage-user/bind',
method: 'PUT',
data
});
},
// 获得个人分销信息
getBrokerageUser: () => {
return request({
@@ -16,12 +24,12 @@ const BrokerageApi = {
});
},
// 获得分销记录分页
getBrokerageRecordPage: (params) => {
getBrokerageRecordPage: params => {
if (params.status === undefined) {
delete params.status
}
const queryString = Object.keys(params)
.map((key) => encodeURIComponent(key) + '=' + params[key])
.map(key => encodeURIComponent(key) + '=' + params[key])
.join('&');
return request({
url: `/trade/brokerage-record/page?${queryString}`,
@@ -29,7 +37,7 @@ const BrokerageApi = {
});
},
// 创建分销提现
createBrokerageWithdraw: (data) => {
createBrokerageWithdraw: data => {
return request({
url: '/trade/brokerage-withdraw/create',
method: 'POST',
@@ -37,17 +45,15 @@ const BrokerageApi = {
});
},
// 获得商品的分销金额
getProductBrokeragePrice: (spuId) => {
getProductBrokeragePrice: spuId => {
return request({
url: '/trade/brokerage-record/get-product-brokerage-price',
method: 'GET',
params: {
spuId
}
params: { spuId }
});
},
// 获得分销用户排行(基于佣金)
getRankByPrice: (params) => {
getRankByPrice: params => {
const queryString = `times=${params.times[0]}&times=${params.times[1]}`;
return request({
url: `/trade/brokerage-user/get-rank-by-price?${queryString}`,
@@ -55,9 +61,9 @@ const BrokerageApi = {
});
},
// 获得分销用户排行分页(基于佣金)
getBrokerageUserChildSummaryPageByPrice: (params) => {
getBrokerageUserChildSummaryPageByPrice: params => {
const queryString = Object.keys(params)
.map((key) => encodeURIComponent(key) + '=' + params[key])
.map(key => encodeURIComponent(key) + '=' + params[key])
.join('&');
return request({
url: `/trade/brokerage-user/rank-page-by-price?${queryString}`,
@@ -65,9 +71,9 @@ const BrokerageApi = {
});
},
// 获得分销用户排行分页(基于用户量)
getBrokerageUserRankPageByUserCount: (params) => {
getBrokerageUserRankPageByUserCount: params => {
const queryString = Object.keys(params)
.map((key) => encodeURIComponent(key) + '=' + params[key])
.map(key => encodeURIComponent(key) + '=' + params[key])
.join('&');
return request({
url: `/trade/brokerage-user/rank-page-by-user-count?${queryString}`,
@@ -75,7 +81,7 @@ const BrokerageApi = {
});
},
// 获得下级分销统计分页
getBrokerageUserChildSummaryPage: (params) => {
getBrokerageUserChildSummaryPage: params => {
return request({
url: '/trade/brokerage-user/child-summary-page',
method: 'GET',
@@ -2,56 +2,53 @@
<template>
<su-popup :show="show" round="10" @close="onClosePoster" type="center" class="popup-box">
<view class="ss-flex-col ss-col-center ss-row-center">
<view
v-if="poster.src === ''"
class="poster-title ss-flex ss-row-center"
:style="{
height: poster.height + 'px',
width: poster.width + 'px',
}"
>
海报加载中...
</view>
<image
v-else
v-if="!!painterImageUrl"
class="poster-img"
:src="poster.src"
:src="painterImageUrl"
:style="{
height: poster.height + 'px',
width: poster.width + 'px',
height: poster.css.height+ 'px',
width: poster.css.width + 'px',
}"
:show-menu-by-longpress="true"
/>
<canvas
class="hideCanvas"
:canvas-id="poster.canvasId"
:id="poster.canvasId"
:style="{
height: poster.height + 'px',
width: poster.width + 'px',
}"
/>
<view
class="poster-btn-box ss-m-t-20 ss-flex ss-row-between ss-col-center"
v-if="poster.src !== ''"
>
<button class="cancel-btn ss-reset-button" @tap="onClosePoster">取消</button>
<button class="save-btn ss-reset-button ui-BG-Main" @tap="onSavePoster">
{{
['wechatOfficialAccount', 'H5'].includes(sheep.$platform.name)
? '长按图片保存'
: '保存图片'
}}
</button>
</view>
</view>
<view
class="poster-btn-box ss-m-t-20 ss-flex ss-row-between ss-col-center"
v-if="!!painterImageUrl"
>
<button class="cancel-btn ss-reset-button" @tap="onClosePoster">取消</button>
<button class="save-btn ss-reset-button ui-BG-Main" @tap="onSavePoster">
{{
['wechatOfficialAccount', 'H5'].includes(sheep.$platform.name)
? '长按图片保存'
: '保存图片'
}}
</button>
</view>
<!-- 海报画板:默认隐藏只用来生成海报。生成方式为主动调用 -->
<l-painter
isCanvasToTempFilePath
pathType="url"
@success="setPainterImageUrl"
hidden
ref="painterRef"
/>
</su-popup>
</template>
<script setup>
import { reactive, getCurrentInstance } from 'vue';
/**
* 海报生成和展示
* 提示:小程序码默认跳转首页,由首页进行 spm 参数解析后跳转到对应的分享页面
* @description 用于生成分享海报,如:分享商品海报。
* @tutorial https://ext.dcloud.net.cn/plugin?id=2389
* @property {Boolean} show 弹出层控制
* @property {Object} shareInfo 分享信息
*/
import { reactive, ref, unref } from 'vue';
import sheep from '@/sheep';
import useCanvas from './useCanvas';
import { getPosterData } from '@/sheep/components/s-share-modal/canvas-poster/poster';
const props = defineProps({
show: {
@@ -60,24 +57,36 @@
},
shareInfo: {
type: Object,
default() {},
default: () => {
},
},
});
const poster = reactive({
canvasId: 'canvasId',
width: sheep.$platform.device.windowWidth * 0.9,
height: 600,
src: '',
css: {
// 根节点若无尺寸,自动获取父级节点
width: sheep.$platform.device.windowWidth * 0.9,
height: 550,
},
views: [],
});
const emits = defineEmits(['success', 'close']);
const vm = getCurrentInstance();
const onClosePoster = () => {
emits('close');
};
const painterRef = ref(); // 海报画板
const painterImageUrl = ref(); // 海报 url
// 渲染海报
const renderPoster = async () => {
await painterRef.value.render(unref(poster));
};
// 获得生成的图片
const setPainterImageUrl = (path) => {
painterImageUrl.value = path;
};
// 保存海报图片
const onSavePoster = () => {
if (['WechatOfficialAccount', 'H5'].includes(sheep.$platform.name)) {
@@ -85,8 +94,9 @@
return;
}
// 非H5 保存到相册
uni.saveImageToPhotosAlbum({
filePath: poster.src,
filePath: painterImageUrl.value,
success: (res) => {
onClosePoster();
sheep.$helper.toast('保存成功');
@@ -98,16 +108,14 @@
});
};
// 使用 canvas 生成海报
async function getPoster(params) {
poster.src = '';
poster.shareInfo = props.shareInfo;
// #ifdef APP-PLUS
poster.canvasId = 'canvasId-' + new Date().getTime();
// #endif
const canvas = await useCanvas(poster, vm);
return canvas;
// 获得海报数据
async function getPoster() {
painterImageUrl.value = undefined
poster.views = await getPosterData({
width: poster.css.width,
shareInfo: props.shareInfo,
});
await renderPoster();
}
defineExpose({
@@ -119,9 +127,11 @@
.popup-box {
position: relative;
}
.poster-title {
color: #999;
}
// 分享海报
.poster-btn-box {
width: 600rpx;
@@ -129,6 +139,7 @@
left: 50%;
transform: translateX(-50%);
bottom: -80rpx;
.cancel-btn {
width: 240rpx;
height: 70rpx;
@@ -139,6 +150,7 @@
font-weight: 500;
color: $dark-9;
}
.save-btn {
width: 240rpx;
height: 70rpx;
@@ -152,10 +164,5 @@
.poster-img {
border-radius: 20rpx;
}
.hideCanvas {
position: fixed;
top: -99999rpx;
left: -99999rpx;
z-index: -99999;
}
</style>
@@ -1,121 +1,125 @@
import sheep from '@/sheep';
import { formatImageUrlProtocol } from './index';
import { formatImageUrlProtocol, getWxaQrcode } from './index';
const goods = (poster) => {
const goods = async (poster) => {
const width = poster.width;
const userInfo = sheep.$store('user').userInfo;
return {
background: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.goods_bg)),
list: [
{
name: 'nickname',
type: 'text',
val: userInfo.nickname,
x: width * 0.22,
y: width * 0.06,
paintbrushProps: {
fillStyle: '#333',
font: {
fontSize: 16,
fontFamily: 'sans-serif',
},
},
const wxa_qrcode = await getWxaQrcode(poster.shareInfo.path, poster.shareInfo.query);
return [
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.goods_bg)),
css: {
width,
position: 'fixed',
'object-fit': 'contain',
top: '0',
left: '0',
zIndex: -1,
},
{
name: 'avatar',
type: 'image',
val: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
x: width * 0.04,
y: width * 0.04,
},
{
type: 'text',
text: userInfo.nickname,
css: {
color: '#333',
fontSize: 16,
fontFamily: 'sans-serif',
position: 'fixed',
top: width * 0.06,
left: width * 0.22,
},
},
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
css: {
position: 'fixed',
left: width * 0.04,
top: width * 0.04,
width: width * 0.14,
height: width * 0.14,
d: width * 0.14,
},
{
name: 'goodsImage',
type: 'image',
val: formatImageUrlProtocol(poster.shareInfo.poster.image),
x: width * 0.03,
y: width * 0.21,
},
{
type: 'image',
src: formatImageUrlProtocol(poster.shareInfo.poster.image),
css: {
position: 'fixed',
left: width * 0.03,
top: width * 0.21,
width: width * 0.94,
height: width * 0.94,
r: 10,
},
{
name: 'goodsTitle',
type: 'text',
val: poster.shareInfo.poster.title,
x: width * 0.04,
y: width * 1.18,
},
{
type: 'text',
text: poster.shareInfo.poster.title,
css: {
position: 'fixed',
left: width * 0.04,
top: width * 1.18,
color: '#333',
fontSize: 14,
lineHeight: 15,
maxWidth: width * 0.91,
line: 2,
lineHeight: 5,
paintbrushProps: {
fillStyle: '#333',
font: {
fontSize: 14,
},
},
},
{
name: 'goodsPrice',
type: 'text',
val: '¥' + poster.shareInfo.poster.price,
x: width * 0.04,
y: width * 1.3,
paintbrushProps: {
fillStyle: '#ff0000',
font: {
fontSize: 20,
fontFamily: 'OPPOSANS',
},
},
},
{
type: 'text',
text: '¥' + poster.shareInfo.poster.price,
css: {
position: 'fixed',
left: width * 0.04,
top: width * 1.31,
fontSize: 20,
fontFamily: 'OPPOSANS',
color: '#333',
},
{
name: 'goodsOriginalPrice',
type: 'text',
val:
poster.shareInfo.poster.original_price > 0
? '¥' + poster.shareInfo.poster.original_price
: '',
x: width * 0.3,
y: width * 1.32,
paintbrushProps: {
fillStyle: '#999',
font: {
fontSize: 10,
fontFamily: 'OPPOSANS',
},
},
textDecoration: {
line: 'line-through',
style: 'solide',
},
},
{
type: 'text',
text:
poster.shareInfo.poster.original_price > 0
? '¥' + poster.shareInfo.poster.original_price
: '',
css: {
position: 'fixed',
left: width * 0.3,
top: width * 1.33,
color: '#999',
fontSize: 10,
fontFamily: 'OPPOSANS',
textDecoration: 'line-through',
},
// #ifndef MP-WEIXIN
{
name: 'qrcode',
type: 'qrcode',
val: poster.shareInfo.link,
x: width * 0.75,
y: width * 1.3,
size: width * 0.2,
},
// #endif
// #ifdef MP-WEIXIN
{
name: 'wxacode',
type: 'image',
val: sheep.$api.third.wechat.getWxacode(poster.shareInfo.path),
x: width * 0.75,
y: width * 1.3,
},
// #ifndef MP-WEIXIN
{
type: 'qrcode',
text: poster.shareInfo.link,
css: {
position: 'fixed',
left: width * 0.75,
top: width * 1.3,
width: width * 0.2,
height: width * 0.2,
},
// #endif
],
};
},
// #endif
// #ifdef MP-WEIXIN
{
type: 'image',
src: wxa_qrcode,
css: {
position: 'fixed',
left: width * 0.75,
top: width * 1.3,
width: width * 0.2,
height: width * 0.2,
},
},
// #endif
];
};
export default goods;
@@ -1,114 +1,122 @@
import sheep from '@/sheep';
import { formatImageUrlProtocol } from './index';
import { formatImageUrlProtocol, getWxaQrcode } from './index';
const groupon = (poster) => {
const groupon = async (poster) => {
const width = poster.width;
const userInfo = sheep.$store('user').userInfo;
return {
background: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.groupon_bg)),
list: [
{
name: 'nickname',
type: 'text',
val: userInfo.nickname,
x: width * 0.22,
y: width * 0.06,
paintbrushProps: {
fillStyle: '#333',
font: {
fontSize: 16,
fontFamily: 'sans-serif',
},
},
const wxa_qrcode = await getWxaQrcode(poster.shareInfo.path, poster.shareInfo.query);
return [
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.groupon_bg)),
css: {
width,
position: 'fixed',
'object-fit': 'contain',
top: '0',
left: '0',
zIndex: -1,
},
{
name: 'avatar',
type: 'image',
val: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
x: width * 0.04,
y: width * 0.04,
},
{
type: 'text',
text: userInfo.nickname,
css: {
color: '#333',
fontSize: 16,
fontFamily: 'sans-serif',
position: 'fixed',
top: width * 0.06,
left: width * 0.22,
},
},
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
css: {
position: 'fixed',
left: width * 0.04,
top: width * 0.04,
width: width * 0.14,
height: width * 0.14,
d: width * 0.14,
},
{
name: 'goodsImage',
type: 'image',
val: formatImageUrlProtocol(poster.shareInfo.poster.image),
x: width * 0.03,
y: width * 0.21,
},
{
type: 'image',
src: formatImageUrlProtocol(poster.shareInfo.poster.image),
css: {
position: 'fixed',
left: width * 0.03,
top: width * 0.21,
width: width * 0.94,
height: width * 0.94,
r: 10,
borderRadius: 10,
},
{
name: 'goodsTitle',
type: 'text',
val: poster.shareInfo.poster.title,
x: width * 0.04,
y: width * 1.18,
},
{
type: 'text',
text: poster.shareInfo.poster.title,
css: {
color: '#333',
fontSize: 14,
position: 'fixed',
top: width * 1.18,
left: width * 0.04,
maxWidth: width * 0.91,
line: 2,
lineHeight: 5,
paintbrushProps: {
fillStyle: '#333',
font: {
fontSize: 14,
},
},
},
{
name: 'goodsPrice',
type: 'text',
val: '¥' + poster.shareInfo.poster.price,
x: width * 0.04,
y: width * 1.3,
paintbrushProps: {
fillStyle: '#ff0000',
font: {
fontSize: 20,
fontFamily: 'OPPOSANS',
},
},
},
{
type: 'text',
text: '¥' + poster.shareInfo.poster.price,
css: {
color: '#ff0000',
fontSize: 20,
fontFamily: 'OPPOSANS',
position: 'fixed',
top: width * 1.3,
left: width * 0.04,
},
{
name: 'grouponNum',
type: 'text',
val: '2人团',
x: width * 0.3,
y: width * 1.32,
paintbrushProps: {
fillStyle: '#ff0000',
font: {
fontSize: 10,
fontFamily: 'OPPOSANS',
},
},
},
{
type: 'text',
text: '2人团',
css: {
color: '#ff0000',
fontSize: 30,
fontFamily: 'OPPOSANS',
position: 'fixed',
left: width * 0.3,
top: width * 1.32,
},
// #ifndef MP-WEIXIN
{
name: 'qrcode',
type: 'qrcode',
val: poster.shareInfo.link,
x: width * 0.75,
y: width * 1.3,
size: width * 0.2,
},
// #endif
// #ifdef MP-WEIXIN
{
name: 'wxacode',
type: 'image',
val: sheep.$api.third.wechat.getWxacode(poster.shareInfo.path),
x: width * 0.75,
y: width * 1.3,
},
// #ifndef MP-WEIXIN
{
type: 'qrcode',
text: poster.shareInfo.link,
css: {
position: 'fixed',
left: width * 0.75,
top: width * 1.3,
width: width * 0.2,
height: width * 0.2,
},
// #endif
],
};
},
// #endif
// #ifdef MP-WEIXIN
{
type: 'image',
src: wxa_qrcode,
css: {
position: 'fixed',
left: width * 0.75,
top: width * 1.3,
width: width * 0.2,
height: width * 0.2,
},
},
// #endif
];
};
export default groupon;
@@ -1,6 +1,7 @@
import user from './user';
import goods from './goods';
import groupon from './groupon';
import SocialApi from '@/sheep/api/member/social';
export function getPosterData(options) {
switch (options.shareInfo.poster.type) {
@@ -30,3 +31,9 @@ export function formatImageUrlProtocol(url) {
return url;
}
// 获得微信小程序码 (Base64 image)
export async function getWxaQrcode(path, query) {
const res = await SocialApi.getWxaQrcode(path, query);
return 'data:image/png;base64,' + res.data;
}
@@ -1,61 +1,74 @@
import sheep from '@/sheep';
import { formatImageUrlProtocol } from './index';
import { formatImageUrlProtocol, getWxaQrcode } from './index';
const user = (poster) => {
const user = async (poster) => {
const width = poster.width;
const userInfo = sheep.$store('user').userInfo;
return {
background: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.user_bg)),
list: [
{
name: 'nickname',
type: 'text',
val: userInfo.nickname,
x: width / 2,
y: width * 0.4,
paintbrushProps: {
textAlign: 'center',
fillStyle: '#333',
font: {
fontSize: 14,
fontFamily: 'sans-serif',
},
},
const wxa_qrcode = await getWxaQrcode(poster.shareInfo.path, poster.shareInfo.query);
return [
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(sheep.$store('app').platform.share.posterInfo.user_bg)),
css: {
width,
position: 'fixed',
'object-fit': 'contain',
top: '0',
left: '0',
zIndex: -1,
},
{
name: 'avatar',
type: 'image',
val: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
x: width * 0.4,
y: width * 0.16,
},
{
type: 'text',
text: userInfo.nickname,
css: {
color: '#333',
fontSize: 14,
textAlign: 'center',
fontFamily: 'sans-serif',
position: 'fixed',
top: width * 0.4,
left: width / 2,
},
},
{
type: 'image',
src: formatImageUrlProtocol(sheep.$url.cdn(userInfo.avatar)),
css: {
position: 'fixed',
left: width * 0.4,
top: width * 0.16,
width: width * 0.2,
height: width * 0.2,
d: width * 0.2,
},
// #ifndef MP-WEIXIN
{
name: 'qrcode',
type: 'qrcode',
val: poster.shareInfo.link,
x: width * 0.35,
y: width * 0.84,
size: width * 0.3,
},
// #endif
// #ifdef MP-WEIXIN
{
name: 'wxacode',
type: 'image',
val: sheep.$api.third.wechat.getWxacode(poster.shareInfo.path),
x: width * 0.35,
y: width * 0.84,
},
// #ifndef MP-WEIXIN
{
type: 'qrcode',
text: poster.shareInfo.link,
css: {
position: 'fixed',
left: width * 0.35,
top: width * 0.84,
width: width * 0.3,
height: width * 0.3,
},
// #endif
],
};
},
// #endif
// #ifdef MP-WEIXIN
{
type: 'image',
src: wxa_qrcode,
css: {
position: 'fixed',
left: width * 0.35,
top: width * 0.84,
width: width * 0.3,
height: width * 0.3,
},
},
// #endif
];
};
export default user;
@@ -1,87 +0,0 @@
/**
* Shopro + qs-canvas 绘制海报
* @version 1.0.0
* @author lidongtony
* @param {Object} options - 海报参数
* @param {Object} vm - 自定义组件实例
*/
import QSCanvas from 'qs-canvas';
import { getPosterData } from './poster';
export default async function useCanvas(options, vm) {
const width = options.width;
const qsc = new QSCanvas(
{
canvasId: options.canvasId,
width: options.width,
height: options.height,
setCanvasWH: (canvas) => {
options.height = canvas.height;
},
},
vm,
);
let drawer = getPosterData(options);
// 绘制背景图
const background = await qsc.drawImg({
type: 'image',
val: drawer.background,
x: 0,
y: 0,
width,
mode: 'widthFix',
zIndex: 0,
});
await qsc.updateCanvasWH({
width: background.width,
height: background.bottom,
});
let list = drawer.list;
for (let i = 0; i < list.length; i++) {
let item = list[i];
// 绘制文字
if (item.type === 'text') {
await qsc.drawText(item);
}
// 绘制图片
if (item.type === 'image') {
if (item.d) {
qsc.setCircle({
x: item.x,
y: item.y,
d: item.d,
clip: true,
});
}
if (item.r) {
qsc.setRect({
x: item.x,
y: item.y,
height: item.height,
width: item.width,
r: item.r,
clip: true,
});
}
await qsc.drawImg(item);
qsc.restore();
}
// 绘制二维码
if (item.type === 'qrcode') {
await qsc.drawQrCode(item);
}
}
await qsc.draw();
// 延迟执行, 防止不稳定
setTimeout(async () => {
options.src = await qsc.toImage();
}, 100);
return options;
}
@@ -92,6 +92,7 @@
showAuthModal();
return;
}
console.log(props.shareInfo);
unref(SharePosterRef).getPoster();
state.showPosterModal = true;
};
+4
View File
@@ -11,9 +11,13 @@ console.log(`[芋道商城 ${version}] http://doc.iocoder.cn`);
export const apiPath = import.meta.env.SHOPRO_API_PATH;
export const staticUrl = import.meta.env.SHOPRO_STATIC_URL;
export const tenantId = import.meta.env.SHOPRO_TENANT_ID;
export const websocketPath = import.meta.env.SHOPRO_WEBSOCKET_PATH;
export default {
baseUrl,
apiPath,
staticUrl,
tenantId,
websocketPath,
};
+1 -1
View File
@@ -45,7 +45,7 @@ export function last(data) {
}
export function cloneDeep(obj) {
const d = isArray(obj) ? obj : {};
const d = isArray(obj) ? [...obj] : {};
if (isObject(obj)) {
for (const key in obj) {
+3 -2
View File
@@ -9,12 +9,13 @@ import AuthUtil from '@/sheep/api/member/auth';
export function showAuthModal(type = 'smsLogin') {
const modal = $store('modal');
if (modal.auth !== '') {
closeAuthModal();
// 注意:延迟修改,保证下面的 closeAuthModal 先执行掉
setTimeout(() => {
modal.$patch((state) => {
state.auth = type;
});
}, 100);
}, 200);
closeAuthModal();
} else {
modal.$patch((state) => {
state.auth = type;
+157
View File
@@ -0,0 +1,157 @@
import { onBeforeUnmount, reactive, ref } from 'vue';
import { baseUrl, websocketPath } from '@/sheep/config';
import { copyValueToTarget } from '@/sheep/util';
/**
* WebSocket 创建 hook
* @param opt 连接配置
* @return {{options: *}}
*/
export function useWebSocket(opt) {
const getAccessToken = () => {
return uni.getStorageSync('token');
};
const options = reactive({
url: (baseUrl + websocketPath).replace('http', 'ws') + '?token=' + getAccessToken(), // ws 地址
isReconnecting: false, // 正在重新连接
reconnectInterval: 3000, // 重连间隔,单位毫秒
heartBeatInterval: 5000, // 心跳间隔,单位毫秒
pingTimeoutDuration: 1000, // 超过这个时间,后端没有返回pong,则判定后端断线了。
heartBeatTimer: null, // 心跳计时器
destroy: false, // 是否销毁
pingTimeout: null, // 心跳检测定时器
reconnectTimeout: null, // 重连定时器ID的属性
onConnected: () => {
}, // 连接成功时触发
onClosed: () => {
}, // 连接关闭时触发
onMessage: (data) => {
}, // 收到消息
});
const SocketTask = ref(null); // SocketTask 由 uni.connectSocket() 接口创建
const initEventListeners = () => {
// 监听 WebSocket 连接打开事件
SocketTask.value.onOpen(() => {
console.log('WebSocket 连接成功');
// 连接成功时触发
options.onConnected();
// 开启心跳检查
startHeartBeat();
});
// 监听 WebSocket 接受到服务器的消息事件
SocketTask.value.onMessage((res) => {
try {
if (res.data === 'pong') {
// 收到心跳重置心跳超时检查
resetPingTimeout();
} else {
options.onMessage(JSON.parse(res.data));
}
} catch (error) {
console.error(error);
}
});
// 监听 WebSocket 连接关闭事件
SocketTask.value.onClose((event) => {
// 情况一:实例销毁
if (options.destroy) {
options.onClosed();
} else { // 情况二:连接失败重连
// 停止心跳检查
stopHeartBeat();
// 重连
reconnect();
}
});
};
// 发送消息
const sendMessage = (message) => {
if (SocketTask.value && !options.destroy) {
SocketTask.value.send({ data: message });
}
};
// 开始心跳检查
const startHeartBeat = () => {
options.heartBeatTimer = setInterval(() => {
sendMessage('ping');
options.pingTimeout = setTimeout(() => {
// 如果在超时时间内没有收到 pong,则认为连接断开
reconnect();
}, options.pingTimeoutDuration);
}, options.heartBeatInterval);
};
// 停止心跳检查
const stopHeartBeat = () => {
clearInterval(options.heartBeatTimer);
resetPingTimeout();
};
// WebSocket 重连
const reconnect = () => {
if (options.destroy || !SocketTask.value) {
// 如果WebSocket已被销毁或尚未完全关闭,不进行重连
return;
}
// 重连中
options.isReconnecting = true;
// 清除现有的重连标志,以避免多次重连
if (options.reconnectTimeout) {
clearTimeout(options.reconnectTimeout);
}
// 设置重连延迟
options.reconnectTimeout = setTimeout(() => {
// 检查组件是否仍在运行和WebSocket是否关闭
if (!options.destroy) {
// 重置重连标志
options.isReconnecting = false;
// 初始化新的WebSocket连接
initSocket();
}
}, options.reconnectInterval);
};
const resetPingTimeout = () => {
if (options.pingTimeout) {
clearTimeout(options.pingTimeout);
options.pingTimeout = null; // 清除超时ID
}
};
const close = () => {
options.destroy = true;
stopHeartBeat();
if (options.reconnectTimeout) {
clearTimeout(options.reconnectTimeout);
}
if (SocketTask.value) {
SocketTask.value.close();
SocketTask.value = null;
}
};
const initSocket = () => {
options.destroy = false;
copyValueToTarget(options, opt);
SocketTask.value = uni.connectSocket({
url: options.url,
complete: () => {
},
success: () => {
},
});
initEventListeners();
};
initSocket();
onBeforeUnmount(() => {
close();
});
return { options };
}
+21 -12
View File
@@ -1,4 +1,3 @@
// import third from '@/sheep/api/third';
import AuthUtil from '@/sheep/api/member/auth';
import SocialApi from '@/sheep/api/member/social';
import UserApi from '@/sheep/api/member/user';
@@ -128,14 +127,14 @@ async function getInfo() {
const checkUpdate = async (silence = true) => {
if (uni.canIUse('getUpdateManager')) {
const updateManager = uni.getUpdateManager();
updateManager.onCheckForUpdate(function (res) {
updateManager.onCheckForUpdate(function(res) {
// 请求完新版本信息的回调
if (res.hasUpdate) {
updateManager.onUpdateReady(function () {
updateManager.onUpdateReady(function() {
uni.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
success: function (res) {
success: function(res) {
if (res.confirm) {
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate();
@@ -143,7 +142,7 @@ const checkUpdate = async (silence = true) => {
},
});
});
updateManager.onUpdateFailed(function () {
updateManager.onUpdateFailed(function() {
// 新的版本下载失败
// uni.showModal({
// title: '已经有新版本了哟~',
@@ -164,27 +163,37 @@ const checkUpdate = async (silence = true) => {
// 获取订阅消息模板
async function getSubscribeTemplate() {
const { error, data } = await third.wechat.subscribeTemplate();
if (error === 0) {
const { code, data } = await SocialApi.getSubscribeTemplateList();
if (code === 0) {
subscribeEventList = data;
}
}
// 订阅消息
function subscribeMessage(event) {
function subscribeMessage(event, callback= undefined) {
let tmplIds = [];
if (typeof event === 'string') {
tmplIds.push(subscribeEventList[event]);
const temp = subscribeEventList.find(item => item.title.includes(event));
if (temp) {
tmplIds.push(temp.id);
}
}
if (typeof event === 'object') {
event.forEach((item) => {
if (typeof subscribeEventList[item] !== 'undefined') tmplIds.push(subscribeEventList[item]);
event.forEach((e) => {
const temp = subscribeEventList.find(item => item.title.includes(e));
if (temp) {
tmplIds.push(temp.id);
}
});
}
if (tmplIds.length === 0) return;
uni.requestSubscribeMessage({
tmplIds,
success: ()=>{
// 不管是拒绝还是同意都触发
callback && callback()
},
fail: (err) => {
console.log(err);
},
@@ -201,5 +210,5 @@ export default {
getInfo,
getOpenid,
subscribeMessage,
checkUpdate
checkUpdate,
};
@@ -1,6 +1,7 @@
// 登录
// import third from '@/sheep/api/third';
import third from '@/sheep/api/migration/third';
import SocialApi from '@/sheep/api/member/social';
import $share from '@/sheep/platform/share';
// TODO 芋艿:等后面搞 App 再弄
const socialType = 32; // 社交类型 - 微信开放平台
@@ -16,6 +17,7 @@ const login = () => {
});
debugger
if (loginRes.errMsg == 'login:ok') {
// TODO third.wechat.login 函数未实现
const res = await third.wechat.login({
platform: 'openPlatform',
shareInfo: uni.getStorageSync('shareLog') || {},
@@ -27,6 +29,7 @@ const login = () => {
});
if (res.error === 0) {
$share.bindBrokerageUser()
resolve(true);
}
} else {
+37 -13
View File
@@ -2,6 +2,7 @@ import $store from '@/sheep/store';
import $platform from '@/sheep/platform';
import $router from '@/sheep/router';
import $url from '@/sheep/url';
import BrokerageApi from '@/sheep/api/trade/brokerage';
// #ifdef H5
import $wxsdk from '@/sheep/libs/sdk-h5-weixin';
// #endif
@@ -26,7 +27,7 @@ const getShareInfo = (
type: 'user',
},
) => {
let shareInfo = {
const shareInfo = {
title: '', // 分享标题
desc: '', // 描述
image: '', // 分享图片
@@ -45,9 +46,12 @@ const getShareInfo = (
// 配置分享链接地址
shareInfo.link = buildSpmLink(query, shareConfig.linkAddress);
// 配置页面地址带参数
shareInfo.path = buildSpmPath(query);
// 配置转发参数
if (shareConfig.methods.includes('forward')) {
// TODO puhui999: forward 这块有点问题
if (shareConfig.forwardInfo.title === '' || shareConfig.forwardInfo.image === '') {
console.log('请在平台设置中配置转发信息');
}
@@ -55,23 +59,27 @@ const getShareInfo = (
shareInfo.title = scene.title || shareConfig.forwardInfo.title;
shareInfo.image = $url.cdn(scene.image || shareConfig.forwardInfo.image);
shareInfo.desc = scene.desc || shareConfig.forwardInfo.subtitle;
shareInfo.path = buildSpmPath(query);
shareInfo.path = buildSpmPath(scene.path, query);
}
return shareInfo;
};
// 构造spm分享参数
/**
* 构造 spm 分享参数
*
* @param params json 格式,其中包含:1)shareId 分享用户的编号;2)page 页面类型;3)query 页面 ID(参数);4)platform 平台类型;5)from 分享来源类型。
* @return 分享串 `spm=${shareId}.${page}.${query}.${platform}.${from}`
*/
const buildSpmQuery = (params) => {
const user = $store('user');
let shareId = '0'; // 设置分享者用户ID
if (typeof params.shareId === 'undefined') {
if (user.isLogin) {
// todo @芋艿 id为空
shareId = user.userInfo.id;
}
}
let page = '1'; // 页面类型: 1=首页(默认),2=商品,3=拼团商品,4=秒杀商品,5=邀请参团...按需扩展
let page = '1'; // 页面类型: 1=首页(默认),2=商品,3=拼团商品,4=秒杀商品,5=邀请参团,6=分销邀请...按需扩展
if (typeof params.page !== 'undefined') {
page = params.page;
}
@@ -84,13 +92,15 @@ const buildSpmQuery = (params) => {
if (typeof params.from !== 'undefined') {
from = platformMap.indexOf(params.from) + 1;
}
//spmParams = ... 可按需扩展
// spmParams = ... 可按需扩展
return `spm=${shareId}.${page}.${query}.${platform}.${from}`;
};
// 构造页面分享参数
// 构造页面分享参数: 所有的分享都先到首页进行 spm 参数解析
const buildSpmPath = (query) => {
return `/pages/index/index?${query}`;
// 默认是主页,页面 page,例如 pages/index/index,根路径前不要填加 /,
// 不能携带参数(参数请放在scene字段里),如果不填写这个字段,默认跳主页面。scancode_time为系统保留参数,不允许配置
return `pages/index/index`;
};
// 构造分享链接
@@ -151,13 +161,13 @@ const decryptSpm = (spm) => {
}
shareParams.platform = platformMap[shareParamsArray[3] - 1];
shareParams.from = fromMap[shareParamsArray[4] - 1];
if (shareParams.shareId != 0) {
// 已登录 立即添加分享记录
if (shareParams.shareId !== 0) {
// 已登录 绑定推广员
if (user.isLogin) {
user.addShareLog(shareParams);
bindBrokerageUser(shareParams.shareId);
} else {
// 未登录 待用户登录后添加分享记录
uni.setStorageSync('shareLog', shareParams);
// 记录分享者编号
uni.setStorageSync('shareId', shareParams.shareId);
}
}
@@ -167,6 +177,19 @@ const decryptSpm = (spm) => {
return shareParams;
};
// 绑定推广员
const bindBrokerageUser = async (val= undefined) => {
try {
const shareId = val || uni.getStorageSync('shareId');
if (!shareId) {
return;
}
await BrokerageApi.bindBrokerageUser({ bindUserId: shareId });
uni.removeStorageSync('shareId');
} catch {
}
};
// 更新公众号分享sdk
const updateShareInfo = (shareInfo) => {
// #ifdef H5
@@ -180,4 +203,5 @@ export default {
getShareInfo,
updateShareInfo,
decryptSpm,
bindBrokerageUser,
};
+4 -4
View File
@@ -4,7 +4,7 @@
*/
import Request from 'luch-request';
import { baseUrl, apiPath } from '@/sheep/config';
import { baseUrl, apiPath, tenantId } from '@/sheep/config';
import $store from '@/sheep/store';
import $platform from '@/sheep/platform';
import {
@@ -96,8 +96,8 @@ http.interceptors.request.use(
config.header['Authorization'] = token;
}
// TODO 芋艿:特殊处理
config.header['Accept'] = '*/*'
config.header['tenant-id'] = '1';
config.header['Accept'] = '*/*';
config.header['tenant-id'] = tenantId;
config.header['terminal'] = '20';
// config.header['Authorization'] = 'Bearer test247';
return config;
@@ -299,4 +299,4 @@ const request = (config) => {
return http.middleware(config);
};
export default request;
export default request;
+3 -8
View File
@@ -28,7 +28,6 @@ const app = defineStore({
},
bind_mobile: 0, // 登陆后绑定手机号提醒 (弱提醒,可手动关闭)
},
chat: {},
template: {
// 店铺装修模板
basic: {}, // 基本信息
@@ -63,7 +62,7 @@ const app = defineStore({
this.info = {
name: '芋道商城',
logo: 'https://static.iocoder.cn/ruoyi-vue-pro-logo.png',
version: '1.1.13',
version: '2.2.0',
copyright: '全部开源,个人与企业可 100% 免费使用',
copytime: 'Copyright© 2018-2024',
@@ -72,8 +71,8 @@ const app = defineStore({
};
this.platform = {
share: {
methods: [ "poster", "link" ],
linkAddress: "https://shopro.sheepjs.com/#/",
methods: ["poster", "link"],
linkAddress: "http://127.0.0.1:3000", // TODO 芋艿:可以考虑改到 .env 那
posterInfo: {
"user_bg": "/static/img/shop/config/user-poster-bg.png",
"goods_bg": "/static/img/shop/config/goods-poster-bg.png",
@@ -82,10 +81,6 @@ const app = defineStore({
},
bind_mobile: 0
};
this.chat = {
chat_domain: "https://api.shopro.sheepjs.com/chat",
room_id: "admin"
}
this.has_wechat_trade_managed = 0;
// 加载主题
+75 -99
View File
@@ -1,18 +1,9 @@
import {
defineStore
} from 'pinia';
// import userApi from '@/sheep/api/user';
import { defineStore } from 'pinia';
import $share from '@/sheep/platform/share';
import {
isEmpty,
cloneDeep,
clone
} from 'lodash';
import { clone, cloneDeep } from 'lodash';
import cart from './cart';
import app from './app';
import {
showAuthModal
} from '@/sheep/hooks/useModal';
import { showAuthModal } from '@/sheep/hooks/useModal';
import UserApi from '@/sheep/api/member/user';
import PayWalletApi from '@/sheep/api/pay/wallet';
import OrderApi from '@/sheep/api/trade/order';
@@ -20,17 +11,17 @@ import CouponApi from '@/sheep/api/promotion/coupon';
// 默认用户信息
const defaultUserInfo = {
avatar: '', // 头像
nickname: '', // 昵称
gender: 0, // 性别
mobile: '', // 手机号
avatar: '', // 头像
nickname: '', // 昵称
gender: 0, // 性别
mobile: '', // 手机号
point: 0, // 积分
};
// 默认钱包信息
const defaultUserWallet = {
balance: 0, // 余额
}
};
// 默认订单、优惠券等其他资产信息
const defaultNumData = {
@@ -42,29 +33,29 @@ const defaultNumData = {
deliveredCount: 0,
uncommentedCount: 0,
afterSaleCount: 0,
},
},
};
const user = defineStore({
id: 'user',
state: () => ({
userInfo: clone(defaultUserInfo), // 用户信息
id: 'user',
state: () => ({
userInfo: clone(defaultUserInfo), // 用户信息
userWallet: clone(defaultUserWallet), // 用户钱包信息
isLogin: !!uni.getStorageSync('token'), // 登录状态
numData: cloneDeep(defaultNumData), // 用户其他数据
lastUpdateTime: 0, // 上次更新时间
}),
isLogin: !!uni.getStorageSync('token'), // 登录状态
numData: cloneDeep(defaultNumData), // 用户其他数据
lastUpdateTime: 0, // 上次更新时间
}),
actions: {
// 获取用户信息
async getInfo() {
const { code, data } = await UserApi.getUserInfo();
actions: {
// 获取用户信息
async getInfo() {
const { code, data } = await UserApi.getUserInfo();
if (code !== 0) {
return;
}
this.userInfo = data;
return Promise.resolve(data);
},
return Promise.resolve(data);
},
// 获得用户钱包
async getWallet() {
@@ -75,7 +66,7 @@ const user = defineStore({
this.userWallet = data;
},
// 获取订单、优惠券等其他资产信息
// 获取订单、优惠券等其他资产信息
getNumData() {
OrderApi.getOrderCount().then(res => {
if (res.code === 0) {
@@ -87,101 +78,86 @@ const user = defineStore({
this.numData.unusedCouponCount = res.data;
}
});
},
},
// 添加分享记录
// TODO 芋艿:整理下;
async addShareLog(params) {
const {
error
} = await userApi.addShareLog(params);
if (error === 0) uni.removeStorageSync('shareLog');
},
// 设置 token
// 设置 token
setToken(token = '', refreshToken = '') {
if (token === '') {
this.isLogin = false;
uni.removeStorageSync('token');
uni.removeStorageSync('refresh-token')
} else {
this.isLogin = true;
uni.setStorageSync('token', token);
if (token === '') {
this.isLogin = false;
uni.removeStorageSync('token');
uni.removeStorageSync('refresh-token');
} else {
this.isLogin = true;
uni.setStorageSync('token', token);
uni.setStorageSync('refresh-token', refreshToken);
this.loginAfter();
}
return this.isLogin;
},
this.loginAfter();
}
return this.isLogin;
},
// 更新用户相关信息 (手动限流,5 秒之内不刷新)
// 更新用户相关信息 (手动限流,5 秒之内不刷新)
async updateUserData() {
if (!this.isLogin) {
this.resetUserData();
return;
}
if (!this.isLogin) {
this.resetUserData();
return;
}
// 防抖,5 秒之内不刷新
const nowTime = new Date().getTime();
const nowTime = new Date().getTime();
if (this.lastUpdateTime + 5000 > nowTime) {
return;
}
this.lastUpdateTime = nowTime;
// 获取最新信息
await this.getInfo();
await this.getInfo();
this.getWallet();
this.getNumData();
return this.userInfo;
},
this.getNumData();
return this.userInfo;
},
// 重置用户默认数据
// 重置用户默认数据
resetUserData() {
// 清空 token
this.setToken();
this.setToken();
// 清空用户相关的缓存
this.userInfo = clone(defaultUserInfo);
this.userInfo = clone(defaultUserInfo);
this.userWallet = clone(defaultUserWallet);
this.numData = cloneDeep(defaultNumData);
this.numData = cloneDeep(defaultNumData);
// 清空购物车的缓存
cart().emptyList();
},
cart().emptyList();
},
// 登录后,加载各种信息
// 登录后,加载各种信息
// TODO 芋艿:整理下;
async loginAfter() {
await this.updateUserData();
await this.updateUserData();
// 加载购物车
cart().getList();
// 登录后设置全局分享参数
$share.getShareInfo();
cart().getList();
// 登录后设置全局分享参数
$share.getShareInfo();
// 提醒绑定手机号
if (app().platform.bind_mobile && !this.userInfo.mobile) {
showAuthModal('changeMobile');
}
// 提醒绑定手机号
if (app().platform.bind_mobile && !this.userInfo.mobile) {
showAuthModal('changeMobile');
}
// 添加分享记录
// TODO 芋艿:整理下;
const shareLog = uni.getStorageSync('shareLog');
if (!isEmpty(shareLog)) {
this.addShareLog({
...shareLog,
});
}
},
// 绑定推广员
$share.bindBrokerageUser()
},
// 登出系统
// 登出系统
async logout() {
this.resetUserData();
return !this.isLogin;
}
},
persist: {
enabled: true,
strategies: [{
key: 'user-store',
}, ],
},
return !this.isLogin;
}
},
persist: {
enabled: true,
strategies: [{
key: 'user-store',
}]
},
});
export default user;
export default user;
+9
View File
@@ -56,6 +56,15 @@ export const TimeStatusEnum = {
END: '已结束',
}
/**
* 微信小程序的订阅模版
*/
export const WxaSubscribeTemplate = {
TRADE_ORDER_DELIVERY: "订单发货通知",
PROMOTION_COMBINATION_SUCCESS: "拼团结果通知",
PAY_WALLET_RECHARGER_SUCCESS: "充值成功通知",
}
export const getTimeStatusEnum = (startTime, endTime) => {
const now = dayjs();
if (now.isBefore(startTime)) {
+20 -2
View File
@@ -64,7 +64,7 @@ export const convertToInteger = (num) => {
* @description format 季度 + 星期 + 几周:"YYYY-mm-dd HH:MM:SS WWW QQQQ ZZZ"
* @returns {string} 返回拼接后的时间字符串
*/
export function formatDate(date, format) {
export function formatDate(date, format= 'YYYY-MM-DD HH:mm:ss') {
// 日期不存在,则返回空
if (!date) {
return ''
@@ -112,4 +112,22 @@ export function resetPagination(pagination) {
pagination.list = [];
pagination.total = 0;
pagination.pageNo = 1;
}
}
/**
* 将值复制到目标对象,且以目标对象属性为准,例:target: {a:1} source:{a:2,b:3} 结果为:{a:2}
* @param target 目标对象
* @param source 源对象
*/
export const copyValueToTarget = (target, source) => {
const newObj = Object.assign({}, target, source)
// 删除多余属性
Object.keys(newObj).forEach((key) => {
// 如果不是target中的属性则删除
if (Object.keys(target).indexOf(key) === -1) {
delete newObj[key]
}
})
// 更新目标对象值
Object.assign(target, newObj)
}
+223
View File
@@ -0,0 +1,223 @@
## 1.9.6.5(2024-04-14)
- fix: 修复`nvue`无法生图的问题
## 1.9.6.4(2024-03-10)
- fix: 修复代理ctx导致H5不能使用ctx.save
## 1.9.6.3(2024-03-08)
- fix: 修复支付宝真机无法使用的问题
## 1.9.6.2(2024-02-22)
- fix: 修复使用render函数报错的问题
## 1.9.6.1(2023-12-22)
- fix: 修复字节小程序非2d字体偏移
- fix: 修复`canvasToTempFilePathSync`会触发两次的问题
- fix: 修复`parser`图片没有宽度的问题
## 1.9.6(2023-12-06)
- fix: 修复背景图受padding影响
- fix: 修复因字节报错改了代理实现导致微信报错
- 1.9.5.8(2023-11-16)
- fix: 修复margin问题
- fix: 修复borderWidth问题
- fix: 修复textBox问题
- fix: 修复字节开发工具报`could not be cloned.`问题
## 1.9.5.7(2023-07-27)
- fix: 去掉多余的方法
- chore: 更新文档,增加自定义字体说明
## 1.9.5.6(2023-07-21)
- feat: 有限的支持富文本
- feat: H5和APP 增加 `hidpi` prop,主要用于大尺寸无法生成图片时用
- fix: 修复 钉钉小程序 缺少 `measureText` 方法
- chore: 由于微信小程序 pc 端的 canvas 2d 时不时抽风,故不使用canvas 2d
## 1.9.5.5(2023-06-27)
- fix: 修复把`emoji`表情字符拆分成多个字符的情况
## 1.9.5.4(2023-06-05)
- fix: 修复因`canvasToTempFilePathSync`监听导致重复调用
## 1.9.5.3(2023-05-23)
- fix: 因isPc错写成了isPC导致小程序PC不能生成图片
## 1.9.5.2(2023-05-22)
- feat: 删除多余文件
## 1.9.5.1(2023-05-22)
- fix: 修复 文字行数与`line-clamp`相同但不满一行时也加了省略号的问题
## 1.9.5(2023-05-14)
- feat: 增加 `text-indent` 和 `calc` 方法
- feat: 优化 布局时间
## 1.9.4.4(2023-04-15)
- fix: 修复无法匹配负值
- fix: 修复 Nvue IOS getImageInfo `useCORS` 为 undefined
## 1.9.4.3(2023-04-01)
- feat: 增加支持文字描边 `text-stroke: '5rpx #fff'`
## 1.9.4.2(2023-03-30)
- fix: 修复 支付宝小程序 isPC 在手机也为true的问题
- feat: 由 微信开发工具 3060 版 无法获取图片尺寸,现 微信开发工具 3220 版 修复该问题,故还原上一版的获取图片方式。
## 1.9.4.1(2023-03-28)
- fix: 修复固定高度不正确问题
## 1.9.4(2023-03-17)
- fix: nvue ios getImageInfo缺少this报错
- fix: pathType 非2d无效问题
- fix: 修复 小米9se 可能会存在多次init 导致画面多次放大
- fix: 修复 border 分开写 width style无效问题
- fix: 修复 支付宝小程序IOS 再次进入不渲染的问题
- fix: 修复 支付宝小程序安卓Zindex排序错乱问题
- fix: 修复 微信开发工具 3060 版 无法获取图片的问题
- feat: 把 for in 改为 forEach
- feat: 增加 hidden
- feat: 根节点 box-sizing 默认 `border-box`
- feat: 增加支持 `vw` `wh`
- chore: pathType 取消 默认值,因为字节开发工具不能显示
- chore: 支付宝小程序开发工具不支持 生成图片 请以真机调试为准
- bug: 企业微信 2.20.3无法使用
## 1.9.3.5(2022-06-29)
- feat: justifyContent 增加 `space-around`、`space-between`
- feat: canvas 2d 也使用`getImageInfo`
- fix: 修复 `text`的 `text-decoration`错位
## 1.9.3.4(2022-06-20)
- fix: 修复 因创建节点速度问题导致顺序出错。
- fix: 修复 微信小程序 PC 无法显示本地图片
- fix: 修复 flex-box 对齐问题
- feat: 增加 `text-shadow`
- feat: 重写 `text` 对齐方式
- chore: 更新文档
## 1.9.3.3(2022-06-17)
- fix: 修复 支付宝小程序 canvas 2d 存在ctx.draw问题导致报错
- fix: 修复 支付宝小程序 toDataURL 存在权限问题改用 `toTempFilePath`
- fix: 修复 支付宝小程序 image size 问题导致 `objectFit` 无效
## 1.9.3.2(2022-06-14)
- fix: 修复 image 设置背景色不生效问题
- fix: 修复 nvue 环境判断缺少参数问题
## 1.9.3.1(2022-06-14)
- fix: 修复 bottom 定位不对问题
- fix: 修复 因小数导致计算出错换行问题
- feat: 增加 `useCORS` h5端图片跨域 在设置请求头无效果后试一下设置这个值
- chore: 更新文档
## 1.9.3(2022-06-13)
- feat: 增加 `zIndex`
- feat: 增加 `flex-box` 该功能处于原始阶段,非常简陋。
- tips: QQ小程序 vue3 不支持, 为 uni 官方BUG
## 1.9.2.9(2022-06-10)
- fix: 修复`text-align`及`margin`居中问题
## 1.9.2.8(2022-06-10)
- fix: 修复 Nvue `canvasToTempFilePathSync` 不生效问题
## 1.9.2.7(2022-06-10)
- fix: 修复 margin及padding的bug
- fix: 修复 Nvue `isCanvasToTempFilePath` 不生效问题
## 1.9.2.6(2022-06-09)
- fix: 修复 Nvue 不显示
- feat: 增加支持字体渐变
```html
<l-painter-text
text="水调歌头\n明月几时有?把酒问青天。不知天上宫阙,今夕是何年。我欲乘风归去,又恐琼楼玉宇,高处不胜寒。起舞弄清影,何似在人间。"
css="background: linear-gradient(,#ff971b 0%, #1989fa 100%); background-clip: text" />
```
## 1.9.2.5(2022-06-09)
- chore: 更变获取父级宽度的设定
- chore: `pathType` 在canvas 2d 默认为 `url`
## 1.9.2.4(2022-06-08)
- fix: 修复 `pathType` 不生效问题
## 1.9.2.3(2022-06-08)
- fix: 修复 `canvasToTempFilePath` 漏写 `success` 参数
## 1.9.2.2(2022-06-07)
- chore: 更新文档
## 1.9.2.1(2022-06-07)
- fix: 修复 vue3 赋值给this再传入导致image无法绘制
- fix: 修复 `canvasToTempFilePathSync` 时机问题
- feat: canvas 2d 更改图片生成方式 `toDataURL`
## 1.9.2(2022-05-30)
- fix: 修复 `canvasToTempFilePathSync` 在 vue3 下只生成一次
## 1.9.1.7(2022-05-28)
- fix: 修复 `qrcode`显示不全问题
## 1.9.1.6(2022-05-28)
- fix: 修复 `canvasToTempFilePathSync` 会重复多次问题
- fix: 修复 `view` css `backgroundImage` 图片下载失败导致 子节点不渲染
## 1.9.1.5(2022-05-27)
- fix: 修正支付宝小程序 canvas 2d版本号 2.7.15
## 1.9.1.4(2022-05-22)
- fix: 修复字节小程序无法使用xml方式
- fix: 修复字节小程序无法使用base64(非2D情况下工具上无法显示)
- fix: 修复支付宝小程序 `canvasToTempFilePath` 报错
## 1.9.1.3(2022-04-29)
- fix: 修复vue3打包后uni对象为空后的报错
## 1.9.1.2(2022-04-25)
- fix: 删除多余文件
## 1.9.1.1(2022-04-25)
- fix: 修复图片不显示问题
## 1.9.1(2022-04-12)
- fix: 因四舍五入导致有些机型错位
- fix: 修复无views报错
- chore: nvue下因ios无法读取插件内static文件,改由下载方式
## 1.9.0(2022-03-20)
- fix: 因无法固定尺寸导致生成图片不全
- fix: 特定情况下text判断无效
- chore: 本地化APP Nvue webview
## 1.8.9(2022-02-20)
- fix: 修复 小程序下载最多10次并发的问题
- fix: 修复 APP端无法获取本地图片
- fix: 修复 APP Nvue端不执行问题
- chore: 增加图片缓存机制
## 1.8.8.8(2022-01-27)
- fix: 修复 主动调用尺寸问题
## 1.8.8.6(2022-01-26)
- fix: 修复 nvue 下无宽度时获取父级宽度
- fix: 修复 ios app 无法渲染问题
## 1.8.8(2022-01-23)
- fix: 修复 主动调用时无节点问题
- fix: 修复 `box-shadow` 颜色问题
- fix: 修复 `transform:rotate` 角度位置问题
- feat: 增加 `overflow:hidden`
## 1.8.7(2022-01-07)
- fix: 修复 image 方向为 `right` 时原始宽高问题
- feat: 支持 view 设置背景图 `background-image: url(xxx)`
- chore: 去掉可选链
## 1.8.6(2021-11-28)
- feat: 支持`view`对`inline-block`的子集使用`text-align`
## 1.8.5.5(2021-08-17)
- chore: 更新文档,删除 replace
- fix: 修复 text 值为 number时报错
## 1.8.5.4(2021-08-16)
- fix: 字节小程序兼容
## 1.8.5.3(2021-08-15)
- fix: 修复线性渐变与css现实效果不一致的问题
- chore: 更新文档
## 1.8.5.2(2021-08-13)
- chore: 增加`background-image`、`background-repeat` 能力,主要用于背景纹理的绘制,并不是代替`image`。例如:大面积的重复平铺的水印
- 注意:这个功能H5暂时无法使用,因为[官方的API有BUG](https://ask.dcloud.net.cn/question/128793),待官方修复!!!
## 1.8.5.1(2021-08-10)
- fix: 修复因`margin`报错问题
## 1.8.5(2021-08-09)
- chore: 增加margin支持`auto`,以达到居中效果
## 1.8.4(2021-08-06)
- chore: 增加判断缓存文件条件
- fix: 修复css 多余空格报错问题
## 1.8.3(2021-08-04)
- tips: 1.6.x 以下的版本升级到1.8.x后要为每个元素都加上定位:position: 'absolute'
- fix: 修复只有一个view子元素时不计算高度的问题
## 1.8.2(2021-08-03)
- fix: 修复 path-type 为 `url` 无效问题
- fix: 修复 qrcode `text` 为空时报错问题
- fix: 修复 image `src` 动态设置时不生效问题
- feat: 增加 css 属性 `min-width` `max-width`
## 1.8.1(2021-08-02)
- fix: 修复无法加载本地图片
## 1.8.0(2021-08-02)
- chore 文档更新
- 使用旧版的同学不要升级!
## 1.8.0-beta(2021-07-30)
- ## 全新布局方式 不兼容旧版!
- chore: 布局方式变更
- tips: 微信canvas 2d 不支持真机调试
## 1.6.6(2021-07-09)
- chore: 统一命名规范,无须主动引入组件
## 1.6.5(2021-06-08)
- chore: 去掉console
## 1.6.4(2021-06-07)
- fix: 修复 数字 为纯字符串时不转换的BUG
## 1.6.3(2021-06-06)
- fix: 修复 PC 端放大的BUG
## 1.6.2(2021-05-31)
- fix: 修复 报`adaptor is not a function`错误
- fix: 修复 text 多行高度
- fix: 优化 默认文字的基准线
- feat: `@progress`事件,监听绘制进度
## 1.6.1(2021-02-28)
- 删除多余节点
## 1.6.0(2021-02-26)
- 调整为uni_modules目录规范
- 修复:transform的rotate不能为负数问题
- 新增:`pathType` 指定生成图片返回的路径类型,可选值有 `base64`、`url`
@@ -0,0 +1,150 @@
const styles = (v ='') => v.split(';').filter(v => v && !/^[\n\s]+$/.test(v)).map(v => {
const key = v.slice(0, v.indexOf(':'))
const value = v.slice(v.indexOf(':')+1)
return {
[key
.replace(/-([a-z])/g, function() { return arguments[1].toUpperCase()})
.replace(/\s+/g, '')
]: value.replace(/^\s+/, '').replace(/\s+$/, '') || ''
}
})
export function parent(parent) {
return {
provide() {
return {
[parent]: this
}
},
data() {
return {
el: {
id: null,
css: {},
views: []
},
}
},
watch: {
css: {
handler(v) {
if(this.canvasId) {
this.el.css = (typeof v == 'object' ? v : v && Object.assign(...styles(v))) || {}
this.canvasWidth = this.el.css && this.el.css.width || this.canvasWidth
this.canvasHeight = this.el.css && this.el.css.height || this.canvasHeight
}
},
immediate: true
}
}
}
}
export function children(parent, options = {}) {
const indexKey = options.indexKey || 'index'
return {
inject: {
[parent]: {
default: null
}
},
watch: {
el: {
handler(v, o) {
if(JSON.stringify(v) != JSON.stringify(o))
this.bindRelation()
},
deep: true,
immediate: true
},
src: {
handler(v, o) {
if(v != o)
this.bindRelation()
},
immediate: true
},
text: {
handler(v, o) {
if(v != o) this.bindRelation()
},
immediate: true
},
css: {
handler(v, o) {
if(v != o)
this.el.css = (typeof v == 'object' ? v : v && Object.assign(...styles(v))) || {}
},
immediate: true
},
replace: {
handler(v, o) {
if(JSON.stringify(v) != JSON.stringify(o))
this.bindRelation()
},
deep: true,
immediate: true
}
},
created() {
if(!this._uid) {
this._uid = this._.uid
}
Object.defineProperty(this, 'parent', {
get: () => this[parent] || [],
})
Object.defineProperty(this, 'index', {
get: () => {
this.bindRelation();
const {parent: {el: {views=[]}={}}={}} = this
return views.indexOf(this.el)
},
});
this.el.type = this.type
if(this.uid) {
this.el.uid = this.uid
}
this.bindRelation()
},
// #ifdef VUE3
beforeUnmount() {
this.removeEl()
},
// #endif
// #ifdef VUE2
beforeDestroy() {
this.removeEl()
},
// #endif
methods: {
removeEl() {
if (this.parent) {
this.parent.el.views = this.parent.el.views.filter(
(item) => item._uid !== this._uid
);
}
},
bindRelation() {
if(!this.el._uid) {
this.el._uid = this._uid
}
if(['text','qrcode'].includes(this.type)) {
this.el.text = this.$slots && this.$slots.default && this.$slots.default[0].text || `${this.text || ''}`.replace(/\\n/g, '\n')
}
if(this.type == 'image') {
this.el.src = this.src
}
if (!this.parent) {
return;
}
let views = this.parent.el.views || [];
if(views.indexOf(this.el) !== -1) {
this.parent.el.views = views.map(v => v._uid == this._uid ? this.el : v)
} else {
this.parent.el.views = [...views, this.el];
}
}
},
mounted() {
// this.bindRelation()
},
}
}
@@ -0,0 +1,28 @@
<template>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-image',
mixins:[children('painter')],
props: {
id: String,
css: [String, Object],
src: String
},
data() {
return {
type: 'image',
el: {
css: {},
src: null
},
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,27 @@
<template>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-qrcode',
mixins:[children('painter')],
props: {
id: String,
css: [String, Object],
text: String
},
data() {
return {
type: 'qrcode',
el: {
css: {},
text: null
},
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,33 @@
<template>
<text style="opacity: 0;height: 0;"><slot/></text>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-text',
mixins:[children('painter')],
props: {
type: {
type: String,
default: 'text'
},
uid: String,
css: [String, Object],
text: [String, Number],
replace: Object,
},
data() {
return {
// type: 'text',
el: {
css: {},
text: null
},
}
}
}
</script>
<style>
</style>
@@ -0,0 +1,34 @@
<template>
<view><slot/></view>
</template>
<script>
import {parent, children} from '../common/relation';
export default {
name: 'lime-painter-view',
mixins:[children('painter'), parent('painter')],
props: {
id: String,
type: {
type: String,
default: 'view'
},
css: [String, Object],
},
data() {
return {
// type: 'view',
el: {
css: {},
views:[]
},
}
},
mounted() {
}
}
</script>
<style>
</style>
@@ -0,0 +1,461 @@
<template>
<view class="lime-painter" ref="limepainter">
<view v-if="canvasId && size" :style="styles">
<!-- #ifndef APP-NVUE -->
<canvas class="lime-painter__canvas" v-if="use2dCanvas" :id="canvasId" type="2d" :style="size"></canvas>
<canvas class="lime-painter__canvas" v-else :id="canvasId" :canvas-id="canvasId" :style="size"
:width="boardWidth * dpr" :height="boardHeight * dpr" :hidpi="hidpi"></canvas>
<!-- #endif -->
<!-- #ifdef APP-NVUE -->
<web-view :style="size" ref="webview"
src="/uni_modules/lime-painter/hybrid/html/index.html"
class="lime-painter__canvas" @pagefinish="onPageFinish" @error="onError" @onPostMessage="onMessage">
</web-view>
<!-- #endif -->
</view>
<slot />
</view>
</template>
<script>
import { parent } from '../common/relation'
import props from './props'
import {toPx, base64ToPath, pathToBase64, isBase64, sleep, getImageInfo }from './utils';
// #ifndef APP-NVUE
import { canIUseCanvas2d, isPC} from './utils';
import Painter from './painter';
// import Painter from '@painter'
const nvue = {}
// #endif
// #ifdef APP-NVUE
import nvue from './nvue'
// #endif
export default {
name: 'lime-painter',
mixins: [props, parent('painter'), nvue],
data() {
return {
use2dCanvas: false,
canvasHeight: 150,
canvasWidth: null,
parentWidth: 0,
inited: false,
progress: 0,
firstRender: 0,
done: false,
tasks: []
};
},
computed: {
styles() {
return `${this.size}${this.customStyle||''};` + (this.hidden && 'position: fixed; left: 1500rpx;')
},
canvasId() {
return `l-painter${this._ && this._.uid || this._uid}`
},
size() {
if (this.boardWidth && this.boardHeight) {
return `width:${this.boardWidth}px; height: ${this.boardHeight}px;`;
}
},
dpr() {
return this.pixelRatio || uni.getSystemInfoSync().pixelRatio;
},
boardWidth() {
const {width = 0} = (this.elements && this.elements.css) || this.elements || this
const w = toPx(width||this.width)
return w || Math.max(w, toPx(this.canvasWidth));
},
boardHeight() {
const {height = 0} = (this.elements && this.elements.css) || this.elements || this
const h = toPx(height||this.height)
return h || Math.max(h, toPx(this.canvasHeight));
},
hasBoard() {
return this.board && Object.keys(this.board).length
},
elements() {
return this.hasBoard ? this.board : JSON.parse(JSON.stringify(this.el))
}
},
created() {
this.use2dCanvas = this.type === '2d' && canIUseCanvas2d() && !isPC
},
async mounted() {
await sleep(30)
await this.getParentWeith()
this.$nextTick(() => {
setTimeout(() => {
this.$watch('elements', this.watchRender, {
deep: true,
immediate: true
});
}, 30)
})
},
// #ifdef VUE3
unmounted() {
this.done = false
this.inited = false
this.firstRender = 0
this.progress = 0
this.painter = null
clearTimeout(this.rendertimer)
},
// #endif
// #ifdef VUE2
destroyed() {
this.done = false
this.inited = false
this.firstRender = 0
this.progress = 0
this.painter = null
clearTimeout(this.rendertimer)
},
// #endif
methods: {
async watchRender(val, old) {
if (!val || !val.views || (!this.firstRender ? !val.views.length : !this.firstRender) || !Object.keys(val).length || JSON.stringify(val) == JSON.stringify(old)) return;
this.firstRender = 1
this.progress = 0
this.done = false
clearTimeout(this.rendertimer)
this.rendertimer = setTimeout(() => {
this.render(val);
}, this.beforeDelay)
},
async setFilePath(path, param) {
let filePath = path
const {pathType = this.pathType} = param || this
if (pathType == 'base64' && !isBase64(path)) {
filePath = await pathToBase64(path)
} else if (pathType == 'url' && isBase64(path)) {
filePath = await base64ToPath(path)
}
if (param && param.isEmit) {
this.$emit('success', filePath);
}
return filePath
},
async getSize(args) {
const {width} = args.css || args
const {height} = args.css || args
if (!this.size) {
if (width || height) {
this.canvasWidth = width || this.canvasWidth
this.canvasHeight = height || this.canvasHeight
await sleep(30);
} else {
await this.getParentWeith()
}
}
},
canvasToTempFilePathSync(args) {
// this.stopWatch && this.stopWatch()
// this.stopWatch = this.$watch('done', (v) => {
// if (v) {
// this.canvasToTempFilePath(args)
// this.stopWatch && this.stopWatch()
// }
// }, {
// immediate: true
// })
this.tasks.push(args)
if(this.done){
this.runTask()
}
},
runTask(){
while(this.tasks.length){
const task = this.tasks.shift()
this.canvasToTempFilePath(task)
}
},
// #ifndef APP-NVUE
getParentWeith() {
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`.lime-painter`)
.boundingClientRect()
.exec(res => {
const {width, height} = res[0]||{}
this.parentWidth = Math.ceil(width||0)
this.canvasWidth = this.parentWidth || 300
this.canvasHeight = height || this.canvasHeight||150
resolve(res[0])
})
})
},
async render(args = {}) {
if(!Object.keys(args).length) {
return console.error('空对象')
}
this.progress = 0
this.done = false
// #ifdef APP-NVUE
this.tempFilePath.length = 0
// #endif
await this.getSize(args)
const ctx = await this.getContext();
let {
use2dCanvas,
boardWidth,
boardHeight,
canvas,
afterDelay
} = this;
if (use2dCanvas && !canvas) {
return Promise.reject(new Error('canvas 没创建'));
}
this.boundary = {
top: 0,
left: 0,
width: boardWidth,
height: boardHeight
};
this.painter = null
if (!this.painter) {
const {width} = args.css || args
const {height} = args.css || args
if(!width && this.parentWidth) {
Object.assign(args, {width: this.parentWidth})
}
const param = {
context: ctx,
canvas,
width: boardWidth,
height: boardHeight,
pixelRatio: this.dpr,
useCORS: this.useCORS,
createImage: getImageInfo.bind(this),
performance: this.performance,
listen: {
onProgress: (v) => {
this.progress = v
this.$emit('progress', v)
},
onEffectFail: (err) => {
this.$emit('faill', err)
}
}
}
this.painter = new Painter(param)
}
try{
// vue3 赋值给data会引起图片无法绘制
const { width, height } = await this.painter.source(JSON.parse(JSON.stringify(args)))
this.boundary.height = this.canvasHeight = height
this.boundary.width = this.canvasWidth = width
await sleep(this.sleep);
await this.painter.render()
await new Promise(resolve => this.$nextTick(resolve));
if (!use2dCanvas) {
await this.canvasDraw();
}
if (afterDelay && use2dCanvas) {
await sleep(afterDelay);
}
this.$emit('done');
this.done = true
if (this.isCanvasToTempFilePath) {
this.canvasToTempFilePath()
.then(res => {
this.$emit('success', res.tempFilePath)
})
.catch(err => {
this.$emit('fail', new Error(JSON.stringify(err)));
});
}
this.runTask()
return Promise.resolve({
ctx,
draw: this.painter,
node: this.node
});
}catch(e){
//TODO handle the exception
}
},
canvasDraw(flag = false) {
return new Promise((resolve, reject) => this.ctx.draw(flag, () => setTimeout(() => resolve(), this
.afterDelay)));
},
async getContext() {
if (!this.canvasWidth) {
this.$emit('fail', 'painter no size')
console.error('[lime-painter]: 给画板或父级设置尺寸')
return Promise.reject();
}
if (this.ctx && this.inited) {
return Promise.resolve(this.ctx);
}
const { type, use2dCanvas, dpr, boardWidth, boardHeight } = this;
const _getContext = () => {
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`#${this.canvasId}`)
.boundingClientRect()
.exec(res => {
if (res) {
const ctx = uni.createCanvasContext(this.canvasId, this);
if (!this.inited) {
this.inited = true;
this.use2dCanvas = false;
this.canvas = res;
}
// 钉钉小程序框架不支持 measureText 方法,用此方法 mock
if (!ctx.measureText) {
function strLen(str) {
let len = 0;
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 0 && str.charCodeAt(i) < 128) {
len++;
} else {
len += 2;
}
}
return len;
}
ctx.measureText = text => {
let fontSize = ctx.state && ctx.state.fontSize || 12;
const font = ctx.__font
if (font && fontSize == 12) {
fontSize = parseInt(font.split(' ')[3], 10);
}
fontSize /= 2;
return {
width: strLen(text) * fontSize
};
}
}
// #ifdef MP-ALIPAY
ctx.scale(dpr, dpr);
// #endif
this.ctx = ctx
resolve(this.ctx);
} else {
console.error('[lime-painter] no node')
}
});
});
};
if (!use2dCanvas) {
return _getContext();
}
return new Promise(resolve => {
uni.createSelectorQuery()
.in(this)
.select(`#${this.canvasId}`)
.node()
.exec(res => {
let {node: canvas} = res && res[0]||{};
if(canvas) {
const ctx = canvas.getContext(type);
if (!this.inited) {
this.inited = true;
this.use2dCanvas = true;
this.canvas = canvas;
}
this.ctx = ctx
resolve(this.ctx);
} else {
console.error('[lime-painter]: no size')
}
});
});
},
canvasToTempFilePath(args = {}) {
return new Promise(async (resolve, reject) => {
const { use2dCanvas, canvasId, dpr, fileType, quality } = this;
const success = async (res) => {
try {
const tempFilePath = await this.setFilePath(res.tempFilePath || res, args)
const result = Object.assign(res, {tempFilePath})
args.success && args.success(result)
resolve(result)
} catch (e) {
this.$emit('fail', e)
}
}
let { top: y = 0, left: x = 0, width, height } = this.boundary || this;
// let destWidth = width * dpr;
// let destHeight = height * dpr;
// #ifdef MP-ALIPAY
// width = destWidth;
// height = destHeight;
// #endif
const copyArgs = Object.assign({
// x,
// y,
// width,
// height,
// destWidth,
// destHeight,
canvasId,
id: canvasId,
fileType,
quality,
}, args, {success});
// if(this.isPC || use2dCanvas) {
// copyArgs.canvas = this.canvas
// }
if (use2dCanvas) {
copyArgs.canvas = this.canvas
try{
// #ifndef MP-ALIPAY
const oFilePath = this.canvas.toDataURL(`image/${args.fileType||fileType}`.replace(/pg/, 'peg'), args.quality||quality)
if(/data:,/.test(oFilePath)) {
uni.canvasToTempFilePath(copyArgs, this);
} else {
const tempFilePath = await this.setFilePath(oFilePath, args)
args.success && args.success({tempFilePath})
resolve({tempFilePath})
}
// #endif
// #ifdef MP-ALIPAY
this.canvas.toTempFilePath(copyArgs)
// #endif
}catch(e){
args.fail && args.fail(e)
reject(e)
}
} else {
// #ifdef MP-ALIPAY
if(this.ctx.toTempFilePath) {
// 钉钉
const ctx = uni.createCanvasContext(canvasId);
ctx.toTempFilePath(copyArgs);
} else {
my.canvasToTempFilePath(copyArgs);
}
// #endif
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath(copyArgs, this);
// #endif
}
})
}
// #endif
}
};
</script>
<style>
.lime-painter,
.lime-painter__canvas {
// #ifndef APP-NVUE
width: 100%;
// #endif
// #ifdef APP-NVUE
flex: 1;
// #endif
}
</style>
@@ -0,0 +1,214 @@
// #ifdef APP-NVUE
import {
sleep,
getImageInfo,
isBase64,
networkReg
} from './utils';
const dom = weex.requireModule('dom')
import {
version
} from '../../package.json'
export default {
data() {
return {
tempFilePath: [],
isInitFile: false,
osName: uni.getSystemInfoSync().osName
}
},
methods: {
getParentWeith() {
return new Promise(resolve => {
dom.getComponentRect(this.$refs.limepainter, (res) => {
this.parentWidth = Math.ceil(res.size.width)
this.canvasWidth = this.canvasWidth || this.parentWidth || 300
this.canvasHeight = res.size.height || this.canvasHeight || 150
resolve(res.size)
})
})
},
onPageFinish() {
this.webview = this.$refs.webview
this.webview.evalJS(`init(${this.dpr})`)
},
onMessage(e) {
const res = e.detail.data[0] || null;
if (res.event) {
if (res.event == 'inited') {
this.inited = true
}
if (res.event == 'fail') {
this.$emit('fail', res)
}
if (res.event == 'layoutChange') {
const data = typeof res.data == 'string' ? JSON.parse(res.data) : res.data
this.canvasWidth = Math.ceil(data.width);
this.canvasHeight = Math.ceil(data.height);
}
if (res.event == 'progressChange') {
this.progress = res.data * 1
}
if (res.event == 'file') {
this.tempFilePath.push(res.data)
if (this.tempFilePath.length > 7) {
this.tempFilePath.shift()
}
return
}
if (res.event == 'success') {
if (res.data) {
this.tempFilePath.push(res.data)
if (this.tempFilePath.length > 8) {
this.tempFilePath.shift()
}
if (this.isCanvasToTempFilePath) {
this.setFilePath(this.tempFilePath.join(''), {
isEmit: true
})
}
} else {
this.$emit('fail', 'canvas no data')
}
return
}
this.$emit(res.event, JSON.parse(res.data));
} else if (res.file) {
this.file = res.data;
} else {
console.info(res[0])
}
},
getWebViewInited() {
if (this.inited) return Promise.resolve(this.inited);
return new Promise((resolve) => {
this.$watch(
'inited',
async val => {
if (val) {
resolve(val)
}
}, {
immediate: true
}
);
})
},
getTempFilePath() {
if (this.tempFilePath.length == 8) return Promise.resolve(this.tempFilePath)
return new Promise((resolve) => {
this.$watch(
'tempFilePath',
async val => {
if (val.length == 8) {
resolve(val.join(''))
}
}, {
deep: true
}
);
})
},
getWebViewDone() {
if (this.progress == 1) return Promise.resolve(this.progress);
return new Promise((resolve) => {
this.$watch(
'progress',
async val => {
if (val == 1) {
this.$emit('done')
this.done = true
this.runTask()
resolve(val)
}
}, {
immediate: true
}
);
})
},
async render(args) {
try {
await this.getSize(args)
const {
width
} = args.css || args
if (!width && this.parentWidth) {
Object.assign(args, {
width: this.parentWidth
})
}
const newNode = await this.calcImage(args);
await this.getWebViewInited()
this.webview.evalJS(`source(${JSON.stringify(newNode)})`)
await this.getWebViewDone()
await sleep(this.afterDelay)
if (this.isCanvasToTempFilePath) {
const params = {
fileType: this.fileType,
quality: this.quality
}
this.webview.evalJS(`save(${JSON.stringify(params)})`)
}
return Promise.resolve()
} catch (e) {
this.$emit('fail', e)
}
},
async calcImage(args) {
let node = JSON.parse(JSON.stringify(args))
const urlReg = /url\((.+)\)/
const {
backgroundImage
} = node.css || {}
const isBG = backgroundImage && urlReg.exec(backgroundImage)[1]
const url = node.url || node.src || isBG
if (['text', 'qrcode'].includes(node.type)) {
return node
}
if ((node.type === "image" || isBG) && url && !isBase64(url) && (this.osName == 'ios' || !networkReg
.test(url))) {
let {
path
} = await getImageInfo(url, true)
if (isBG) {
node.css.backgroundImage = `url(${path})`
} else {
node.src = path
}
} else if (node.views && node.views.length) {
for (let i = 0; i < node.views.length; i++) {
node.views[i] = await this.calcImage(node.views[i])
}
}
return node
},
async canvasToTempFilePath(args = {}) {
if (!this.inited) {
return this.$emit('fail', 'no init')
}
this.tempFilePath = []
if (args.fileType == 'jpg') {
args.fileType = 'jpeg'
}
this.webview.evalJS(`save(${JSON.stringify(args)})`)
try {
let tempFilePath = await this.getTempFilePath()
tempFilePath = await this.setFilePath(tempFilePath, args)
args.success({
errMsg: "canvasToTempFilePath:ok",
tempFilePath
})
} catch (e) {
console.log('e', e)
args.fail({
error: e
})
}
}
}
}
// #endif
@@ -0,0 +1 @@
var t=function(){return t=Object.assign||function(t){for(var e,i=1,n=arguments.length;i<n;i++)for(var r in e=arguments[i])Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r]);return t},t.apply(this,arguments)};function e(t,e,i,n){return new(i||(i=Promise))((function(r,o){function s(t){try{h(n.next(t))}catch(t){o(t)}}function a(t){try{h(n.throw(t))}catch(t){o(t)}}function h(t){var e;t.done?r(t.value):(e=t.value,e instanceof i?e:new i((function(t){t(e)}))).then(s,a)}h((n=n.apply(t,e||[])).next())}))}function i(t,e){var i,n,r,o,s={label:0,sent:function(){if(1&r[0])throw r[1];return r[1]},trys:[],ops:[]};return o={next:a(0),throw:a(1),return:a(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function a(o){return function(a){return function(o){if(i)throw new TypeError("Generator is already executing.");for(;s;)try{if(i=1,n&&(r=2&o[0]?n.return:o[0]?n.throw||((r=n.return)&&r.call(n),0):n.next)&&!(r=r.call(n,o[1])).done)return r;switch(n=0,r&&(o=[2&o[0],r.value]),o[0]){case 0:case 1:r=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,n=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!(r=s.trys,(r=r.length>0&&r[r.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!r||o[1]>r[0]&&o[1]<r[3])){s.label=o[1];break}if(6===o[0]&&s.label<r[1]){s.label=r[1],r=o;break}if(r&&s.label<r[2]){s.label=r[2],s.ops.push(o);break}r[2]&&s.ops.pop(),s.trys.pop();continue}o=e.call(t,s)}catch(t){o=[6,t],n=0}finally{i=r=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,a])}}}var n={MP_WEIXIN:"mp-weixin",MP_QQ:"mp-qq",MP_ALIPAY:"mp-alipay",MP_BAIDU:"mp-baidu",MP_TOUTIAO:"mp-toutiao",MP_DINGDING:"mp-dingding",H5:"h5",WEB:"web",PLUS:"plus"},r=["Top","Right","Bottom","Left"],o="right",s="bottom",a=["contentSize","clientSize","borderSize","offsetSize"],h="row",c="column",f={TOP:"top",MIDDLE:"middle",BOTTOM:s},l={LEFT:"left",CENTER:"center",RIGHT:o},d="view",u="text",p="image",g="qrcode",v="block",y="inline-block",x="none",b="flex",w="absolute",m="fixed",S={display:v,color:"#000000",lineHeight:"1.4em",fontSize:14,fontWeight:400,fontFamily:"sans-serif",lineCap:"butt",flexDirection:h,flexWrap:"nowrap",textAlign:"left",alignItems:"flex-start",justifyContent:"flex-start",position:"static",transformOrigin:"".concat("center"," ").concat("center")},z={upx2px:function(t){return window.innerWidth/750*t},getSystemInfoSync:function(){return{screenWidth:window.innerWidth,screenHeight:window.innerHeight}},getImageInfo:function(t){var e=t.src,i=t.success,n=t.fail,r=new Image;r.onload=function(){i({width:r.naturalWidth,height:r.naturalHeight,path:r.src,src:e})},r.onerror=n,r.src=e}},I="object"==typeof window?"undefined"==typeof uni||"undefined"!=typeof uni&&!uni.addInterceptor?n.WEB:n.H5:"object"==typeof swan?n.MP_BAIDU:"object"==typeof tt?n.MP_TOUTIAO:"object"==typeof plus?n.PLUS:"object"==typeof wx?n.MP_WEIXIN:void 0,M=I==n.MP_WEIXIN?wx:"undefined"!=typeof uni?uni.getImageInfo?{upx2px:function(t){return uni.upx2px(t)},getSystemInfoSync:function(){return uni.getSystemInfoSync()},getImageInfo:function(t){return uni.getImageInfo(t)},downloadFile:function(t){return uni.downloadFile(t)}}:Object.assign(uni,z):"undefined"!=typeof window?z:uni;if(!M.upx2px){var k=((M.getSystemInfoSync&&M.getSystemInfoSync()).screenWidth||375)/750;M.upx2px=function(t){return k*t}}function B(t){return/^-?\d+(\.\d+)?$/.test(t)}function W(t,e,i){if(B(t))return 1*t;if("string"==typeof t){var n=/^-?([0-9]+)?([.]{1}[0-9]+){0,1}(em|rpx|vw|vh|px|%)$/g.exec(t);if(!t||!n)return 0;var r=n[3];t=parseFloat(t);var o=0;if("rpx"===r)o=M.upx2px(t);else if("px"===r)o=1*t;else if("%"===r&&e)o=t*W(e)/100;else if("em"===r&&e)o=t*W(e||14);else if(["vw","vh"].includes(r)){var s=M.getSystemInfoSync(),a=s.screenWidth,h=s.screenHeight;o=t*("vw"==r?a:h)/100}return 1*o.toFixed(2)}return 0}function P(t){return/%$/.test(t)}var O=function(t){return!(!t||!t.startsWith("linear")&&!t.startsWith("radial"))},T=function(t,e,i,n,r,o){t.startsWith("linear")?function(t,e,i,n,r,o){for(var s=function(t,e,i,n,r){void 0===n&&(n=0);void 0===r&&(r=0);var o=t.match(/([-]?\d{1,3})deg/),s=o&&o[1]?parseFloat(o[1]):0;s>=360&&(s-=360);s<0&&(s+=360);if(0===(s=Math.round(s)))return{x0:Math.round(e/2)+n,y0:i+r,x1:Math.round(e/2)+n,y1:r};if(180===s)return{x0:Math.round(e/2)+n,y0:r,x1:Math.round(e/2)+n,y1:i+r};if(90===s)return{x0:n,y0:Math.round(i/2)+r,x1:e+n,y1:Math.round(i/2)+r};if(270===s)return{x0:e+n,y0:Math.round(i/2)+r,x1:n,y1:Math.round(i/2)+r};var a=Math.round(180*Math.asin(e/Math.sqrt(Math.pow(e,2)+Math.pow(i,2)))/Math.PI);if(s===a)return{x0:n,y0:i+r,x1:e+n,y1:r};if(s===180-a)return{x0:n,y0:r,x1:e+n,y1:i+r};if(s===180+a)return{x0:e+n,y0:r,x1:n,y1:i+r};if(s===360-a)return{x0:e+n,y0:i+r,x1:n,y1:r};var h=0,c=0,f=0,l=0;if(s<a||s>180-a&&s<180||s>180&&s<180+a||s>360-a){var d=s*Math.PI/180,u=s<a||s>360-a?i/2:-i/2,p=Math.tan(d)*u,g=s<a||s>180-a&&s<180?e/2-p:-e/2-p;h=-(f=p+(v=Math.pow(Math.sin(d),2)*g)),c=-(l=u+v/Math.tan(d))}if(s>a&&s<90|Line truncated
@@ -0,0 +1,56 @@
export default {
props: {
board: Object,
pathType: String, // 'base64'、'url'
fileType: {
type: String,
default: 'png'
},
hidden: Boolean,
quality: {
type: Number,
default: 1
},
css: [String, Object],
// styles: [String, Object],
width: [Number, String],
height: [Number, String],
pixelRatio: Number,
customStyle: String,
isCanvasToTempFilePath: Boolean,
// useCanvasToTempFilePath: Boolean,
sleep: {
type: Number,
default: 1000 / 30
},
beforeDelay: {
type: Number,
default: 100
},
afterDelay: {
type: Number,
default: 100
},
performance: Boolean,
// #ifdef MP-WEIXIN || MP-TOUTIAO || MP-ALIPAY
type: {
type: String,
default: '2d'
},
// #endif
// #ifdef APP-NVUE
hybrid: Boolean,
timeout: {
type: Number,
default: 2000
},
// #endif
// #ifdef H5 || APP-PLUS
useCORS: Boolean,
hidpi: {
type: Boolean,
default: true
}
// #endif
}
}
@@ -0,0 +1 @@
var t=function(){return t=Object.assign||function(t){for(var e,i=1,n=arguments.length;i<n;i++)for(var r in e=arguments[i])Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r]);return t},t.apply(this,arguments)};function e(t,e,i,n){return new(i||(i=Promise))((function(r,o){function s(t){try{a(n.next(t))}catch(t){o(t)}}function h(t){try{a(n.throw(t))}catch(t){o(t)}}function a(t){var e;t.done?r(t.value):(e=t.value,e instanceof i?e:new i((function(t){t(e)}))).then(s,h)}a((n=n.apply(t,e||[])).next())}))}function i(t,e){var i,n,r,o,s={label:0,sent:function(){if(1&r[0])throw r[1];return r[1]},trys:[],ops:[]};return o={next:h(0),throw:h(1),return:h(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function h(o){return function(h){return function(o){if(i)throw new TypeError("Generator is already executing.");for(;s;)try{if(i=1,n&&(r=2&o[0]?n.return:o[0]?n.throw||((r=n.return)&&r.call(n),0):n.next)&&!(r=r.call(n,o[1])).done)return r;switch(n=0,r&&(o=[2&o[0],r.value]),o[0]){case 0:case 1:r=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,n=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!(r=s.trys,(r=r.length>0&&r[r.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!r||o[1]>r[0]&&o[1]<r[3])){s.label=o[1];break}if(6===o[0]&&s.label<r[1]){s.label=r[1],r=o;break}if(r&&s.label<r[2]){s.label=r[2],s.ops.push(o);break}r[2]&&s.ops.pop(),s.trys.pop();continue}o=e.call(t,s)}catch(t){o=[6,t],n=0}finally{i=r=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,h])}}}var n={MP_WEIXIN:"mp-weixin",MP_QQ:"mp-qq",MP_ALIPAY:"mp-alipay",MP_BAIDU:"mp-baidu",MP_TOUTIAO:"mp-toutiao",MP_DINGDING:"mp-dingding",H5:"h5",WEB:"web",PLUS:"plus"},r=["Top","Right","Bottom","Left"],o="right",s="bottom",h=["contentSize","clientSize","borderSize","offsetSize"],a="row",l="column",d={TOP:"top",MIDDLE:"middle",BOTTOM:s},c={LEFT:"left",CENTER:"center",RIGHT:o},f="view",u="text",p="image",g="qrcode",v="block",y="inline-block",x="none",b="flex",w="absolute",m="fixed",S={display:v,color:"#000000",lineHeight:"1.4em",fontSize:14,fontWeight:400,fontFamily:"sans-serif",lineCap:"butt",flexDirection:a,flexWrap:"nowrap",textAlign:"left",alignItems:"flex-start",justifyContent:"flex-start",position:"static",transformOrigin:"".concat("center"," ").concat("center")},z={upx2px:function(t){return window.innerWidth/750*t},getSystemInfoSync:function(){return{screenWidth:window.innerWidth,screenHeight:window.innerHeight}},getImageInfo:function(t){var e=t.src,i=t.success,n=t.fail,r=new Image;r.onload=function(){i({width:r.naturalWidth,height:r.naturalHeight,path:r.src,src:e})},r.onerror=n,r.src=e}},I="object"==typeof window?"undefined"==typeof uni||"undefined"!=typeof uni&&!uni.addInterceptor?n.WEB:n.H5:"object"==typeof swan?n.MP_BAIDU:"object"==typeof tt?n.MP_TOUTIAO:"object"==typeof plus?n.PLUS:"object"==typeof wx?n.MP_WEIXIN:void 0,M=I==n.MP_WEIXIN?wx:"undefined"!=typeof uni?uni.getImageInfo?{upx2px:function(t){return uni.upx2px(t)},getSystemInfoSync:function(){return uni.getSystemInfoSync()},getImageInfo:function(t){return uni.getImageInfo(t)},downloadFile:function(t){return uni.downloadFile(t)}}:Object.assign(uni,z):"undefined"!=typeof window?z:uni;if(!M.upx2px){var B=((M.getSystemInfoSync&&M.getSystemInfoSync()).screenWidth||375)/750;M.upx2px=function(t){return B*t}}function W(t){return/^-?\d+(\.\d+)?$/.test(t)}function k(t,e,i){if(W(t))return 1*t;if("string"==typeof t){var n=/^-?([0-9]+)?([.]{1}[0-9]+){0,1}(em|rpx|vw|vh|px|%)$/g.exec(t);if(!t||!n)return 0;var r=n[3];t=parseFloat(t);var o=0;if("rpx"===r)o=M.upx2px(t);else if("px"===r)o=1*t;else if("%"===r&&e)o=t*k(e)/100;else if("em"===r&&e)o=t*k(e||14);else if(["vw","vh"].includes(r)){var s=M.getSystemInfoSync(),h=s.screenWidth,a=s.screenHeight;o=t*("vw"==r?h:a)/100}return 1*o.toFixed(2)}return 0}function P(t){return/%$/.test(t)}var O=function(t){return!(!t||!t.startsWith("linear")&&!t.startsWith("radial"))},T=function(t,e,i,n,r,o){t.startsWith("linear")?function(t,e,i,n,r,o){for(var s=function(t,e,i,n,r){void 0===n&&(n=0);void 0===r&&(r=0);var o=t.match(/([-]?\d{1,3})deg/),s=o&&o[1]?parseFloat(o[1]):0;s>=360&&(s-=360);s<0&&(s+=360);if(0===(s=Math.round(s)))return{x0:Math.round(e/2)+n,y0:i+r,x1:Math.round(e/2)+n,y1:r};if(180===s)return{x0:Math.round(e/2)+n,y0:r,x1:Math.round(e/2)+n,y1:i+r};if(90===s)return{x0:n,y0:Math.round(i/2)+r,x1:e+n,y1:Math.round(i/2)+r};if(270===s)return{x0:e+n,y0:Math.round(i/2)+r,x1:n,y1:Math.round(i/2)+r};var h=Math.round(180*Math.asin(e/Math.sqrt(Math.pow(e,2)+Math.pow(i,2)))/Math.PI);if(s===h)return{x0:n,y0:i+r,x1:e+n,y1:r};if(s===180-h)return{x0:n,y0:r,x1:e+n,y1:i+r};if(s===180+h)return{x0:e+n,y0:r,x1:n,y1:i+r};if(s===360-h)return{x0:e+n,y0:i+r,x1:n,y1:r};var a=0,l=0,d=0,c=0;if(s<h||s>180-h&&s<180||s>180&&s<180+h||s>360-h){var f=s*Math.PI/180,u=s<h||s>360-h?i/2:-i/2,p=Math.tan(f)*u,g=s<h||s>180-h&&s<180?e/2-p:-e/2-p;a=-(d=p+(v=Math.pow(Math.sin(f),2)*g)),l=-(c=u+v/Math.tan(f))}if(s>h&&s<90|Line truncated
@@ -0,0 +1,368 @@
export const networkReg = /^(http|\/\/)/;
export const isBase64 = (path) => /^data:image\/(\w+);base64/.test(path);
export function sleep(delay) {
return new Promise(resolve => setTimeout(resolve, delay))
}
let {platform, SDKVersion} = uni.getSystemInfoSync()
export const isPC = /windows|mac/.test(platform)
// 缓存图片
let cache = {}
export function isNumber(value) {
return /^-?\d+(\.\d+)?$/.test(value);
}
export function toPx(value, baseSize, isDecimal = false) {
// 如果是数字
if (typeof value === 'number') {
return value
}
// 如果是字符串数字
if (isNumber(value)) {
return value * 1
}
// 如果有单位
if (typeof value === 'string') {
const reg = /^-?([0-9]+)?([.]{1}[0-9]+){0,1}(em|rpx|px|%)$/g
const results = reg.exec(value);
if (!value || !results) {
return 0;
}
const unit = results[3];
value = parseFloat(value);
let res = 0;
if (unit === 'rpx') {
res = uni.upx2px(value);
} else if (unit === 'px') {
res = value * 1;
} else if (unit === '%') {
res = value * toPx(baseSize) / 100;
} else if (unit === 'em') {
res = value * toPx(baseSize || 14);
}
return isDecimal ? res.toFixed(2) * 1 : Math.round(res);
}
return 0
}
// 计算版本
export function compareVersion(v1, v2) {
v1 = v1.split('.')
v2 = v2.split('.')
const len = Math.max(v1.length, v2.length)
while (v1.length < len) {
v1.push('0')
}
while (v2.length < len) {
v2.push('0')
}
for (let i = 0; i < len; i++) {
const num1 = parseInt(v1[i], 10)
const num2 = parseInt(v2[i], 10)
if (num1 > num2) {
return 1
} else if (num1 < num2) {
return -1
}
}
return 0
}
function gte(version) {
// #ifdef MP-ALIPAY
SDKVersion = my.SDKVersion
// #endif
return compareVersion(SDKVersion, version) >= 0;
}
export function canIUseCanvas2d() {
// #ifdef MP-WEIXIN
return gte('2.9.2');
// #endif
// #ifdef MP-ALIPAY
return gte('2.7.15');
// #endif
// #ifdef MP-TOUTIAO
return gte('1.78.0');
// #endif
return false
}
// #ifdef MP
export const prefix = () => {
// #ifdef MP-TOUTIAO
return tt
// #endif
// #ifdef MP-WEIXIN
return wx
// #endif
// #ifdef MP-BAIDU
return swan
// #endif
// #ifdef MP-ALIPAY
return my
// #endif
// #ifdef MP-QQ
return qq
// #endif
// #ifdef MP-360
return qh
// #endif
}
// #endif
/**
* base64转路径
* @param {Object} base64
*/
export function base64ToPath(base64) {
const [, format] = /^data:image\/(\w+);base64,/.exec(base64) || [];
return new Promise((resolve, reject) => {
// #ifdef MP
const fs = uni.getFileSystemManager()
//自定义文件名
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
let pre = prefix()
// #ifdef MP-TOUTIAO
const filePath = `${pre.getEnvInfoSync().common.USER_DATA_PATH}/${time}.${format}`
// #endif
// #ifndef MP-TOUTIAO
const filePath = `${pre.env.USER_DATA_PATH}/${time}.${format}`
// #endif
fs.writeFile({
filePath,
data: base64.split(',')[1],
encoding: 'base64',
success() {
resolve(filePath)
},
fail(err) {
console.error(err)
reject(err)
}
})
// #endif
// #ifdef H5
// mime类型
let mimeString = base64.split(',')[0].split(':')[1].split(';')[0];
//base64 解码
let byteString = atob(base64.split(',')[1]);
//创建缓冲数组
let arrayBuffer = new ArrayBuffer(byteString.length);
//创建视图
let intArray = new Uint8Array(arrayBuffer);
for (let i = 0; i < byteString.length; i++) {
intArray[i] = byteString.charCodeAt(i);
}
resolve(URL.createObjectURL(new Blob([intArray], {
type: mimeString
})))
// #endif
// #ifdef APP-PLUS
const bitmap = new plus.nativeObj.Bitmap('bitmap' + Date.now())
bitmap.loadBase64Data(base64, () => {
if (!format) {
reject(new Error('ERROR_BASE64SRC_PARSE'))
}
const time = new Date().getTime();
const filePath = `_doc/uniapp_temp/${time}.${format}`
bitmap.save(filePath, {},
() => {
bitmap.clear()
resolve(filePath)
},
(error) => {
bitmap.clear()
reject(error)
})
}, (error) => {
bitmap.clear()
reject(error)
})
// #endif
})
}
/**
* 路径转base64
* @param {Object} string
*/
export function pathToBase64(path) {
if (/^data:/.test(path)) return path
return new Promise((resolve, reject) => {
// #ifdef H5
let image = new Image();
image.setAttribute("crossOrigin", 'Anonymous');
image.onload = function() {
let canvas = document.createElement('canvas');
canvas.width = this.naturalWidth;
canvas.height = this.naturalHeight;
canvas.getContext('2d').drawImage(image, 0, 0);
let result = canvas.toDataURL('image/png')
resolve(result);
canvas.height = canvas.width = 0
}
image.src = path + '?v=' + Math.random()
image.onerror = (error) => {
reject(error);
};
// #endif
// #ifdef MP
if (uni.canIUse('getFileSystemManager')) {
uni.getFileSystemManager().readFile({
filePath: path,
encoding: 'base64',
success: (res) => {
resolve('data:image/png;base64,' + res.data)
},
fail: (error) => {
console.error({error, path})
reject(error)
}
})
}
// #endif
// #ifdef APP-PLUS
plus.io.resolveLocalFileSystemURL(getLocalFilePath(path), (entry) => {
entry.file((file) => {
const fileReader = new plus.io.FileReader()
fileReader.onload = (data) => {
resolve(data.target.result)
}
fileReader.onerror = (error) => {
reject(error)
}
fileReader.readAsDataURL(file)
}, reject)
}, reject)
// #endif
})
}
export function getImageInfo(path, useCORS) {
const isCanvas2D = this && this.canvas && this.canvas.createImage
return new Promise(async (resolve, reject) => {
// let time = +new Date()
let src = path.replace(/^@\//,'/')
if (cache[path] && cache[path].errMsg) {
resolve(cache[path])
} else {
try {
// #ifdef MP || APP-PLUS
if (isBase64(path) && (isCanvas2D ? isPC : true)) {
src = await base64ToPath(path)
}
// #endif
// #ifdef H5
if(useCORS) {
src = await pathToBase64(path)
}
// #endif
} catch (error) {
reject({
...error,
src
})
}
// #ifndef APP-NVUE
if(isCanvas2D && !isPC) {
const img = this.canvas.createImage()
img.onload = function() {
const image = {
path: img,
width: img.width,
height: img.height
}
cache[path] = image
resolve(cache[path])
}
img.onerror = function(err) {
reject({err,path})
}
img.src = src
return
}
// #endif
uni.getImageInfo({
src,
success: (image) => {
const localReg = /^\.|^\/(?=[^\/])/;
// #ifdef MP-WEIXIN || MP-BAIDU || MP-QQ || MP-TOUTIAO
image.path = localReg.test(src) ? `/${image.path}` : image.path;
// #endif
if(isCanvas2D) {
const img = this.canvas.createImage()
img.onload = function() {
image.path = img
cache[path] = image
resolve(cache[path])
}
img.onerror = function(err) {
reject({err,path})
}
img.src = src
return
}
// #ifdef APP-PLUS
// console.log('getImageInfo', +new Date() - time)
// ios 比较严格 可能需要设置跨域
if(uni.getSystemInfoSync().osName == 'ios' && useCORS) {
pathToBase64(image.path).then(base64 => {
image.path = base64
cache[path] = image
resolve(cache[path])
}).catch(err => {
console.error({err, path})
reject({err,path})
})
return
}
// #endif
cache[path] = image
resolve(cache[path])
},
fail(err) {
console.error({err, path})
reject({err,path})
}
})
}
})
}
// #ifdef APP-PLUS
const 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) {
const localFilePath = plus.io.convertAbsoluteFileSystem(path)
if (localFilePath !== path) {
return localFilePath
} else {
path = path.substr(1)
}
}
return '_www/' + path
}
// #endif
@@ -0,0 +1,248 @@
<template>
<view class="page">
<!-- <demo-block title="json用法">
<image :src="picture" v-if="picture" mode="widthFix"></image>
<l-painter :board="poster" @success="picture = $event" isCanvasToTempFilePath ref="json" style="position: fixed;left: 1500rpx;width: 750rpx;" path-type="url"></l-painter>
</demo-block> -->
<!-- <demo-block title="基础用法">
<l-painter ref="painter">
<l-painter-view css="background: #07c160; height: 120rpx; width: 120rpx; display: inline-block">
</l-painter-view>
<l-painter-view
css="background: #1989fa; height: 120rpx; width: 120rpx; border-top-right-radius: 60rpx; border-bottom-left-radius: 60rpx; display: inline-block; margin: 0 30rpx;">
</l-painter-view>
<l-painter-view
css="background: #ff9d00; height: 120rpx; width: 120rpx; border-radius: 50%; display: inline-block">
</l-painter-view>
</l-painter>
</demo-block> -->
<!-- <demo-block title="View 容器">
<l-painter>
<l-painter-view css="background: #f0f0f0; padding-top: 100rpx;">
<l-painter-view css="background: #d9d9d9; width: 33.33%; height: 100rpx; display: inline-block">
</l-painter-view>
<l-painter-view css="background: #bfbfbf; width: 66.66%; height: 100rpx; display: inline-block">
</l-painter-view>
</l-painter-view>
</l-painter>
</demo-block> -->
<!-- <demo-block title="Text 文本">
<l-painter>
<l-painter-view css="background: #f5f5f5; padding: 30rpx; color: #222a29">
<l-painter-text text="登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼" />
<l-painter-text text="登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼"
css="text-align:center; padding-top: 20rpx; text-decoration: line-through " />
<l-painter-text text="登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼" css="text-align:right; padding-top: 20rpx" />
<l-painter-text text="水调歌头\n明月几时有?把酒问青天。不知天上宫阙,今夕是何年。我欲乘风归去,又恐琼楼玉宇,高处不胜寒。起舞弄清影,何似在人间。"
css="line-clamp: 3; padding-top: 20rpx; background: linear-gradient(,#ff971b 0%, #1989fa 100%); background-clip: text" />
</l-painter-view>
</l-painter>
</demo-block> -->
<!-- <demo-block title="Image 图片">
<l-painter>
<l-painter-view>
<l-painter-text text="基础用法"
css="margin-top: 30rpx; display: block; padding-bottom:20rpx; color: #999" />
<l-painter-view>
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx" />
</l-painter-view>
</l-painter-view>
<l-painter-view>
<l-painter-text text="填充方式"
css=" margin-top: 30rpx; display: block; padding-bottom:20rpx; color: #999" />
<l-painter-view css="display: inline-block; padding-right: 12rpx">
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; background: #f5f5f5" />
<l-painter-text text="contain" css="margin-top: 10rpx; display: block; text-align:center;" />
</l-painter-view>
<l-painter-view css="display: inline-block; padding: 0 12rpx">
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: cover; background: #f5f5f5" />
<l-painter-text text="cover" css="margin-top: 10rpx; display: block; text-align:center;" />
</l-painter-view>
<l-painter-view css="display: inline-block; padding: 0 12rpx">
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: fill; background: #f5f5f5" />
<l-painter-text text="fill" css="margin-top: 10rpx; display: block; text-align:center;" />
</l-painter-view>
<l-painter-view css="display: inline-block; margin-top: 30rpx">
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: none; background: #f5f5f5" />
<l-painter-text text="none" css="margin-top: 10rpx; display: block; text-align:center;" />
</l-painter-view>
</l-painter-view>
</l-painter>
</demo-block> -->
<!-- <demo-block title="QRcode 二维码">
<l-painter>
<l-painter-qrcode text="limeui.qcoon.cn" css="width: 200rpx; height: 200rpx" />
</l-painter>
</demo-block> -->
<demo-block title="栗子海报">
<image v-if="picture2" :src="picture2" mode="widthFix"></image>
<l-painter
css="width: 750rpx; padding-bottom: 100rpx; background: linear-gradient(180deg,#ff971b 0%, #ff5000 100%)"
@fail="fail"
@done="done"
pathType="url"
ref="poster"
performance
>
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="background: #ffffff; object-fit: cover; margin-left: 40rpx; margin-top: 40rpx; width: 84rpx; border: 2rpx solid #ffffff; box-sizing: border-box; height: 84rpx; border-radius: 50%;" />
<l-painter-view css="margin-top: 40rpx; padding-left: 20rpx; display: inline-block">
<l-painter-text text="隔壁老王"
css="display: block; padding-bottom: 10rpx; color: #ffffff; font-size: 32rpx; fontWeight: bold" />
<l-painter-text text="为您挑选了一个好物?" css="color: rgba(255,255,255,.7); font-size: 24rpx" />
</l-painter-view>
<l-painter-view
css="margin-left: 40rpx; margin-top: 30rpx; padding: 32rpx; box-sizing: border-box; background: #ffffff; border-radius: 16rpx; width: 670rpx; box-shadow: 0 20rpx 58rpx rgba(0,0,0,.15)">
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="object-fit: cover; object-position: 50% 50%; width: 606rpx; height: 606rpx;" />
<l-painter-view
css="margin-top: 32rpx; color: #FF0000; font-weight: bold; font-size: 28rpx; line-height: 1em;">
<l-painter-text text="¥" css="vertical-align: bottom" />
<l-painter-text text="39" css="vertical-align: bottom; font-size: 58rpx" />
<l-painter-text text=".39" css="vertical-align: bottom" />
<l-painter-text text="¥59.99"
css="vertical-align: bottom; padding-left: 10rpx; font-weight: normal; text-decoration: line-through; color: #999999" />
</l-painter-view>
<l-painter-view css="margin-top: 32rpx; font-size: 26rpx; color: #8c5400">
<l-painter-text text="自营" css="color: #212121; background: #ffb400;" />
<l-painter-text text="30天最低价"
css="margin-left: 16rpx; background: #fff4d9; text-decoration: line-through;" />
<l-painter-text text="满减优惠" css="margin-left: 16rpx; background: #fff4d9" />
<l-painter-text text="超高好评" css="margin-left: 16rpx; background: #fff4d9" />
</l-painter-view>
<l-painter-view css="margin-top: 30rpx; display: flex">
<l-painter-text uid="tag"
css="position:absolute; top: 6rpx; padding: 5rpx 10rpx; color: #212121; background: #ffb400; font-size: 24rpx"
text="京东自营"></l-painter-text>
<l-painter-text
css="text-indent: calc(tag.width + 10rpx); flex: 1; padding-right: 20rpx; box-sizing: border-box; line-clamp: 2; color: #333333; line-height: 1.8em; font-size: 36rpx;"
text="360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝"></l-painter-text>
<l-painter-qrcode css="width: 128rpx; height: 128rpx;" text="limeui.qcoon.cn"></l-painter-qrcode>
</l-painter-view>
</l-painter-view>
</l-painter>
<button type="default" @click="save">save</button>
</demo-block>
</view>
</template>
<script>
export default {
data: () => ({
picture: '',
picture2: '',
show: false,
'poster': {
'css': {
'width': '750rpx',
'height': '1333rpx',
},
'views': [{
'type': 'image',
'src': 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
'css': {
'width': '750rpx',
'height': '1333rpx',
'background': 'white',
'position': 'fixed',
'top': '0',
'left': '0',
'zIndex': -1,
},
}, {
'css': {
'width': '750rpx',
'position': 'fixed',
'top': '610rpx',
'left': '303rpx',
},
'views': [{
'type': 'view',
'css': {
'width': '150rpx',
'height': '150rpx',
'background': '#FFF',
},
'views': [{
'type': 'image',
'src': 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHgAAAB4CAMAAAAOusbgAAAC91BMVEX/GAD/////AAD/CAD3rq3/FAD//Pz/DwD2pKP7zcr/wrz/5+T/YlH/MBv52db/jID/Oyb/GAD/8/L/STb/Lhj/JA3/gXP/fnD/cGL/////AAD/GAD/EwDjHRv/EAD/wrz/DAD/FgD/CQD/AwH/29ffAAD/Y1L+/Pz/HAP/BQD/5+T/GwH/STb/MBv/FQD/+/r/p57/Lhj/DgD/cGL/fnD/zsn/PCj/YE//Szn/jID//f3/JA3/NyL/8/Pra2v/ta7/1tL/rKTpTEvpX1//3tn/vbzqWVf/a1v/mY//yML/gXP/VUP/5ePkGBb/KBL/Njb/Mx7/joL/9/b/3tz/ubL/dmf/Gxv/4d3/vLX/Z1fhAQD/n57/U1H/IQvhCQf/y8j/wbz/emz/bl7/LBX/HwjaAAD/2Nf/k4f/kIT/Pyv/JhD/rqX/qJ//oZj/lYr/gYD/RjXkIR7/+fn/4+D/09H/v7j/UT//JiX/6ujW09T/l43/iH7/WUf/TjvkHhz/8O7/6ebn5eb/npT/nJHxh4b/fG7iExHiDw3/8vH/sqr/sKf/hnn/XUvoPj3/RTL/LhrlAAD/2tf/0s7/hHf/c3HkJSPW/f7/7er/pp3zmJjnNDP/Qy//KBz/GBj/HRHe3N33xcb/qKPufn7/dGXtXVz/OTjq6Or/pJrvdnbtZWX/ZGLqVFT/WlL/UE//Dg3kBwXoAADk/f3y8PLwpqj/pp7/n5f/cHDlLi3lEA7t6+3Y19r61NX2vL3zt7eurK3/kI/yjo1sb3HtcHD/IRn/KRjpDAv6///z///49vf54uPY4uPl3+HGxMW1trj/rq3flpaEh4j/a2H/XFvkR0jpRkX/RkP28/XQ8/Xb7/HdsbLLm5yAfX/fbGzoWVjjUVD/MyjG/f3G2ty71dfbysvntbbNp6mjl5njeXjcWVjrNzbcNTTnEhDo7u/QsbLfqqvap6fOoaJ1oaLhi4t3eHm7c3O0Tk6lMC8CLC3gHBrRGxr/FRUAExX7ExI4D1MFAAAAGXRSTlPy8vLy/vLx8v358vLy8vny8uXy8vLy8vLyQ/UAIwAAE0tJREFUaN6tm3dcFEcUx4H03svOrcfuncfdckU4WjhAkIgEEBQkIigo1YDGiD2IaBRFVBRR7BpbjCVRE2NJ77333nvvPX/kTVlm95b18PPx90ncMrPve7M75b2ZIeyiMFOdEnZOeHj4hRHkIuKCcKpTI06LuBCOF192Cr5/WsTZ4XqdDRkuPT08/PTzIkxNA9ZiLpsrWhCEREQuULJAlY58KBWOaSkKvi+iSEGvSCTaYwcIgtNrt5grDJN9Yi/iYJIBFRrBOMGKxpmDTU0DF8BuBRll14DdNrgx2wimT44yB5uYZmCfMinSqImiVQXLytLIyKyFwzzDCvVgUZofOS5rrjnY1DQBi2iwYNQUq6SCfeQzxiOElunBfmWWADIHm5qmYLBrVH8OFtEgAragOA24KEWx2hIEo8bBA9UUbGK6b+AaKOnhIHCiAGowARfDA/4pfQZPGhOlasxSLTh99F2jR+jB1nLIleQSOXjBmKgxE9n5PHhgSYCAjaZXGcAexBXLwUx6sFtEINnNwTPgulTQC8BG09UGcBSyqEJXhQCrGTk4GxLjjWCjac+JgwuZ7UgECi7xHLiuPMng8UfiiZZLFqk+Pn71whuSiZbW+Dk4/YbkG+JXk3xHrjlJYDeiUty0B1suMDXY9bX6OkRVcBLAfJDgiXnMblpwO05GzPbJBLMO3e62oCQVZIcbORrw9dCpS7jinlywf3lcXJzHb5ESryF6cHzV3pf3lkCGCQ/SO82SxT88Lm5v4ckFo/lwPgiqsh8xCUzVvBKgqpNSq42OwDVItlhkIpt7HgN4kEwFmaJOHDwHcQ0PArvx51yIwazEYElxqQAvXEtubNZQYqNprwG89JarVN2yQw+WxVS4uY6CxQbIsVGWJfcSGIsXw804eLJaJGBlTiRRVg7tq42m40KPTtrxuFgAUTBqhuNQCYqINLU6jlV9K6KSTUenEwO3a8B5ONHvBzdPlOxqO65in1EWsfo8HmcIRi3Wggexm4ehMHPwyAdN2q7tq0vhWnJDie12O9Ru7oGYmGY+V0x/o0aJfj14Vl3/usKR3pHL6lbWjdq0yRtrkSl4KtzIHun1pvgsvhqv11vt6wGbmiZgeGlWo6C26MGVyIqyA85AO/Kj4YGAc67aZW6EG/MDTmcygubkdDqnc5/L1DQDWyWj/MHga5FExuNRyAaNAqSC6yEBV/nxAMbOQAIHm5oG8Ck2UyFpKGmmCophjsC1cJyu+lzYyxxMmqmMrofjKvhFSVpwCjK3DeGPy1RSTTQukN1in8DA2WlpaYOwlwnHaFziSDhZzsDJdpc9Ca6LVfBwxdx2WNg50aYaGu3ElWpo9NAiCpZdKSkpNbJFdqeALHBSDkeXTMFpkHHB/vKUcplWLmHeUHPb54SFC30UgN1WRVFwq5EVkIzjOjiKbgymWoJwBgI+vsL7Dq5EtD7KtF5yiVY/VC6qGCTjDKjhJIJ3QFXxQ2lFqBqShuuWbAo6zMFuH1TLmhMDj5+ZOHMpOy/58MMP67Wh0dSxCQnxiqx4EhISJlitKtenzE9IGFsFT46nYKUerrsCapc2M1HVTDy+jWXXF+rAVQj1eDYZCDRV0AscARK4zZV6yiyiOmwfMldRMJohaDQcceGEUew8QgfOJT471WC/TbEEB4ML2CCR4fdzcCR36AnYI2g0GfmIVC8hElnxuSVMB07WgOdy74ZrYl/AcwSNViAqmxt6AbjuIm6EG8DnJiXdnoG/7+1JSalWCs64Pen2qG3btq1LGnPbmME0kageMoQCWxvAYpWTgadvG0S0zGaxxkJCNlxPIoF5BEJknPewnxXHAk2fAFLYWL8cUdndocHEGZCmCHqNp74viawH9OLsAZjF9N6AxmdPQionBBi+Bb5p7EBykYmzRwqluNSAECE/j1II2GXXeIJdkJcJoQRSqxXiDbYjkKsX8HgKJjW7iNRqFdwOsdhVEgWXQAi2qrIyvtJl7QFLM5ILrwPdMAH/5i1bCpgatuwoLCycs6VgS17h0tsqwUqVJBNwIHn1wiOD8Ws4QkI+LCkWwr7K2bOTL2Bgqus0tXoAj0QJGLpjE60tKSnBBZwyNWNTIu5p2Og0AH/TdNykqLePRZxjkfRcKpjH3twjA3FwofDQa0OYdg7sTe8fWpsdNOvDqw+X3esEsGmJpyCmHAa+TuiodRxXta8OjmJtwkp7raCpCHdv4GIIvWawbzz0waYHC19+OQ5E4jIGHuLop1UFsMoqKh4fuf/xv8gNx5BZeAKmbm/c3rhJTdc0NUjU9Py9cURz/LIOzH+WomnHosBlBLcscji6d7Yeun/r1s9++/3vgy0EnCmA+PsS3Xq/ehTqDRyFZFnTjtGKgDm4cZGjbd+htTTt4X//fOnIgSBwiWjD5vTgiQbwCBYLaMHD9V09QvEU3FhW0VhW9sj5cPfXB4SnnnrjuQeev+ON/EV6sJOVGEyr4t2alYF9tuT09PSrbDqwraA9/YX0IoFq9ut5rw8i4Ja2jq8dR8964oknnM53H3DeeffdkPzE041tFDz0hXSsF26d8XpeXo1ITL8wlYPFlLy810sDFEyjUL9bB3b7DOMxAbflP7tr9/lP3gnaJQh377oJ68n7DuRT8K2ISqa1mppewMFs6GJgi0wGTB0YXAufYok2gB0733x+zR3PAm3XU+++JLzx7M3P3vSUMBBSCHgQEn0gOg/itWPTotYhQx4OZvMqVjcdPpI0boIiGMBlZ775xU9v3Yy1656HH37gxpvuvunjB/KPMfAEJJP5RpfApl+xCxqDL1hHnqgBy74Vqamp5RByFaSmzlxFHCO4AeepkdOmTSvCveC0jGmzMLji6++e++SDH9fceM8999y4684vHn743Zde+vmPg40c7KuBJ5d3TZvWNRnM1PgoeB5YaJqZOrMZjisDuuZEekW9ilxIQWPhpBn5UTIGdx5a88nH3x77dM09N2Ldveu555//6P7/oE4zMHubJRKyk/jGgyi4EiwsI1/RhhrU5kRslwJ4vBCkYHD+wbc/eu7VxzuPvbhmzTMEffMdT97/eEWjFnwVLiAG48jrKgZeiPzkK+bowHQcsKFVwWCNI4CacIm//ear/IP9Gg90vvrDM2vefOaZt9bctL0TuCp4IlLQRjxiKQjZ60isqZBxrRLMN/MugoGLIdRKgpArGXd2i9MW488aSFucFq0B01fdeOzgIsC09Ovs3PDli++8886X3Z34Rk+JIcLzgLUMi98q9sclhhsTcSgJ5qPY0LViblraxQDGoVf5/gUQcqXhHn1/yv487N5u2YLjsh6wEksbDVNL/oGDLZ9//t5B3FlycBFYad9fvj91KgRmA3DRWci3GI5zKVi2QKR3GWlOkqKgJT1Do4ISqUOP4zIOrsFgru4DnW/D9y3L14GxEpAdlQsmAjAJ+U4JOwNiMAi0esCTkJ/Uy2ibguMUDi7QgRcd/eCXO96641Av4OlIQgVmYEgEs+qMgA9a+TqYvQgQr0xB9YFAAEpss8kmYFDnwDU3v/39Z529gRVUUxRwEtc6wMxysGKTCPgsiMHqFYuvwesd2YTj8LEJY28dueLReojRIsttZuD8Yy9+9eIH33z6uBG8GCykr9i0aRMemZaNXDGSvUw1MaFd9LMQZgZePrTbUS5LnYYQ/UoNihm4reO9zquPdr/HuUZHAAcoqXCcL+iVZpUYeDkSsdBsXgPIxM6AGiTZuwhYRCkc3NJW1q/fgX7H8vPLKlp6BY+wwhtdyXsunUoYmMeS8RxMHQE/+915cLRScGOFo8zRnVkG/lZ+haNjAxzLHGX9WvTg/qTEJuB5PeCELDrLine truncated
'css': {
'width': '138rpx',
'height': '138rpx',
'marginTop': '6rpx',
'marginLeft': '6rpx',
},
}],
}],
}],
},
}),
mounted() {
this.$refs.poster.canvasToTempFilePathSync({
fileType: 'jpg',
quality: 1,
success: (res) => {
console.log(`mounted`, res.tempFilePath);
this.picture2 = res.tempFilePath;
this.saveImage();
},
});
},
methods: {
close() {
this.show = false;
},
fail(v) {
console.log(v);
},
done(v) {
console.log('绘制完成:');
},
save() {
this.$refs.poster.canvasToTempFilePathSync({
fileType: 'jpg',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
this.picture2 = res.tempFilePath;
this.saveImage();
},
fail(e) {
console.log('???????????', e);
},
});
},
// 保存图征
saveImage() {
// #ifndef H5
uni.saveImageToPhotosAlbum({
filePath: this.picture,
success(res) {
uni.showToast({
title: '已保存到相册',
icon: 'success',
duration: 2000,
});
},
});
// #endif
},
},
};
</script>
<style lang="stylus" scoped>
</style>
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title></title>
<style type="text/css">
html,
body,
canvas {
padding: 0;
margin: 0;
width: 100%;
height: 100%;
overflow-y: hidden;
background-color: transparent;
}
</style>
</head>
<body>
<canvas id="lime-painter"></canvas>
<script type="text/javascript" src="./uni.webview.1.5.3.js"></script>
<script type="text/javascript" src="./painter.js"></script>
<script>
var cache = [];
var painter = null;
var canvas = null;
var context = null;
var timer = null;
var pixelRatio = 1;
console.log = function (...args) {
postMessage(args);
};
// function stringify(key, value) {
// if (typeof value === 'object' && value !== null) {
// if (cache.indexOf(value) !== -1) {
// return;
// }
// cache.push(value);
// }
// return value;
// };
function emit(event, data) {
postMessage({
event,
data: (typeof data !== 'object' && data !== null ? data : JSON.stringify(data))
});
cache = [];
};
function postMessage(data) {
uni.postMessage({
data
});
};
function init(dpr) {
canvas = document.querySelector('#lime-painter');
context = canvas.getContext('2d');
pixelRatio = dpr || window.devicePixelRatio;
painter = new Painter({
id: 'lime-painter',
context,
canvas,
pixelRatio,
width: canvas.offsetWidth,
height: canvas.offsetHeight,
listen: {
onProgress(v) {
emit('progressChange', v);
},
onEffectFail(err) {
//console.error(err)
emit('fail', err);
}
}
});
emit('inited', true);
};
function save(args) {
delete args.success;
delete args.fail;
clearTimeout(timer);
timer = setTimeout(() => {
const path = painter.save(args);
if (typeof path == 'string') {
const index = Math.ceil(path.length / 8);
for (var i = 0; i < 8; i++) {
if (i == 7) {
emit('success', path.substr(i * index, index));
} else {
emit('file', path.substr(i * index, index));
}
};
} else {
// console.log('canvas no data')
emit('fail', 'canvas no data');
};
}, 30);
};
async function source(args) {
let size = await painter.source(args);
emit('layoutChange', size);
if(!canvas.height) {
console.log('canvas no size')
emit('fail', 'canvas no size');
}
painter.render().catch(err => {
// console.error(err)
emit('fail', err);
});
};
</script>
</body>
</html>
@@ -0,0 +1 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports):"function"==typeof define&&define.amd?define(["exports"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Painter={})}(this,(function(t){"use strict";var e=function(){return e=Object.assign||function(t){for(var e,i=1,n=arguments.length;i<n;i++)for(var r in e=arguments[i])Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r]);return t},e.apply(this,arguments)};function i(t,e,i,n){return new(i||(i=Promise))((function(r,o){function s(t){try{h(n.next(t))}catch(t){o(t)}}function a(t){try{h(n.throw(t))}catch(t){o(t)}}function h(t){var e;t.done?r(t.value):(e=t.value,e instanceof i?e:new i((function(t){t(e)}))).then(s,a)}h((n=n.apply(t,e||[])).next())}))}function n(t,e){var i,n,r,o,s={label:0,sent:function(){if(1&r[0])throw r[1];return r[1]},trys:[],ops:[]};return o={next:a(0),throw:a(1),return:a(2)},"function"==typeof Symbol&&(o[Symbol.iterator]=function(){return this}),o;function a(o){return function(a){return function(o){if(i)throw new TypeError("Generator is already executing.");for(;s;)try{if(i=1,n&&(r=2&o[0]?n.return:o[0]?n.throw||((r=n.return)&&r.call(n),0):n.next)&&!(r=r.call(n,o[1])).done)return r;switch(n=0,r&&(o=[2&o[0],r.value]),o[0]){case 0:case 1:r=o;break;case 4:return s.label++,{value:o[1],done:!1};case 5:s.label++,n=o[1],o=[0];continue;case 7:o=s.ops.pop(),s.trys.pop();continue;default:if(!(r=s.trys,(r=r.length>0&&r[r.length-1])||6!==o[0]&&2!==o[0])){s=0;continue}if(3===o[0]&&(!r||o[1]>r[0]&&o[1]<r[3])){s.label=o[1];break}if(6===o[0]&&s.label<r[1]){s.label=r[1],r=o;break}if(r&&s.label<r[2]){s.label=r[2],s.ops.push(o);break}r[2]&&s.ops.pop(),s.trys.pop();continue}o=e.call(t,s)}catch(t){o=[6,t],n=0}finally{i=r=0}if(5&o[0])throw o[1];return{value:o[0]?o[1]:void 0,done:!0}}([o,a])}}}var r={MP_WEIXIN:"mp-weixin",MP_QQ:"mp-qq",MP_ALIPAY:"mp-alipay",MP_BAIDU:"mp-baidu",MP_TOUTIAO:"mp-toutiao",MP_DINGDING:"mp-dingding",H5:"h5",WEB:"web",PLUS:"plus"},o="width",s="height",a="margin",h="center",c="flex-start",f="flex-end",d=["Top","Right","Bottom","Left"],l="left",u="right",p="top",g="bottom",v="middle",y=["contentSize","clientSize","borderSize","offsetSize"],b="row",x="column",w={TOP:p,MIDDLE:v,BOTTOM:g},m={LEFT:l,CENTER:h,RIGHT:u},S="view",z="text",I="image",M="qrcode",k="block",B="inline-block",W="none",P="flex",O="absolute",T="fixed",L="transparent",R="fill",F={display:k,color:"#000000",lineHeight:"1.4em",fontSize:14,fontWeight:400,fontFamily:"sans-serif",lineCap:"butt",flexDirection:b,flexWrap:"nowrap",textAlign:l,alignItems:c,justifyContent:c,position:"static",transformOrigin:"".concat(h," ").concat(h)},A={upx2px:function(t){return window.innerWidth/750*t},getSystemInfoSync:function(){return{screenWidth:window.innerWidth,screenHeight:window.innerHeight}},getImageInfo:function(t){var e=t.src,i=t.success,n=t.fail,r=new Image;r.onload=function(){i({width:r.naturalWidth,height:r.naturalHeight,path:r.src,src:e})},r.onerror=n,r.src=e}},E="object",j="undefined",C=typeof window==E?typeof uni==j||typeof uni!==j&&!uni.addInterceptor?r.WEB:r.H5:typeof swan==E?r.MP_BAIDU:typeof tt==E?r.MP_TOUTIAO:typeof plus===E?r.PLUS:typeof wx==E?r.MP_WEIXIN:void 0,H=C==r.MP_WEIXIN?wx:typeof uni!=j?uni.getImageInfo?{upx2px:function(t){return uni.upx2px(t)},getSystemInfoSync:function(){return uni.getSystemInfoSync()},getImageInfo:function(t){return uni.getImageInfo(t)},downloadFile:function(t){return uni.downloadFile(t)}}:Object.assign(uni,A):typeof window!=j?A:uni;if(!H.upx2px){var D=((H.getSystemInfoSync&&H.getSystemInfoSync()).screenWidth||375)/750;H.upx2px=function(t){return D*t}}function Y(t){return/^-?\d+(\.\d+)?$/.test(t)}function $(t,e,i){if(Y(t))return 1*t;if("string"==typeof t){var n=/^-?([0-9]+)?([.]{1}[0-9]+){0,1}(em|rpx|vw|vh|px|%)$/g.exec(t);if(!t||!n)return 0;var r=n[3];t=parseFloat(t);var o=0;if("rpx"===r)o=H.upx2px(t);else if("px"===r)o=1*t;else if("%"===r&&e)o=t*$(e)/100;else if("em"===r&&e)o=t*$(e||14);else if(["vw","vh"].includes(r)){var s=H.getSystemInfoSync(),a=s.screenWidth,h=s.screenHeight;o=t*("vw"==r?a:h)/100}return 1*o.toFixed(2)}return 0}function U(t){return/%$/.test(t)}var N=function(t){return!(!t||!t.startsWith("linear")&&!t.startsWith("radial"))},_=function(t,e,i,n,r,o){t.startsWith("linear")?function(t,e,i,n,r,o){for(var s=function(t,e,i,n,r){void 0===n&&(n=0);void 0===r&&(r=0);var o=t.match(/([-]?\d{1,3})deg/),s=o&&o[1]?parseFloat(o[1]):0;s>=360&&(s-=360);s<0&&(s+=360);if(0===(s=Math.round(s)))return{x0:Math.round(e/2)+n,y0:i+r,x1:Math.round(e/2)+n,y1:r};if(180===s)return{x0:Math.round(e/2)+n,y0:r,x1:Math.round(e/2)+n,y1:i+r};if(90===s)return{x0:n,y0:Math.round(i/2)+r,x1:e+n,y1:Math.round(i/2)+r};if(270===s)return{x0:e+n,y0:Math.round(i/2)+r,x1:n,y1:Math.round(i/2)+r};var a=Math.round(180*Math.asin(e/Math.sqrt(Math.pow(e,2)+Math.pow(i,2)))/Math.PI);if(s===a)return{x0:n,y0:i+r,x1:e+n,y1:r};if(s===180-a)return{x0:n,y0:r,x1:e+n,y1:i+r};if(s===180+a)return{x0:e+n,y0:r,x1:n,y1:i+r};if(s===360-a)return{x0:e+n,y0:i+rLine truncated
@@ -0,0 +1 @@
!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).uni=n()}(this,(function(){"use strict";try{var e={};Object.defineProperty(e,"passive",{get:function(){!0}}),window.addEventListener("test-passive",null,e)}catch(e){}var n=Object.prototype.hasOwnProperty;function i(e,i){return n.call(e,i)}var t=[];function o(){return window.__dcloud_weex_postMessage||window.__dcloud_weex_}var r=function(e,n){var i={options:{timestamp:+new Date},name:e,arg:n};if(o()){if("postMessage"===e){var r={data:[n]};return window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessage(r):window.__dcloud_weex_.postMessage(JSON.stringify(r))}var a={type:"WEB_INVOKE_APPSERVICE",args:{data:i,webviewIds:t}};window.__dcloud_weex_postMessage?window.__dcloud_weex_postMessageToService(a):window.__dcloud_weex_.postMessageToService(JSON.stringify(a))}if(!window.plus)return window.parent.postMessage({type:"WEB_INVOKE_APPSERVICE",data:i,pageId:""},"*");if(0===t.length){var d=plus.webview.currentWebview();if(!d)throw new Error("plus.webview.currentWebview() is undefined");var s=d.parent(),w="";w=s?s.id:d.id,t.push(w)}if(plus.webview.getWebviewById("__uniapp__service"))plus.webview.postMessageToUniNView({type:"WEB_INVOKE_APPSERVICE",args:{data:i,webviewIds:t}},"__uniapp__service");else{var u=JSON.stringify(i);plus.webview.getLaunchWebview().evalJS('UniPlusBridge.subscribeHandler("'.concat("WEB_INVOKE_APPSERVICE",'",').concat(u,",").concat(JSON.stringify(t),");"))}},a={navigateTo:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;r("navigateTo",{url:encodeURI(n)})},navigateBack:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.delta;r("navigateBack",{delta:parseInt(n)||1})},switchTab:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;r("switchTab",{url:encodeURI(n)})},reLaunch:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;r("reLaunch",{url:encodeURI(n)})},redirectTo:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},n=e.url;r("redirectTo",{url:encodeURI(n)})},getEnv:function(e){o()?e({nvue:!0}):window.plus?e({plus:!0}):e({h5:!0})},postMessage:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};r("postMessage",e.data||{})}},d=/uni-app/i.test(navigator.userAgent),s=/Html5Plus/i.test(navigator.userAgent),w=/complete|loaded|interactive/;var u=window.my&&navigator.userAgent.indexOf("AlipayClient")>-1;var g=window.swan&&window.swan.webView&&/swan/i.test(navigator.userAgent);var c=window.qq&&window.qq.miniProgram&&/QQ/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var v=window.tt&&window.tt.miniProgram&&/toutiaomicroapp/i.test(navigator.userAgent);var m=window.wx&&window.wx.miniProgram&&/micromessenger/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var p=window.qa&&/quickapp/i.test(navigator.userAgent);var f=window.ks&&window.ks.miniProgram&&/micromessenger/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);var l=window.tt&&window.tt.miniProgram&&/Lark|Feishu/i.test(navigator.userAgent);var _=window.jd&&window.jd.miniProgram&&/micromessenger/i.test(navigator.userAgent)&&/miniProgram/i.test(navigator.userAgent);for(var E,b=function(){window.UniAppJSBridge=!0,document.dispatchEvent(new CustomEvent("UniAppJSBridgeReady",{bubbles:!0,cancelable:!0}))},h=[function(e){if(d||s)return window.__dcloud_weex_postMessage||window.__dcloud_weex_?document.addEventListener("DOMContentLoaded",e):window.plus&&w.test(document.readyState)?setTimeout(e,0):document.addEventListener("plusready",e),a},function(e){if(m)return window.WeixinJSBridge&&window.WeixinJSBridge.invoke?setTimeout(e,0):document.addEventListener("WeixinJSBridgeReady",e),window.wx.miniProgram},function(e){if(c)return window.QQJSBridge&&window.QQJSBridge.invoke?setTimeout(e,0):document.addEventListener("QQJSBridgeReady",e),window.qq.miniProgram},function(e){if(u){document.addEventListener("DOMContentLoaded",e);var n=window.my;return{navigateTo:n.navigateTo,navigateBack:n.navigateBack,switchTab:n.switchTab,reLaunch:n.reLaunch,redirectTo:n.redirectTo,postMessage:n.postMessage,getEnv:n.getEnv}}},function(e){if(g)return document.addEventListener("DOMContentLoaded",e),window.swan.webView},function(e){if(v)return document.addEventListener("DOMContentLoaded",e),window.tt.miniProgram},function(e){if(p){window.QaJSBridge&&window.QaJSBridge.invoke?setTimeout(e,0):document.addEventListener("QaJSBridgeReady",e);var n=window.qa;return{navigateTo:n.navigateTo,navigateBack:n.navigateBack,switchTab:n.switchTab,reLaunch:n.reLaunch,redirectTo:n.redirectTo,postMessage:n.postMessage,getEnv:n.getEnv}}},function(e){if(f)return window.WeixinJSBridge&&window.WeixinJSBridge.invoke?setTimeout(e,0):document.addEventListener("WeixinJSBridgeReady",e),window.ks.miniProgram},function(e){if(l)return document.aLine truncated
+93
View File
@@ -0,0 +1,93 @@
{
"id": "lime-painter",
"displayName": "海报画板",
"version": "1.9.6.5",
"description": "一款canvas海报组件,更优雅的海报生成方案,有限的支持富文本",
"keywords": [
"海报",
"富文本",
"生成海报",
"生成二维码",
"JSON"
],
"repository": "https://gitee.com/liangei/lime-painter",
"engines": {
"HBuilderX": "^3.4.14"
},
"dcloudext": {
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": "305716444"
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": "",
"type": "component-vue"
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "y"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "y",
"百度": "y",
"字节跳动": "y",
"QQ": "y",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
},
"Vue": {
"vue2": "y",
"vue3": "y"
}
}
}
},
"name": "lime-painter",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC"
}
+388
View File
@@ -0,0 +1,388 @@
/*
* HTML5 Parser By Sam Blowes
*
* Designed for HTML5 documents
*
* Original code by John Resig (ejohn.org)
* http://ejohn.org/blog/pure-javascript-html-parser/
* Original code by Erik Arvidsson, Mozilla Public License
* http://erik.eae.net/simplehtmlparser/simplehtmlparser.js
*
* ----------------------------------------------------------------------------
* License
* ----------------------------------------------------------------------------
*
* This code is triple licensed using Apache Software License 2.0,
* Mozilla Public License or GNU Public License
*
* ////////////////////////////////////////////////////////////////////////////
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* ////////////////////////////////////////////////////////////////////////////
*
* The contents of this file are subject to the Mozilla Public License
* Version 1.1 (the "License"); you may not use this file except in
* compliance with the License. You may obtain a copy of the License at
* http://www.mozilla.org/MPL/
*
* Software distributed under the License is distributed on an "AS IS"
* basis, WITHOUT WARRANTY OF ANY KIND, either express or implied. See the
* License for the specific language governing rights and limitations
* under the License.
*
* The Original Code is Simple HTML Parser.
*
* The Initial Developer of the Original Code is Erik Arvidsson.
* Portions created by Erik Arvidssson are Copyright (C) 2004. All Rights
* Reserved.
*
* ////////////////////////////////////////////////////////////////////////////
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; either version 2
* of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* ----------------------------------------------------------------------------
* Usage
* ----------------------------------------------------------------------------
*
* // Use like so:
* HTMLParser(htmlString, {
* start: function(tag, attrs, unary) {},
* end: function(tag) {},
* chars: function(text) {},
* comment: function(text) {}
* });
*
* // or to get an XML string:
* HTMLtoXML(htmlString);
*
* // or to get an XML DOM Document
* HTMLtoDOM(htmlString);
*
* // or to inject into an existing document/DOM node
* HTMLtoDOM(htmlString, document);
* HTMLtoDOM(htmlString, document.body);
*
*/
// Regular Expressions for parsing tags and attributes
var startTag = /^<([-A-Za-z0-9_]+)((?:\s+[a-zA-Z_:][-a-zA-Z0-9_:.]*(?:\s*=\s*(?:(?:"[^"]*")|(?:'[^']*')|[^>\s]+))?)*)\s*(\/?)>/;
var endTag = /^<\/([-A-Za-z0-9_]+)[^>]*>/;
var attr = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:(?:"((?:\\.|[^"])*)")|(?:'((?:\\.|[^'])*)')|([^>\s]+)))?/g; // Empty Elements - HTML 5
var empty = makeMap('area,base,basefont,br,col,frame,hr,img,input,link,meta,param,embed,command,keygen,source,track,wbr'); // Block Elements - HTML 5
// fixed by xxx 将 ins 标签从块级名单中移除
var block = makeMap('a,address,article,applet,aside,audio,blockquote,button,canvas,center,dd,del,dir,div,dl,dt,fieldset,figcaption,figure,footer,form,frameset,h1,h2,h3,h4,h5,h6,header,hgroup,hr,iframe,isindex,li,map,menu,noframes,noscript,object,ol,output,p,pre,section,script,table,tbody,td,tfoot,th,thead,tr,ul,video'); // Inline Elements - HTML 5
var inline = makeMap('abbr,acronym,applet,b,basefont,bdo,big,br,button,cite,code,del,dfn,em,font,i,iframe,img,input,ins,kbd,label,map,object,q,s,samp,script,select,small,span,strike,strong,sub,sup,textarea,tt,u,var'); // Elements that you can, intentionally, leave open
// (and which close themselves)
var closeSelf = makeMap('colgroup,dd,dt,li,options,p,td,tfoot,th,thead,tr'); // Attributes that have their values filled in disabled="disabled"
var fillAttrs = makeMap('checked,compact,declare,defer,disabled,ismap,multiple,nohref,noresize,noshade,nowrap,readonly,selected'); // Special Elements (can contain anything)
var special = makeMap('script,style');
function HTMLParser(html, handler) {
var index;
var chars;
var match;
var stack = [];
var last = html;
stack.last = function () {
return this[this.length - 1];
};
while (html) {
chars = true; // Make sure we're not in a script or style element
if (!stack.last() || !special[stack.last()]) {
// Comment
if (html.indexOf('<!--') == 0) {
index = html.indexOf('-->');
if (index >= 0) {
if (handler.comment) {
handler.comment(html.substring(4, index));
}
html = html.substring(index + 3);
chars = false;
} // end tag
} else if (html.indexOf('</') == 0) {
match = html.match(endTag);
if (match) {
html = html.substring(match[0].length);
match[0].replace(endTag, parseEndTag);
chars = false;
} // start tag
} else if (html.indexOf('<') == 0) {
match = html.match(startTag);
if (match) {
html = html.substring(match[0].length);
match[0].replace(startTag, parseStartTag);
chars = false;
}
}
if (chars) {
index = html.indexOf('<');
var text = index < 0 ? html : html.substring(0, index);
html = index < 0 ? '' : html.substring(index);
if (handler.chars) {
handler.chars(text);
}
}
} else {
html = html.replace(new RegExp('([\\s\\S]*?)<\/' + stack.last() + '[^>]*>'), function (all, text) {
text = text.replace(/<!--([\s\S]*?)-->|<!\[CDATA\[([\s\S]*?)]]>/g, '$1$2');
if (handler.chars) {
handler.chars(text);
}
return '';
});
parseEndTag('', stack.last());
}
if (html == last) {
throw 'Parse Error: ' + html;
}
last = html;
} // Clean up any remaining tags
parseEndTag();
function parseStartTag(tag, tagName, rest, unary) {
tagName = tagName.toLowerCase();
if (block[tagName]) {
while (stack.last() && inline[stack.last()]) {
parseEndTag('', stack.last());
}
}
if (closeSelf[tagName] && stack.last() == tagName) {
parseEndTag('', tagName);
}
unary = empty[tagName] || !!unary;
if (!unary) {
stack.push(tagName);
}
if (handler.start) {
var attrs = [];
rest.replace(attr, function (match, name) {
var value = arguments[2] ? arguments[2] : arguments[3] ? arguments[3] : arguments[4] ? arguments[4] : fillAttrs[name] ? name : '';
attrs.push({
name: name,
value: value,
escaped: value.replace(/(^|[^\\])"/g, '$1\\\"') // "
});
});
if (handler.start) {
handler.start(tagName, attrs, unary);
}
}
}
function parseEndTag(tag, tagName) {
// If no tag name is provided, clean shop
if (!tagName) {
var pos = 0;
} // Find the closest opened tag of the same type
else {
for (var pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos] == tagName) {
break;
}
}
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (var i = stack.length - 1; i >= pos; i--) {
if (handler.end) {
handler.end(stack[i]);
}
} // Remove the open elements from the stack
stack.length = pos;
}
}
}
function makeMap(str) {
var obj = {};
var items = str.split(',');
for (var i = 0; i < items.length; i++) {
obj[items[i]] = true;
}
return obj;
}
function removeDOCTYPE(html) {
return html.replace(/<\?xml.*\?>\n/, '').replace(/<!doctype.*>\n/, '').replace(/<!DOCTYPE.*>\n/, '');
}
function parseAttrs(attrs) {
return attrs.reduce(function (pre, attr) {
var value = attr.value;
var name = attr.name;
if (pre[name]) {
pre[name] = pre[name] + " " + value;
} else {
pre[name] = value;
}
return pre;
}, {});
}
function convertStyleStringToJSON(styleString) {
var styles = styleString.split(";"); // 通过分号将样式字符串分割为多个样式声明
var result = {};
styles.forEach(function(style) {
var styleParts = style.split(":"); // 通过冒号将样式声明分割为属性和值
var property = styleParts[0].trim();
var value = styleParts[1] && styleParts[1].trim();
if (property && value) {
result[property] = value; // 将属性和值添加到结果对象中
}
});
return result;
}
function parseHtml(html) {
html = removeDOCTYPE(html);
var stacks = [];
var results = {
node: 'root',
children: []
};
HTMLParser(html, {
start: function start(tag, attrs, unary) {
var node = {
name: tag
};
if (attrs.length !== 0) {
node.attrs = parseAttrs(attrs);
node.styles = node.attrs.style ? convertStyleStringToJSON(node.attrs.style) : {}
}
if(!node.type) {
if(inline[node.name] && node.name !== 'img' ) {
node.type = 'text';
if(node.name == 'br') {
node.text = '\n'
} else if(node.name == 'strong'){
node.styles.fontWeight = 'bold'
}
} else if(node.name == 'img'){
node.type = 'image'
node.src = node.attrs.src
} else {
node.type = 'view'
if(['h1','h2','h3','h4','h5','h6'].includes(node.name)) {
node.styles.fontWeight = 'bold'
}
}
}
if (unary) {
var parent = stacks[0] || results;
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
} else {
stacks.unshift(node);
}
},
end: function end(tag) {
var node = stacks.shift();
if (node.name !== tag) console.error('invalid state: mismatch end tag');
if (stacks.length === 0) {
results.children.push(node);
} else {
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
const isTextBox = node.children && node.children.length > 1 && node.children.every(child => {
return ['text','image'].includes(child.type)
})
if(isTextBox) {
node.type = 'textBox'
}
},
chars: function chars(text) {
var node = {
type: 'text',
text: text
};
if (stacks.length === 0) {
results.children.push(node);
} else {
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
},
comment: function comment(text) {
var node = {
node: 'comment',
text: text
};
var parent = stacks[0];
if (!parent.children) {
parent.children = [];
}
parent.children.push(node);
}
});
return results.children;
}
export default parseHtml;
+963
View File
@@ -0,0 +1,963 @@
# Painter 画板 测试版
> uniapp 海报画板,更优雅的海报生成方案
> [查看更多 站点 1](https://limeui.qcoon.cn/#/painter)
> [查看更多 站点 2](http://liangei.gitee.io/limeui/#/painter)
> Q 群:1169785031
## 平台兼容
| H5 | 微信小程序 | 支付宝小程序 | 百度小程序 | 头条小程序 | QQ 小程序 | App |
| --- | ---------- | ------------ | ---------- | ---------- | --------- | --- |
| √ | √ | √ | 未测 | √ | √ | √ |
## 安装
在市场导入**[海报画板](https://ext.dcloud.net.cn/plugin?id=2389)uni_modules**版本的即可,无需`import`
## 代码演示
### 插件demo
- lime-painter 为 demo
- 位于 uni_modules/lime-painter/components/lime-painter
- 导入插件后直接使用可查看demo
```vue
<lime-painter />
```
### 基本用法
- 插件提供 JSON 及 Template 的方式绘制海报
- 参考 css 块状流布局模拟 css schema。
- 另外flex布局还不是成完善,请谨慎使用,普通的流布局我觉得已经够用了。
#### 方式一 Template
- 提供`l-painter-view`、`l-painter-text`、`l-painter-image`、`l-painter-qrcode`四种类型组件
- 通过 `css` 属性绘制样式,与 style 使用方式保持一致。
```html
<l-painter>
//如果使用Template出现顺序错乱,可使用`template` 等所有变量完成再显示
<template v-if="show">
<l-painter-view
css="background: #07c160; height: 120rpx; width: 120rpx; display: inline-block"
></l-painter-view>
<l-painter-view
css="background: #1989fa; height: 120rpx; width: 120rpx; border-top-right-radius: 60rpx; border-bottom-left-radius: 60rpx; display: inline-block; margin: 0 30rpx;"
></l-painter-view>
<l-painter-view
css="background: #ff9d00; height: 120rpx; width: 120rpx; border-radius: 50%; display: inline-block"
></l-painter-view>
<template>
</l-painter>
```
#### 方式二 JSON
- 在 json 里四种类型组件的`type`为`view`、`text`、`image`、`qrcode`
- 通过 `board` 设置海报所需的 JSON 数据进行绘制或`ref`获取组件实例调用组件内的`render(json)`
- 所有类型的 schema 都具有`css`字段,css 的 key 值使用**驼峰**如:`lineHeight`
```html
<l-painter :board="poster"/>
```
```js
data() {
return {
poster: {
css: {
// 根节点若无尺寸,自动获取父级节点
width: '750rpx'
},
views: [
{
css: {
background: "#07c160",
height: "120rpx",
width: "120rpx",
display: "inline-block"
},
type: "view"
},
{
css: {
background: "#1989fa",
height: "120rpx",
width: "120rpx",
borderTopRightRadius: "60rpx",
borderBottomLeftRadius: "60rpx",
display: "inline-block",
margin: "0 30rpx"
},
views: [],
type: "view"
},
{
css: {
background: "#ff9d00",
height: "120rpx",
width: "120rpx",
borderRadius: "50%",
display: "inline-block"
},
views: [],
type: "view"
},
]
}
}
}
```
### View 容器
- 类似于 `div` 可以嵌套承载更多的 view、text、image,qrcode 共同构建一颗完整的节点树
- 在 JSON 里具有 `views` 的数组字段,用于嵌套承载节点。
#### 方式一 Template
```html
<l-painter>
<l-painter-view css="background: #f0f0f0; padding-top: 100rpx;">
<l-painter-view
css="background: #d9d9d9; width: 33.33%; height: 100rpx; display: inline-block"
></l-painter-view>
<l-painter-view
css="background: #bfbfbf; width: 66.66%; height: 100rpx; display: inline-block"
></l-painter-view>
</l-painter-view>
</l-painter>
```
#### 方式二 JSON
```js
{
css: {},
views: [
{
type: 'view',
css: {
background: '#f0f0f0',
paddingTop: '100rpx'
},
views: [
{
type: 'view',
css: {
background: '#d9d9d9',
width: '33.33%',
height: '100rpx',
display: 'inline-block'
}
},
{
type: 'view',
css: {
background: '#bfbfbf',
width: '66.66%',
height: '100rpx',
display: 'inline-block'
}
}
],
}
]
}
```
### Text 文本
- 通过 `text` 属性填写文本内容。
- 支持`\n`换行符
- 支持省略号,使用 css 的`line-clamp`设置行数,当文字内容超过会显示省略号。
- 支持`text-decoration`
#### 方式一 Template
```html
<l-painter>
<l-painter-view css="background: #e0e2db; padding: 30rpx; color: #222a29">
<l-painter-text
text="登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼"
/>
<l-painter-text
text="登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼"
css="text-align:center; padding-top: 20rpx; text-decoration: line-through "
/>
<l-painter-text
text="登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼"
css="text-align:right; padding-top: 20rpx"
/>
<l-painter-text
text="水调歌头\n明月几时有?把酒问青天。不知天上宫阙,今夕是何年。我欲乘风归去,又恐琼楼玉宇,高处不胜寒。起舞弄清影,何似在人间。"
css="line-clamp: 3; padding-top: 20rpx; background: linear-gradient(,#ff971b 0%, #ff5000 100%); background-clip: text"
/>
</l-painter-view>
</l-painter>
```
#### 方式二 JSON
```js
// 基础用法
{
type: 'text',
text: '登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼',
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼',
css: {
// 设置居中对齐
textAlign: 'center',
// 设置中划线
textDecoration: 'line-through'
}
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼',
css: {
// 设置右对齐
textAlign: 'right',
}
},
{
type: 'text',
text: '登鹳雀楼\n白日依山尽,黄河入海流\n欲穷千里目,更上一层楼',
css: {
// 设置行数,超出显示省略号
lineClamp: 3,
// 渐变文字
background: 'linear-gradient(,#ff971b 0%, #1989fa 100%)',
backgroundClip: 'text'
}
}
```
### Image 图片
- 通过 `src` 属性填写图片路径。
- 图片路径支持:网络图片,本地 static 里的图片路径,缓存路径,**字节的static目录是写相对路径**
- 通过 `css` 的 `object-fit`属性可以设置图片的填充方式,可选值见下方 CSS 表格。
- 通过 `css` 的 `object-position`配合 `object-fit` 可以设置图片的对齐方式,类似于`background-position`,详情见下方 CSS 表格。
- 使用网络图片时:小程序需要去公众平台配置 [downloadFile](https://mp.weixin.qq.com/) 域名
- 使用网络图片时:**H5 和 Nvue 需要决跨域问题**
#### 方式一 Template
```html
<l-painter>
<!-- 基础用法 -->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx"
/>
<!-- 填充方式 -->
<!-- css object-fit 设置 填充方式 见下方表格-->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; background: #eee"
/>
<!-- css object-position 设置 图片的对齐方式-->
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="width: 200rpx; height: 200rpx; object-fit: contain; object-position: 50% 50%; background: #eee"
/>
</l-painter>
```
#### 方式二 JSON
```js
// 基础用法
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx'
}
},
// 填充方式
// css objectFit 设置 填充方式 见下方表格
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx',
objectFit: 'contain'
}
},
// css objectPosition 设置 图片的对齐方式
{
type: 'image',
src: 'https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg',
css: {
width: '200rpx',
height: '200rpx',
objectFit: 'contain',
objectPosition: '50% 50%'
}
}
```
### Qrcode 二维码
- 通过`text`属性填写需要生成二维码的文本。
- 通过 `css` 里的 `color` 可设置生成码点的颜色。
- 通过 `css` 里的 `background`可设置背景色。
- 通过 `css `里的 `width`、`height`设置尺寸。
#### 方式一 Template
```html
<l-painter>
<l-painter-qrcode
text="limeui.qcoon.cn"
css="width: 200rpx; height: 200rpx"
/>
</l-painter>
```
#### 方式二 JSON
```js
{
type: 'qrcode',
text: 'limeui.qcoon.cn',
css: {
width: '200rpx',
height: '200rpx',
}
}
```
### 富文本
- 这是一个有限支持的测试能力,只能通过JSON方式,不要抱太大希望!
- 首先需要把富文本转成JSON,这需要引入`parser`这个包,如果你不使用是不会进入主包
```html
<l-painter ref="painter"/>
```
```js
import parseHtml from '@/uni_modules/lime-painter/parser'
const json = parseHtml(`<p><span>测试测试</span><img src="/static/logo.png"/></p>`)
this.$refs.painter.render(json)
```
### 生成图片
- 方式1、通过设置`isCanvasToTempFilePath`自动生成图片并在 `@success` 事件里接收海报临时路径
- 方式2、通过调用内部方法生成图片:
```html
<l-painter ref="painter">...code</l-painter>
```
```js
this.$refs.painter.canvasToTempFilePathSync({
fileType: "jpg",
// 如果返回的是base64是无法使用 saveImageToPhotosAlbum,需要设置 pathType为url
pathType: 'url',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
// 非H5 保存到相册
// H5 提示用户长按图另存
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: function () {
console.log('save success');
}
});
},
});
```
### 主动调用方式
- 通过获取组件实例内部的`render`函数 传递`JSON`即可
```html
<l-painter ref="painter" />
```
```js
// 渲染
this.$refs.painter.render(jsonSchema);
// 生成图片
this.$refs.painter.canvasToTempFilePathSync({
fileType: "jpg",
// 如果返回的是base64是无法使用 saveImageToPhotosAlbum,需要设置 pathType为url
pathType: 'url',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
// 非H5 保存到相册
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: function () {
console.log('save success');
}
});
},
});
```
### H5跨域
- 一般是需要后端或管理OSS资源的大佬处理
- 一般OSS的处理方式:
1、设置来源
```cmd
*
```
2、允许Methods
```html
GET
```
3、允许Headers
```html
access-control-allow-origin:*
```
4、最后如果还是不行,可试下给插件设置`useCORS`
```html
<l-painter useCORS>
```
### 海报示例
- 提供一份示例,只把插件当成生成图片的工具,非必要不要在弹窗里使用。
- 通过设置`isCanvasToTempFilePath`主动生成图片,再由 `@success` 事件接收海报临时路径
- 设置`hidden`隐藏画板。
请注意,示例用到了图片,海报的渲染是包括下载图片的时间,也许在某天图片会失效或访问超级慢,请更换为你的图片再查看,另外如果你是小程序请在使用示例时把**不校验合法域名**勾上!!!!!不然不显示还以为是插件的锅,求求了大佬们!
#### 方式一 Template
```html
<image :src="path" mode="widthFix"></image>
<l-painter
isCanvasToTempFilePath
@success="path = $event"
hidden
css="width: 750rpx; padding-bottom: 40rpx; background: linear-gradient(,#ff971b 0%, #ff5000 100%)"
>
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="margin-left: 40rpx; margin-top: 40rpx; width: 84rpx; height: 84rpx; border-radius: 50%;"
/>
<l-painter-view
css="margin-top: 40rpx; padding-left: 20rpx; display: inline-block"
>
<l-painter-text
text="隔壁老王"
css="display: block; padding-bottom: 10rpx; color: #fff; font-size: 32rpx; fontWeight: bold"
/>
<l-painter-text
text="为您挑选了一个好物"
css="color: rgba(255,255,255,.7); font-size: 24rpx"
/>
</l-painter-view>
<l-painter-view
css="margin-left: 40rpx; margin-top: 30rpx; padding: 32rpx; box-sizing: border-box; background: #fff; border-radius: 16rpx; width: 670rpx; box-shadow: 0 20rpx 58rpx rgba(0,0,0,.15)"
>
<l-painter-image
src="https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg"
css="object-fit: cover; object-position: 50% 50%; width: 606rpx; height: 606rpx; border-radius: 12rpx;"
/>
<l-painter-view
css="margin-top: 32rpx; color: #FF0000; font-weight: bold; font-size: 28rpx; line-height: 1em;"
>
<l-painter-text text="¥" css="vertical-align: bottom" />
<l-painter-text
text="39"
css="vertical-align: bottom; font-size: 58rpx"
/>
<l-painter-text text=".39" css="vertical-align: bottom" />
<l-painter-text
text="¥59.99"
css="vertical-align: bottom; padding-left: 10rpx; font-weight: normal; text-decoration: line-through; color: #999999"
/>
</l-painter-view>
<l-painter-view css="margin-top: 32rpx; font-size: 26rpx; color: #8c5400">
<l-painter-text text="自营" css="color: #212121; background: #ffb400;" />
<l-painter-text
text="30天最低价"
css="margin-left: 16rpx; background: #fff4d9; text-decoration: line-through;"
/>
<l-painter-text
text="满减优惠"
css="margin-left: 16rpx; background: #fff4d9"
/>
<l-painter-text
text="超高好评"
css="margin-left: 16rpx; background: #fff4d9"
/>
</l-painter-view>
<l-painter-view css="margin-top: 30rpx">
<l-painter-text
css="line-clamp: 2; color: #333333; line-height: 1.8em; font-size: 36rpx; width: 478rpx; padding-right:32rpx; box-sizing: border-box"
text="360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝"
></l-painter-text>
<l-painter-qrcode
css="width: 128rpx; height: 128rpx;"
text="limeui.qcoon.cn"
></l-painter-qrcode>
</l-painter-view>
</l-painter-view>
</l-painter>
```
```js
data() {
return {
path: ''
}
}
```
#### 方式二 JSON
```html
<image :src="path" mode="widthFix"></image>
<l-painter
:board="poster"
isCanvasToTempFilePath
@success="path = $event"
hidden
/>
```
```js
data() {
return {
path: '',
poster: {
css: {
width: "750rpx",
paddingBottom: "40rpx",
background: "linear-gradient(,#000 0%, #ff5000 100%)"
},
views: [
{
src: "https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg",
type: "image",
css: {
background: "#fff",
objectFit: "cover",
marginLeft: "40rpx",
marginTop: "40rpx",
width: "84rpx",
border: "2rpx solid #fff",
boxSizing: "border-box",
height: "84rpx",
borderRadius: "50%"
}
},
{
type: "view",
css: {
marginTop: "40rpx",
paddingLeft: "20rpx",
display: "inline-block"
},
views: [
{
text: "隔壁老王",
type: "text",
css: {
display: "block",
paddingBottom: "10rpx",
color: "#fff",
fontSize: "32rpx",
fontWeight: "bold"
}
},
{
text: "为您挑选了一个好物",
type: "text",
css: {
color: "rgba(255,255,255,.7)",
fontSize: "24rpx"
},
}
],
},
{
css: {
marginLeft: "40rpx",
marginTop: "30rpx",
padding: "32rpx",
boxSizing: "border-box",
background: "#fff",
borderRadius: "16rpx",
width: "670rpx",
boxShadow: "0 20rpx 58rpx rgba(0,0,0,.15)"
},
views: [
{
src: "https://m.360buyimg.com/babel/jfs/t1/196317/32/13733/288158/60f4ea39E6fb378ed/d69205b1a8ed3c97.jpg",
type: "image",
css: {
objectFit: "cover",
objectPosition: "50% 50%",
width: "606rpx",
height: "606rpx"
},
}, {
css: {
marginTop: "32rpx",
color: "#FF0000",
fontWeight: "bold",
fontSize: "28rpx",
lineHeight: "1em"
},
views: [{
text: "¥",
type: "text",
css: {
verticalAlign: "bottom"
},
}, {
text: "39",
type: "text",
css: {
verticalAlign: "bottom",
fontSize: "58rpx"
},
}, {
text: ".39",
type: "text",
css: {
verticalAlign: "bottom"
},
}, {
text: "¥59.99",
type: "text",
css: {
verticalAlign: "bottom",
paddingLeft: "10rpx",
fontWeight: "normal",
textDecoration: "line-through",
color: "#999999"
}
}],
type: "view"
}, {
css: {
marginTop: "32rpx",
fontSize: "26rpx",
color: "#8c5400"
},
views: [{
text: "自营",
type: "text",
css: {
color: "#212121",
background: "#ffb400"
},
}, {
text: "30天最低价",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9",
textDecoration: "line-through"
},
}, {
text: "满减优惠",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9"
},
}, {
text: "超高好评",
type: "text",
css: {
marginLeft: "16rpx",
background: "#fff4d9"
},
}],
type: "view"
}, {
css: {
marginTop: "30rpx"
},
views: [
{
text: "360儿童电话手表9X 智能语音问答定位支付手表 4G全网通20米游泳级防水视频通话拍照手表男女孩星空蓝",
type: "text",
css: {
paddingRight: "32rpx",
boxSizing: "border-box",
lineClamp: 2,
color: "#333333",
lineHeight: "1.8em",
fontSize: "36rpx",
width: "478rpx"
},
}, {
text: "limeui.qcoon.cn",
type: "qrcode",
css: {
width: "128rpx",
height: "128rpx",
},
}],
type: "view"
}],
type: "view"
}
]
}
}
}
```
### 自定义字体
- 需要平台的支持,已知微信小程序支持,其它的没试过,如果可行请告之
```
// 需要在app.vue中下载字体
uni.loadFontFace({
global:true,
scopes: ['native'],
family: '自定义字体名称',
source: 'url("https://sungd.github.io/Pacifico.ttf")',
success() {
console.log('success')
}
})
// 然后就可以在插件的css中写font-family: '自定义字体名称'
```
### Nvue
- 必须为HBX 3.4.11及以上
### 原生小程序
- 插件里的`painter.js`支持在原生小程序中使用
- new Painter 之后在`source`里传入 JSON
- 再调用`render`绘制海报
- 如需生成图片,请查看微信小程序 cavnas 的[canvasToTempFilePath](https://developers.weixin.qq.com/miniprogram/dev/api/canvas/wx.canvasToTempFilePath.html)
```html
<canvas type="2d" id="painter" style="width: 100%"></canvas>
```
```js
import { Painter } from "./painter";
page({
data: {
poster: {
css: {
width: "750rpx",
},
views: [
{
type: "view",
css: {
background: "#d2d4c8",
paddingTop: "100rpx",
},
views: [
{
type: "view",
css: {
background: "#5f7470",
width: "33.33%",
height: "100rpx",
display: "inline-block",
},
},
{
type: "view",
css: {
background: "#889696",
width: "33.33%",
height: "100rpx",
display: "inline-block",
},
},
{
type: "view",
css: {
background: "#b8bdb5",
width: "33.33%",
height: "100rpx",
display: "inline-block",
},
},
],
},
],
},
},
async onLoad() {
const res = await this.getCentext();
const painter = new Painter(res);
// 返回计算布局后的整个内容尺寸
const { width, height } = await painter.source(this.data.poster);
// 得到计算后的尺寸后 可给canvas尺寸赋值,达到动态响应效果
// 渲染
await painter.render();
},
// 获取canvas 2d
// 非2d 需要传一个 createImage 方法用于获取图片信息 即把 getImageInfo 的 success 通过 promise resolve 返回
getCentext() {
return new Promise((resolve) => {
wx.createSelectorQuery()
.select(`#painter`)
.node()
.exec((res) => {
let { node: canvas } = res[0];
resolve({
canvas,
context: canvas.getContext("2d"),
width: canvas.width,
height: canvas.height,
// createImage: getImageInfo()
pixelRatio: 2,
});
});
});
},
});
```
### 旧版(1.6.x)更新
- 由于 1.8.x 版放弃了以定位的方式,所以 1.6.x 版更新之后要每个样式都加上`position: absolute`
- 旧版的 `image` mode 模式被放弃,使用`object-fit`
- 旧版的 `isRenderImage` 改成 `is-canvas-to-temp-file-path`
- 旧版的 `maxLines` 改成 `line-clamp`
## API
### Props
| 参数 | 说明 | 类型 | 默认值 |
| -------------------------- | ------------------------------------------------------------ | ---------------- | ------------ |
| board | JSON 方式的海报元素对象集 | <em>object</em> | - |
| css | 海报内容最外层的样式,可以理解为`body` | <em>object</em> | 参数请向下看 |
| custom-style | canvas 元素的样式 | <em>string</em> | |
| hidden | 隐藏画板 | <em>boolean</em> | `false` |
| is-canvas-to-temp-file-path | 是否生成图片,在`@success`事件接收图片地址 | <em>boolean</em> | `false` |
| after-delay | 生成图片错乱,可延时生成图片 | <em>number</em> | `100` |
| type | canvas 类型,对微信头条支付宝小程序可有效,可选值:`2d`,`''` | <em>string</em> | `2d` |
| file-type | 生成图片的后缀类型, 可选值:`png`、`jpg` | <em>string</em> | `png` |
| path-type | 生成图片路径类型,可选值`url`、`base64` | <em>string</em> | `-` |
| pixel-ratio | 生成图片的像素密度,默认为对应手机的像素密度,`nvue`无效 | <em>number</em> | `-` |
| hidpi | H5和APP是否使用高清处理 | <em>boolean</em> | `true` |
| width | **废弃** 画板的宽度,一般只用于通过内部方法时加上 | <em>number</em> | `` |
| height | **废弃** 画板的高度 ,同上 | <em>number</em> | `` |
### css
| 属性名 | 支持的值或类型 | 默认值 |
| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| (min\max)width | 支持`%`、`rpx`、`px` | - |
| height | 同上 | - |
| color | `string` | - |
| position | 定位,可选值:`absolute`、`fixed` | - |
| ↳ left、top、right、bottom | 配合`position`才生效,支持`%`、`rpx`、`px` | - |
| margin | 可简写或各方向分别写,如:`margin-top`,支持`auto`、`rpx`、`px` | - |
| padding | 可简写或各方向分别写,支持`rpx`、`px` | - |
| border | 可简写或各个值分开写:`border-width`、`border-style` 、`border-color`,简写请按顺序写 | - |
| line-clamp | `number`,超过行数显示省略号 | - |
| vertical-align | 文字垂直对齐,可选值:`bottom`、`top`、`middle` | `middle` |
| line-height | 文字行高,支持`rpx`、`px`、`em` | `1.4em` |
| font-weight | 文字粗细,可选值:`normal`、`bold` | `normal` |
| font-size | 文字大小,`string`,支持`rpx`、`px` | `14px` |
| text-decoration | 文本修饰,可选值:`underline` 、`line-through`、`overline` | - |
| text-stroke | 文字描边,可简写或各个值分开写,如:`text-stroke-color`, `text-stroke-width` | - |
| text-align | 文本水平对齐,可选值:`right` 、`center` | `left` |
| display | 框类型,可选值:`block`、`inline-block`、`flex`、`none`,当为`none`时是不渲染该段, `flex`功能简陋。 | - |
| flex | 配合 display: flex; 属性定义了在分配多余空间,目前只用为数值如: flex: 1 | - |
| align-self | 配合 display: flex; 单个项目垂直轴对齐方式: `flex-start` `flex-end` `center` | `flex-start` |
| justify-content | 配合 display: flex; 水平轴对齐方式: `flex-start` `flex-end` `center` | `flex-start` |
| align-items | 配合 display: flex; 垂直轴对齐方式: `flex-start` `flex-end` `center` | `flex-start` |
| border-radius | 圆角边框,支持`%`、`rpx`、`px` | - |
| box-sizing | 可选值:`border-box` | - |
| box-shadow | 投影 | - |
| background(color) | 支持渐变,但必须写百分比!如:`linear-gradient(,#ff971b 0%, #ff5000 100%)`、`radial-gradient(#0ff 15%, #f0f 60%)`,目前 radial-gradient 渐变的圆心为元素中点,半径为最长边,不支持设置 | - |
| background-clip | 文字渐变,配合`background`背景渐变,设置`background-clip: text` 达到文字渐变效果 | - |
| background-image | view 元素背景:`url(src)`,若只是设置背景图,请不要设置`background-repeat` | - |
| background-repeat | 设置是否及如何重复背景纹理,可选值:`repeat`、`repeat-x`、`repeat-y`、`no-repeat` | `repeat` |
| [object-fit](https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-fit/) | 图片元素适应容器方式,类似于`mode`,可选值:`cover`、 `contain`、 `fill`、 `none` | - |
| [object-position](https://developer.mozilla.org/zh-CN/docs/Web/CSS/object-position) | 图片的对齐方式,配合`object-fit`使用 | - |
### 图片填充模式 object-fit
| 名称 | 含义 |
| ------- | ------------------------------------------------------ |
| contain | 保持宽高缩放图片,使图片的长边能完全显示出来 |
| cover | 保持宽高缩放图片,使图片的短边能完全显示出来,裁剪长边 |
| fill | 拉伸图片,使图片填满元素 |
| none | 保持图片原有尺寸 |
### 事件 Events
| 事件名 | 说明 | 返回值 |
| -------- | ---------------------------------------------------------------- | ------ |
| success | 生成图片成功,若使用`is-canvas-to-temp-filePath` 可以接收图片地址 | path |
| fail | 生成图片失败 | error |
| done | 绘制成功 | |
| progress | 绘制进度 | number |
### 暴露函数 Expose
| 事件名 | 说明 | 返回值 |
| -------- | ---------------------------------------------------------------- | ------ |
| render(object) | 渲染器,传入JSON 绘制海报 | promise |
| [canvasToTempFilePath](https://uniapp.dcloud.io/api/canvas/canvasToTempFilePath.html#canvastotempfilepath)(object) | 把当前画布指定区域的内容导出生成指定大小的图片,并返回文件临时路径。 | |
| canvasToTempFilePathSync(object) | 同步接口,同上 | |
## 常见问题
- 1、H5 端使用网络图片需要解决跨域问题。
- 2、小程序使用网络图片需要去公众平台增加下载白名单!二级域名也需要配!
- 3、H5 端生成图片是 base64,有时显示只有一半可以使用原生标签`<IMG/>`
- 4、发生保存图片倾斜变形或提示 native buffer exceed size limit 时,使用 pixel-ratio="2"参数,降分辨率。
- 5、h5 保存图片不需要调接口,提示用户长按图片保存。
- 6、画板不能隐藏,包括`v-if`,`v-show`、`display:none`、`opacity:0`,另外也不要把画板放在弹窗里。如果需要隐藏画板请设置 `custom-style="position: fixed; left: 200%"`
- 7、微信小程序真机调试请使用 **真机调试2.0**,不支持1.0。
- 8、微信小程序打开调试时可以生但并闭无法生成时,这种情况一般是没有在公众号配置download域名
- 9、HBX 3.4.5之前的版本不支持vue3
- 10、在微信开发工具上 canvas 层级最高无法zindex,并不影响真机
- 11、请不要导入非uni_modules插件
- 12、关于QQ小程序 报 Propertyor method"toJSON"is not defined 请把基础库调到 1.50.3
- 13、支付宝小程序 IDE 不支持 生成图片 请以真机调试结果为准
- 14、返回值为字符串 `data:,` 大概是尺寸超过限制,设置 pixel-ratio="2"
- 华为手机 APP 上无法生成图片,请使用 HBX2.9.11++(已过时,忽略这条)
- IOS APP 请勿使用 HBX2.9.3.20201014 的版本!这个版本无法生成图片。(已过时,忽略这条)
- 苹果微信 7.0.20 存在闪退和图片无法 onload 为微信 bug(已过时,忽略这条)
- 微信小程序 IOS 旧接口 如父级设置圆角,子级也设会导致子级的失效,为旧接口BUG。
- 微信小程序 安卓 旧接口 如使用图片必须加背景色,为旧接口BUG。
- 微信小程序 安卓端 [图片可能在首次可以加载成功,再次加载会不触发任何事件](https://developers.weixin.qq.com/community/develop/doc/000ee2b8dacf4009337f51f4556800?highLine=canvas%25202d%2520createImage),临时解决方法是给图片加个时间戳
## 打赏
如果你觉得本插件,解决了你的问题,赠人玫瑰,手留余香。
![](https://testingcf.jsdelivr.net/gh/liangei/image@1.9/alipay.png)
![](https://testingcf.jsdelivr.net/gh/liangei/image@1.9/wpay.png)
+74
View File
@@ -0,0 +1,74 @@
## 2.7.11(2024-06-28)
1.`新增` 方法`updateVirtualListRender`,支持手动触发虚拟列表渲染更新。
2.`新增` 延迟加载列表演示。
3.`修复` v2.7.8引出的vue3+npm+微信小程序中,`uni.$zp`配置失效的问题。
4.`修复` 在本地分页+虚拟列表情况下,虚拟列表cell未被正常销毁的问题。
5.`修复` 打开调试模式下无法获取getApp导致的`cannot read property 'zp_handleQueryCallback' of undefined`的报错。
6.`修复` 极小概率出现的分页请求较快且快速滚动到底部时未能加载更多的问题。
## 2.7.10(2024-05-10)
1.`修复` v2.7.8引出的vue3+npm+微信小程序中,uni.$zp配置失效的问题。
## 2.7.9(2024-05-10)
1.`修复` 在新版HbuilderX+vue3+微信小程序中可能出现的加载第二页数据后返回顶部无法下拉的问题。
2.`修复` 在vue3+抖音小程序中可能出现的首次加载reload没有触发的问题。
3.`优化` ts类型中,`ZPagingInstance`泛型不必填,默认为`any`。
## 2.7.8(2024-05-09)
1.`新增` typescript类型声明文件,感谢小何同学提供。
2.`新增` 添加极简写法fetch相关配置及示例。
3.`新增` `fixed-cellHeight`配置,支持在虚拟列表中自定义固定的cell高度。
4.`新增` `refresher-refreshing-scrollable`配置,支持自定义下拉刷新中是否允许列表滚动。
5.`修复` 在新版HubilderX+vue3+h5中,`swiper-demo`模式`slot=top`被导航栏遮挡的问题。
6.`修复` 下拉进入二楼偶现的二楼高度未铺满全屏的问题。
7.`修复` 虚拟列表中complete若传相同对象会导致key重复的问题。
8.`修复` 在虚拟列表中调用refresh后缓存高度原始数据未清空的问题。
9.`修复` 聊天记录模式删除记录时顶部显示loading的问题。
9.`优化` `scrollIntoViewByIndex`支持在虚拟列表中滚动到指定cell。
10.`优化` `content-z-index`默认值修改为1。
## 2.7.7(2024-04-01)
1.`新增` 下拉进入二楼功能及相关配置&demo。
2.`新增` 虚拟列表写法添加【非内置列表】写法,可良好兼容vue3中的各平台并有较优的性能表现。
3.`新增` `z-paging-cell`补充`@touchstart`事件。
4.`修复` 页面滚动模式设置了`auto-full-height`后可能出现的依然有异常空白占位的问题和下拉刷新时列表数据被切割的问题。
## 2.7.6(2024-02-29)
1.`新增` `max-width`,支持设置`z-paging`的最大宽度,默认`z-paging`宽度铺满窗口。
2.`新增` `chat-adjust-position-offset`,支持设置使用聊天记录模式中键盘弹出时占位高度偏移距离。
3.`修复` 由于renderjs中聊天记录模式判断不准确导致的可能出现的从聊天记录页面跳转到其他页面后返回页面无法滚动的问题。
4.`修复` 聊天记录模式首次加载失败后,发送消息顶部会显示加载失败文字的问题。
5.`修复` 聊天记录模式nvue可能出现的键盘弹出无法滚动到底部的问题。
6.`修复` 聊天记录模式+nvue滚动条无法展示的问题&底部会显示加载中的问题。
7.`修复` 聊天记录模式监听键盘弹出可能出现的无法正常销毁的问题。
8.`修复` 直接修改dataList的值组件内部的值未更新的问题。
## 2.7.5(2024-01-23)
1.`新增` props:`chat-loading-more-default-as-loading`,支持设置在聊天记录模式中滑动到顶部状态为默认状态时,以加载中的状态展示。
2.`新增` slots:`chatNoMore`,支持自定义聊天记录模式没有更多数据view。
3.`修复` 固定在底部view可能出现默认黄色的问题。
4.`优化` 聊天记录加载更多样式,与普通模式对齐,支持点击加载更多&点击重试,并支持加载更多相关配置。
5.`优化` 微调下拉刷新和底部加载更多样式。
6.`优化` 聊天记录模式自动滚动到底部添加延时以避免可能出现的滚动到底部位置不正确的问题。
7.`优化` 使用新的判断滚动到顶部算法以解决在安卓设备中可能出现的因滚动到顶部时scrollTop不为0导致的下拉刷新被禁用的问题。
## 2.7.4(2024-01-14)
1.`新增` props:`auto-adjust-position-when-chat`,支持设置使用聊天记录模式中键盘弹出时是否自动调整slot="bottom"高度。
2.`新增` props:`auto-to-bottom-when-chat`,支持设置使用聊天记录模式中键盘弹出时是否自动滚动到底部。
3.`新增` props:`show-chat-loading-when-reload`,支持设置使用聊天记录模式中reload时是否显示chatLoading。
4.`修复` 在聊天记录模式中`scrollIntoViewById`和`scrollIntoViewByNodeTop`无效的问题。
5.`优化` 聊天记录模式底部安全区域针对键盘开启/关闭兼容处理。
6.`优化` 更新内置的空数据图&加载失败图,感谢图鸟UI提供的免费可商用的空数据图和加载失败图!
## 2.7.3(2024-01-10)
1.`新增` 聊天记录模式支持虚拟列表&添加相关demo。
2.`新增` nvue中list添加`@scrollend`监听。
3.`优化` 聊天记录模式+vue第一页并且没有更多时不倒置列表。
4.`优化` 聊天记录模式+nvue中数据不满屏时默认从顶部开始,不进行列表倒置。
## 2.7.2(2024-01-09)
1.`修复` `vue3+h5`中报错`uni.onKeyboardHeightChange is not a function`的问题。
2.`优化` 聊天记录模式细节:表情面板在触摸列表时隐藏&添加隐藏动画。
## 2.7.1(2024-01-08)
1.`新增` `keyboardHeightChange` event,支持监听键盘高度改变。
2.`新增` 聊天记录模式新增切换表情面板/键盘demo。
3.`优化` 键盘弹出占位添加动画效果。
## 2.7.0(2024-01-07)
2024新年快乐!!祝大家在新的一年里工作顺利,事事顺心!
1.`新增` 全新的聊天记录模式设计!将vue中的聊天记录模式与nvue中对齐,完全解决了聊天记录模式滚动到顶部加载更多在vue中抖动的问题,同时将聊天记录模式键盘自动弹出自动上推页面交由`z-paging`处理,解决了由此引发的各种问题,尤其是在微信小程序中导航栏被键盘顶出屏幕外的问题。如果您使用了`z-paging`的聊天记录模式,强烈建议更新,写法有一定变更,具体请参见demo。
2.`新增` `swiper-demo`新增`onShow`时候调用reload演示。
3.`修复` 修复滚动相关方法在微信小程序中首次滚动动画无效的问题。
4.`修复` props设置单位,单位为px时报错的问题。
5.`修复` 在某些情况下`z-paging`加载了但是未渲染时,reload无效的问题。
6.`修复` 底部loading动画未生效的问题。
@@ -0,0 +1,39 @@
<!-- 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>
export default {
name: "z-paging-cell",
props: {
//cellStyle
cellStyle: {
type: Object,
default: function() {
return {}
}
}
},
methods: {
onTouchstart(e) {
this.$emit('touchstart', e);
}
}
}
</script>
@@ -0,0 +1,189 @@
<!-- 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'
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
},
// 空数据图片是否使用fixed布局并铺满z-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>
@@ -0,0 +1,143 @@
<!-- 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'
export default {
name: "z-paging-swiper-item",
components: { zPaging },
data() {
return {
firstLoaded: false
}
},
props: {
// 当前组件的index,也就是当前组件是swiper中的第几个
tabIndex: {
type: Number,
default: function() {
return 0
}
},
// 当前swiper切换到第几个index
currentIndex: {
type: Number,
default: function() {
return 0
}
},
// 是否使用虚拟列表,默认为否
useVirtualList: {
type: Boolean,
default: false
},
// 是否在z-paging内部循环渲染列表(内置列表),默认为否。若use-virtual-list为true,则此项恒为true
useInnerList: {
type: Boolean,
default: false
},
// 内置列表cell的key名称,仅nvue有效,在nvue中开启use-inner-list时必须填此项
cellKeyName: {
type: String,
default: ''
},
// innerList样式
innerListStyle: {
type: Object,
default: function() {
return {};
}
},
// 预加载的列表可视范围(列表高度)页数,默认为12,即预加载当前页及上下各12页的cell。此数值越大,则虚拟列表中加载的dom越多,内存消耗越大(会维持在一个稳定值),但增加预加载页面数量可缓解快速滚动短暂白屏问题
preloadPage: {
type: [Number, String],
default: 12
},
// 虚拟列表cell高度模式,默认为fixed,也就是每个cell高度完全相同,将以第一个cell高度为准进行计算。可选值【dynamic】,即代表高度是动态非固定的,【dynamic】性能低于【fixed】。
cellHeightMode: {
type: String,
default: 'fixed'
},
// 虚拟列表列数,默认为1。常用于每行有多列的情况,例如每行有2列数据,需要将此值设置为2
virtualListCol: {
type: [Number, String],
default: 1
},
// 虚拟列表scroll取样帧率,默认为60,过高可能出现卡顿等问题
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>
@@ -0,0 +1,167 @@
<!-- 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'
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 = uni.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>
@@ -0,0 +1,178 @@
<!-- [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() {
const statusTextArr = [this.c.defaultText,this.c.loadingText,this.c.noMoreText,this.c.failText];
return statusTextArr[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>
@@ -0,0 +1,208 @@
<!-- [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,
isIos: uni.getSystemInfoSync().platform === 'ios',
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'
],
computed: {
ts() {
return this.defaultThemeStyle;
},
// 当前状态数组
statusTextArr() {
this.updateTime();
return [this.defaultText, this.pullingText, this.refreshingText, this.completeText, this.goF2Text];
},
// 当前状态文字
currentTitle() {
return this.statusTextArr[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>
@@ -0,0 +1,3 @@
// z-paging全局配置文件,注意避免更新时此文件被覆盖,若被覆盖,可在此文件中右键->点击本地历史记录,找回覆盖前的配置
export default {}
@@ -0,0 +1,237 @@
/* [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-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;
}
@@ -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 */
@@ -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..."
}
@@ -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
}
@@ -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": "加载中..."
}
@@ -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": "加載中..."
}
@@ -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
@@ -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
@@ -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.scrollToTop(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)
}
}
},
}
}
@@ -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);
})
})
}
}
}
}
@@ -0,0 +1,141 @@
// [z-paging]通用布局相关模块
// #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
// 感谢litangyu!!https://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 = uni.getSystemInfoSync().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 = uni.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
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();
});
}
}
}
@@ -0,0 +1,738 @@
// [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, String],
default: u.gc('defaultPageNo', 1),
observer: function(newVal) {
this.pageNo = newVal;
},
},
// 自定义pageSize,默认为10
defaultPageSize: {
type: [Number, String],
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 Found:Page,因为这时候去获取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;
}
if (!this.isFirstPage) {
this.listRendering = true;
this.$nextTick(() => {
u.delay(() => this.listRendering = false);
})
} else {
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 => {
this.completeByTotal(res, this.totalLocalPagingList.length);
})
} 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.LoadingMore) {
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 };
},
}
}
@@ -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');
},
}
}
@@ -0,0 +1,119 @@
// [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'
const language = uni.getSystemInfoSync().language;
export default {
data() {
return {
language
}
},
computed: {
finalLanguage() {
try {
const local = uni.getLocale();
const language = this.language;
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;
}
}
}
@@ -0,0 +1,370 @@
// [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');
}
},
methods: {
// 页面滚动到底部时通知z-paging进行进一步处理
pageReachBottom() {
!this.useChatRecordMode && 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-滑动到底部触发;1、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.$emit('scrolltolower', from);
// 如果是只使用下拉刷新 或者 禁用底部加载更多 或者 底部加载更多不是默认状态或加载失败状态 或者 是加载中状态 或者 空数据图已经展示了,则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.LoadingMore;
})
} else {
// emit @query相关加载更多事件
this._emitQuery(this.pageNo, this.defaultPageSize, Enum.QueryFrom.LoadingMore);
this._callMyParentQuery();
}
// 设置当前加载状态为底部加载更多状态
this.loadingType = Enum.LoadingType.LoadingMore;
}
},
// (预处理)判断当没有更多数据且分页内容未超出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;
},
}
}
@@ -0,0 +1,95 @@
// [z-paging]loading相关模块
import u from '.././z-paging-utils'
import Enum from '.././z-paging-enum'
export default {
props: {
// 第一次加载后自动隐藏loading slot,默认为是
autoHideLoadingAfterFirstLoaded: {
type: Boolean,
default: u.gc('autoHideLoadingAfterFirstLoaded', true)
},
// loading slot是否铺满屏幕并固定,默认为否
loadingFullFixed: {
type: Boolean,
default: u.gc('loadingFullFixed', false)
},
// 是否自动显示系统Loading:即uni.showLoading,若开启则将在刷新列表时(调用reload、refresh时)显示,下拉刷新和滚动到底部加载更多不会显示,默认为false。
autoShowSystemLoading: {
type: Boolean,
default: u.gc('autoShowSystemLoading', false)
},
// 显示系统Loading时是否显示透明蒙层,防止触摸穿透,默认为是(H5、App、微信小程序、百度小程序有效)
systemLoadingMask: {
type: Boolean,
default: u.gc('systemLoadingMask', true)
},
// 显示系统Loading时显示的文字,默认为"加载中"
systemLoadingText: {
type: [String, Object],
default: u.gc('systemLoadingText', null)
},
},
data() {
return {
loading: false,
loadingForNow: false,
}
},
watch: {
// loading状态
loadingStatus(newVal) {
this.$emit('loadingStatusChange', newVal);
this.$nextTick(() => {
this.loadingStatusAfterRender = newVal;
})
if (this.useChatRecordMode) {
if (this.isFirstPage && (newVal === Enum.More.NoMore || newVal === Enum.More.Fail)) {
this.isFirstPageAndNoMore = true;
return;
}
}
this.isFirstPageAndNoMore = false;
},
loading(newVal){
if (newVal) {
this.loadingForNow = newVal;
}
},
},
computed: {
// 是否显示loading
showLoading() {
if (this.firstPageLoaded || !this.loading || !this.loadingForNow) return false;
if (this.finalShowSystemLoading) {
// 显示系统loading
uni.showLoading({
title: this.finalSystemLoadingText,
mask: this.systemLoadingMask
})
}
return this.autoHideLoadingAfterFirstLoaded ? (this.fromEmptyViewReload ? true : !this.pagingLoaded) : this.loadingType === Enum.LoadingType.Refresher;
},
// 最终的是否显示系统loading
finalShowSystemLoading() {
return this.autoShowSystemLoading && this.loadingType === Enum.LoadingType.Refresher;
}
},
methods: {
// 处理开始加载更多状态
_startLoading(isReload = false) {
if ((this.showLoadingMoreWhenReload && !this.isUserPullDown) || !isReload) {
this.loadingStatus = Enum.More.Loading;
}
this.loading = true;
},
// 停止系统loading和refresh
_endSystemLoadingAndRefresh(){
this.finalShowSystemLoading && uni.hideLoading();
!this.useCustomRefresher && uni.stopPullDownRefresh();
// #ifdef APP-NVUE
this.usePageScroll && uni.stopPullDownRefresh();
// #endif
}
}
}
@@ -0,0 +1,255 @@
// [z-paging]nvue独有部分模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
// #ifdef APP-NVUE
const weexAnimation = weex.requireModule('animation');
// #endif
export default {
props: {
// #ifdef APP-NVUE
// nvue中修改列表类型,可选值有list、waterfall和scroller,默认为list
nvueListIs: {
type: String,
default: u.gc('nvueListIs', 'list')
},
// nvue waterfall配置,仅在nvue中且nvueListIs=waterfall时有效,配置参数详情参见:https://uniapp.dcloud.io/component/waterfall
nvueWaterfallConfig: {
type: Object,
default: u.gc('nvueWaterfallConfig', {})
},
// nvue 控制是否回弹效果,iOS不支持动态修改
nvueBounce: {
type: Boolean,
default: u.gc('nvueBounce', true)
},
// nvue中通过代码滚动到顶部/底部时,是否加快动画效果(无滚动动画时无效),默认为否
nvueFastScroll: {
type: Boolean,
default: u.gc('nvueFastScroll', false)
},
// nvue中list的id
nvueListId: {
type: String,
default: u.gc('nvueListId', '')
},
// nvue中refresh组件的样式
nvueRefresherStyle: {
type: Object,
default: u.gc('nvueRefresherStyle', {})
},
// nvue中是否按分页模式(类似竖向swiper)显示List,默认为false
nvuePagingEnabled: {
type: Boolean,
default: u.gc('nvuePagingEnabled', false)
},
// 是否隐藏nvue列表底部的tagView,此view用于标识滚动到底部位置,若隐藏则滚动到底部功能将失效,在nvue中实现吸顶+swiper功能时需将最外层z-paging的此属性设置为true。默认为否
hideNvueBottomTag: {
type: Boolean,
default: u.gc('hideNvueBottomTag', false)
},
// nvue中控制onscroll事件触发的频率:表示两次onscroll事件之间列表至少滚动了10px。注意,将该值设置为较小的数值会提高滚动事件采样的精度,但同时也会降低页面的性能
offsetAccuracy: {
type: Number,
default: u.gc('offsetAccuracy', 10)
},
// #endif
},
data() {
return {
nRefresherLoading: false,
nListIsDragging: false,
nShowBottom: true,
nFixFreezing: false,
nShowRefresherReveal: false,
nLoadingMoreFixedHeight: false,
nShowRefresherRevealHeight: 0,
nOldShowRefresherRevealHeight: -1,
nRefresherWidth: uni.upx2px(750),
nF2Opacity: 0
}
},
computed: {
// #ifdef APP-NVUE
nScopedSlots() {
// #ifdef VUE2
return this.$scopedSlots;
// #endif
// #ifdef VUE3
return null;
// #endif
},
nWaterfallColumnCount() {
if (this.finalNvueListIs !== 'waterfall') return 0;
return this._nGetWaterfallConfig('column-count', 2);
},
nWaterfallColumnWidth() {
return this._nGetWaterfallConfig('column-width', 'auto');
},
nWaterfallColumnGap() {
return this._nGetWaterfallConfig('column-gap', 'normal');
},
nWaterfallLeftGap() {
return this._nGetWaterfallConfig('left-gap', 0);
},
nWaterfallRightGap() {
return this._nGetWaterfallConfig('right-gap', 0);
},
nViewIs() {
const is = this.finalNvueListIs;
return is === 'scroller' || is === 'view' ? 'view' : is === 'waterfall' ? 'header' : 'cell';
},
nSafeAreaBottomHeight() {
return this.safeAreaInsetBottom ? this.safeAreaBottom : 0;
},
finalNvueListIs() {
if (this.usePageScroll) return 'view';
const nvueListIsLowerCase = this.nvueListIs.toLowerCase();
if (['list','waterfall','scroller'].indexOf(nvueListIsLowerCase) !== -1) return nvueListIsLowerCase;
return 'list';
},
finalNvueSuperListIs() {
return this.usePageScroll ? 'view' : 'scroller';
},
finalNvueRefresherEnabled() {
return this.finalNvueListIs !== 'view' && this.finalRefresherEnabled && !this.nShowRefresherReveal && !this.useChatRecordMode;
},
// #endif
},
mounted(){
// #ifdef APP-NVUE
//旋转屏幕时更新宽度
uni.onWindowResize((res) => {
// this._nUpdateRefresherWidth();
})
// #endif
},
methods: {
// #ifdef APP-NVUE
// 列表滚动时触发
_nOnScroll(e) {
this.$emit('scroll', e);
const contentOffsetY = -e.contentOffset.y;
this.oldScrollTop = contentOffsetY;
this.nListIsDragging = e.isDragging;
this._checkShouldShowBackToTop(contentOffsetY, contentOffsetY - 1);
},
// 列表滚动结束
_nOnScrollend(e) {
this.$emit('scrollend', e);
},
// 下拉刷新刷新中
_nOnRrefresh() {
if (this.nShowRefresherReveal) return;
// 进入刷新状态
this.nRefresherLoading = true;
if (this.refresherStatus === Enum.Refresher.GoF2) {
this._handleGoF2();
this.$nextTick(() => {
this._nRefresherEnd();
})
} else {
this.refresherStatus = Enum.Refresher.Loading;
this._doRefresherLoad();
}
},
// 下拉刷新下拉中
_nOnPullingdown(e) {
if (this.refresherStatus === Enum.Refresher.Loading || (this.isIos && !this.nListIsDragging)) return;
this._emitTouchmove(e);
let { viewHeight, pullingDistance } = e;
// 更新下拉刷新状态
// 下拉刷新距离超过阈值
if (pullingDistance >= viewHeight) {
// 如果开启了下拉进入二楼并且下拉刷新距离超过进入二楼阈值,则当前下拉刷新状态为松手进入二楼,否则为松手立即刷新
// (pullingDistance - viewHeight) + this.finalRefresherThreshold 不等同于pullingDistance,此处是为了兼容不同平台下拉相同距离pullingDistance不一致的问题,pullingDistance仅与viewHeight互相关联
this.refresherStatus = this.refresherF2Enabled && (pullingDistance - viewHeight) + this.finalRefresherThreshold >= this.finalRefresherF2Threshold ? Enum.Refresher.GoF2 : Enum.Refresher.ReleaseToRefresh;
} else {
// 下拉刷新距离未超过阈值,显示默认状态
this.refresherStatus = Enum.Refresher.Default;
}
},
// 下拉刷新结束
_nRefresherEnd(doEnd = true) {
if (doEnd) {
this._nDoRefresherEndAnimation(0, -this.nShowRefresherRevealHeight);
!this.usePageScroll && this.$refs['zp-n-list'].resetLoadmore();
this.nRefresherLoading = false;
}
},
// 执行主动触发下拉刷新动画
_nDoRefresherEndAnimation(height, translateY, animate = true, checkStack = true) {
// 清除下拉刷新相关timeout
this._cleanRefresherCompleteTimeout();
this._cleanRefresherEndTimeout();
if (!this.finalShowRefresherWhenReload) {
// 如果reload不需要自动展示下拉刷新view,则在complete duration结束后再把下拉刷新状态设置回默认
this.refresherEndTimeout = u.delay(() => {
this.refresherStatus = Enum.Refresher.Default;
}, this.refresherCompleteDuration);
return;
}
// 用户处理用户在短时间内多次调用reload的情况,此时下拉刷新view不需要重复显示,只需要保证最后一次reload对应的请求结束后收回下拉刷新view即可
const stackCount = this.refresherRevealStackCount;
if (height === 0 && checkStack) {
this.refresherRevealStackCount --;
if (stackCount > 1) return;
this.refresherEndTimeout = u.delay(() => {
this.refresherStatus = Enum.Refresher.Default;
}, this.refresherCompleteDuration);
}
if (stackCount > 1) {
this.refresherStatus = Enum.Refresher.Loading;
}
const duration = animate ? 200 : 0;
if (this.nOldShowRefresherRevealHeight !== height) {
if (height > 0) {
this.nShowRefresherReveal = true;
}
// 展示下拉刷新view
weexAnimation.transition(this.$refs['zp-n-list-refresher-reveal'], {
styles: {
height: `${height}px`,
transform: `translateY(${translateY}px)`,
},
duration,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
}
u.delay(() => {
if (animate) {
this.nShowRefresherReveal = height > 0;
}
}, duration > 0 ? duration - 60 : 0);
this.nOldShowRefresherRevealHeight = height;
},
// 滚动到底部加载更多
_nOnLoadmore() {
if (this.nShowRefresherReveal || !this.totalData.length) return;
this.useChatRecordMode ? this.doChatRecordLoadMore() : this._onLoadingMore('toBottom');
},
// 获取nvue waterfall单项配置
_nGetWaterfallConfig(key, defaultValue) {
return this.nvueWaterfallConfig[key] || defaultValue;
},
// 更新nvue 下拉刷新view容器的宽度
_nUpdateRefresherWidth() {
u.delay(() => {
this.$nextTick(()=>{
this._getNodeClientRect('.zp-n-list').then(node => {
if (node) {
this.nRefresherWidth = node[0].width || this.nRefresherWidth;
}
})
})
})
}
// #endif
}
}
@@ -0,0 +1,824 @@
// [z-paging]下拉刷新view模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
// #ifdef APP-NVUE
const weexAnimation = weex.requireModule('animation');
// #endif
export default {
props: {
// 下拉刷新的主题样式,支持black,white,默认black
refresherThemeStyle: {
type: String,
default: u.gc('refresherThemeStyle', '')
},
// 自定义下拉刷新中左侧图标的样式
refresherImgStyle: {
type: Object,
default: u.gc('refresherImgStyle', {})
},
// 自定义下拉刷新中右侧状态描述文字的样式
refresherTitleStyle: {
type: Object,
default: u.gc('refresherTitleStyle', {})
},
// 自定义下拉刷新中右侧最后更新时间文字的样式(show-refresher-update-time为true时有效)
refresherUpdateTimeStyle: {
type: Object,
default: u.gc('refresherUpdateTimeStyle', {})
},
// 在微信小程序和QQ小程序中,是否实时监听下拉刷新中进度,默认为否
watchRefresherTouchmove: {
type: Boolean,
default: u.gc('watchRefresherTouchmove', false)
},
// 底部加载更多的主题样式,支持black,white,默认black
loadingMoreThemeStyle: {
type: String,
default: u.gc('loadingMoreThemeStyle', '')
},
// 是否只使用下拉刷新,设置为true后将关闭mounted自动请求数据、关闭滚动到底部加载更多,强制隐藏空数据图。默认为否
refresherOnly: {
type: Boolean,
default: u.gc('refresherOnly', false)
},
// 自定义下拉刷新默认状态下回弹动画时间,单位为毫秒,默认为100毫秒,nvue无效
refresherDefaultDuration: {
type: [Number, String],
default: u.gc('refresherDefaultDuration', 100)
},
// 自定义下拉刷新结束以后延迟回弹的时间,单位为毫秒,默认为0
refresherCompleteDelay: {
type: [Number, String],
default: u.gc('refresherCompleteDelay', 0)
},
// 自定义下拉刷新结束回弹动画时间,单位为毫秒,默认为300毫秒(refresherEndBounceEnabled为false时,refresherCompleteDuration为设定值的1/3),nvue无效
refresherCompleteDuration: {
type: [Number, String],
default: u.gc('refresherCompleteDuration', 300)
},
// 自定义下拉刷新中是否允许列表滚动,默认为是
refresherRefreshingScrollable: {
type: Boolean,
default: u.gc('refresherRefreshingScrollable', true)
},
// 自定义下拉刷新结束状态下是否允许列表滚动,默认为否
refresherCompleteScrollable: {
type: Boolean,
default: u.gc('refresherCompleteScrollable', false)
},
// 是否使用自定义的下拉刷新,默认为是,即使用z-paging的下拉刷新。设置为false即代表使用uni scroll-view自带的下拉刷新,h5、App、微信小程序以外的平台不支持uni scroll-view自带的下拉刷新
useCustomRefresher: {
type: Boolean,
default: u.gc('useCustomRefresher', true)
},
// 自定义下拉刷新下拉帧率,默认为40,过高可能会出现抖动问题
refresherFps: {
type: [Number, String],
default: u.gc('refresherFps', 40)
},
// 自定义下拉刷新允许触发的最大下拉角度,默认为40度,当下拉角度小于设定值时,自定义下拉刷新动画不会被触发
refresherMaxAngle: {
type: [Number, String],
default: u.gc('refresherMaxAngle', 40)
},
// 自定义下拉刷新的角度由未达到最大角度变到达到最大角度时,是否继续下拉刷新手势,默认为否
refresherAngleEnableChangeContinued: {
type: Boolean,
default: u.gc('refresherAngleEnableChangeContinued', false)
},
// 自定义下拉刷新默认状态下的文字
refresherDefaultText: {
type: [String, Object],
default: u.gc('refresherDefaultText', null)
},
// 自定义下拉刷新松手立即刷新状态下的文字
refresherPullingText: {
type: [String, Object],
default: u.gc('refresherPullingText', null)
},
// 自定义下拉刷新刷新中状态下的文字
refresherRefreshingText: {
type: [String, Object],
default: u.gc('refresherRefreshingText', null)
},
// 自定义下拉刷新刷新结束状态下的文字
refresherCompleteText: {
type: [String, Object],
default: u.gc('refresherCompleteText', null)
},
// 自定义继续下拉进入二楼文字
refresherGoF2Text: {
type: [String, Object],
default: u.gc('refresherGoF2Text', null)
},
// 自定义下拉刷新默认状态下的图片
refresherDefaultImg: {
type: String,
default: u.gc('refresherDefaultImg', null)
},
// 自定义下拉刷新松手立即刷新状态下的图片,默认与refresherDefaultImg一致
refresherPullingImg: {
type: String,
default: u.gc('refresherPullingImg', null)
},
// 自定义下拉刷新刷新中状态下的图片
refresherRefreshingImg: {
type: String,
default: u.gc('refresherRefreshingImg', null)
},
// 自定义下拉刷新刷新结束状态下的图片
refresherCompleteImg: {
type: String,
default: u.gc('refresherCompleteImg', null)
},
// 自定义下拉刷新刷新中状态下是否展示旋转动画
refresherRefreshingAnimated: {
type: Boolean,
default: u.gc('refresherRefreshingAnimated', true)
},
// 是否开启自定义下拉刷新刷新结束回弹效果,默认为是
refresherEndBounceEnabled: {
type: Boolean,
default: u.gc('refresherEndBounceEnabled', true)
},
// 是否开启自定义下拉刷新,默认为是
refresherEnabled: {
type: Boolean,
default: u.gc('refresherEnabled', true)
},
// 设置自定义下拉刷新阈值,默认为80rpx
refresherThreshold: {
type: [Number, String],
default: u.gc('refresherThreshold', '80rpx')
},
// 设置系统下拉刷新默认样式,支持设置 black,white,none,none 表示不使用默认样式,默认为black
refresherDefaultStyle: {
type: String,
default: u.gc('refresherDefaultStyle', 'black')
},
// 设置自定义下拉刷新区域背景
refresherBackground: {
type: String,
default: u.gc('refresherBackground', 'transparent')
},
// 设置固定的自定义下拉刷新区域背景
refresherFixedBackground: {
type: String,
default: u.gc('refresherFixedBackground', 'transparent')
},
// 设置固定的自定义下拉刷新区域高度,默认为0
refresherFixedBacHeight: {
type: [Number, String],
default: u.gc('refresherFixedBacHeight', 0)
},
// 设置自定义下拉刷新下拉超出阈值后继续下拉位移衰减的比例,范围0-1,值越大代表衰减越多。默认为0.65(nvue无效)
refresherOutRate: {
type: Number,
default: u.gc('refresherOutRate', 0.65)
},
// 是否开启下拉进入二楼功能,默认为否
refresherF2Enabled: {
type: Boolean,
default: u.gc('refresherF2Enabled', false)
},
// 下拉进入二楼阈值,默认为200rpx
refresherF2Threshold: {
type: [Number, String],
default: u.gc('refresherF2Threshold', '200rpx')
},
// 下拉进入二楼动画时间,单位为毫秒,默认为200毫秒
refresherF2Duration: {
type: [Number, String],
default: u.gc('refresherF2Duration', 200)
},
// 下拉进入二楼状态松手后是否弹出二楼,默认为是
showRefresherF2: {
type: Boolean,
default: u.gc('showRefresherF2', true)
},
// 设置自定义下拉刷新下拉时实际下拉位移与用户下拉距离的比值,默认为0.75,即代表若用户下拉10px,则实际位移为7.5px(nvue无效)
refresherPullRate: {
type: Number,
default: u.gc('refresherPullRate', 0.75)
},
// 是否显示最后更新时间,默认为否
showRefresherUpdateTime: {
type: Boolean,
default: u.gc('showRefresherUpdateTime', false)
},
// 如果需要区别不同页面的最后更新时间,请为不同页面的z-paging的`refresher-update-time-key`设置不同的字符串
refresherUpdateTimeKey: {
type: String,
default: u.gc('refresherUpdateTimeKey', 'default')
},
// 下拉刷新时下拉到“松手立即刷新”或“松手进入二楼”状态时是否使手机短振动,默认为否(h5无效)
refresherVibrate: {
type: Boolean,
default: u.gc('refresherVibrate', false)
},
// 下拉刷新时是否禁止下拉刷新view跟随用户触摸竖直移动,默认为否。注意此属性只是禁止下拉刷新view移动,其他下拉刷新逻辑依然会正常触发
refresherNoTransform: {
type: Boolean,
default: u.gc('refresherNoTransform', false)
},
// 是否开启下拉刷新状态栏占位,适用于隐藏导航栏时,下拉刷新需要避开状态栏高度的情况,默认为否
useRefresherStatusBarPlaceholder: {
type: Boolean,
default: u.gc('useRefresherStatusBarPlaceholder', false)
},
},
data() {
return {
R: Enum.Refresher,
//下拉刷新状态
refresherStatus: Enum.Refresher.Default,
refresherTouchstartY: 0,
lastRefresherTouchmove: null,
refresherReachMaxAngle: true,
refresherTransform: 'translateY(0px)',
refresherTransition: '',
finalRefresherDefaultStyle: 'black',
refresherRevealStackCount: 0,
refresherCompleteTimeout: null,
refresherCompleteSubTimeout: null,
refresherEndTimeout: null,
isTouchmovingTimeout: null,
refresherTriggered: false,
isTouchmoving: false,
isTouchEnded: false,
isUserPullDown: false,
privateRefresherEnabled: -1,
privateShowRefresherWhenReload: false,
customRefresherHeight: -1,
showCustomRefresher: false,
doRefreshAnimateAfter: false,
isRefresherInComplete: false,
showF2: false,
f2Transform: '',
pullDownTimeStamp: 0,
moveDis: 0,
oldMoveDis: 0,
currentDis: 0,
oldCurrentMoveDis: 0,
oldRefresherTouchmoveY: 0,
oldTouchDirection: '',
oldEmitedTouchDirection: '',
oldPullingDistance: -1,
refresherThresholdUpdateTag: 0
}
},
watch: {
refresherDefaultStyle: {
handler(newVal) {
if (newVal.length) {
this.finalRefresherDefaultStyle = newVal;
}
},
immediate: true
},
refresherStatus(newVal) {
newVal === Enum.Refresher.Loading && this._cleanRefresherEndTimeout();
this.refresherVibrate && (newVal === Enum.Refresher.ReleaseToRefresh || newVal === Enum.Refresher.GoF2) && this._doVibrateShort();
this.$emit('refresherStatusChange', newVal);
this.$emit('update:refresherStatus', newVal);
},
// 监听当前下拉刷新启用/禁用状态
refresherEnabled(newVal) {
// 当禁用下拉刷新时,强制收回正在展示的下拉刷新view
!newVal && this.endRefresh();
}
},
computed: {
pullDownDisTimeStamp() {
return 1000 / this.refresherFps;
},
refresherThresholdUnitConverted() {
return u.addUnit(this.refresherThreshold, this.unit);
},
finalRefresherEnabled() {
if (this.useChatRecordMode) return false;
if (this.privateRefresherEnabled === -1) return this.refresherEnabled;
return this.privateRefresherEnabled === 1;
},
finalRefresherThreshold() {
let refresherThreshold = this.refresherThresholdUnitConverted;
let idDefault = false;
if (refresherThreshold === u.addUnit(80, this.unit)) {
idDefault = true;
if (this.showRefresherUpdateTime) {
refresherThreshold = u.addUnit(120, this.unit);
}
}
if (idDefault && this.customRefresherHeight > 0) return this.customRefresherHeight + this.finalRefresherThresholdPlaceholder;
return u.convertToPx(refresherThreshold) + this.finalRefresherThresholdPlaceholder;
},
finalRefresherF2Threshold() {
return u.convertToPx(u.addUnit(this.refresherF2Threshold, this.unit));
},
finalRefresherThresholdPlaceholder() {
return this.useRefresherStatusBarPlaceholder ? this.statusBarHeight : 0;
},
finalRefresherFixedBacHeight() {
return u.convertToPx(this.refresherFixedBacHeight);
},
finalRefresherThemeStyle() {
return this.refresherThemeStyle.length ? this.refresherThemeStyle : this.defaultThemeStyle;
},
finalRefresherOutRate() {
let rate = this.refresherOutRate;
rate = Math.max(0,rate);
rate = Math.min(1,rate);
return rate;
},
finalRefresherPullRate() {
let rate = this.refresherPullRate;
rate = Math.max(0,rate);
return rate;
},
finalRefresherTransform() {
if (this.refresherNoTransform || this.refresherTransform === 'translateY(0px)') return 'none';
return this.refresherTransform;
},
finalShowRefresherWhenReload() {
return this.showRefresherWhenReload || this.privateShowRefresherWhenReload;
},
finalRefresherTriggered() {
if (!(this.finalRefresherEnabled && !this.useCustomRefresher)) return false;
return this.refresherTriggered;
},
showRefresher() {
const showRefresher = this.finalRefresherEnabled && this.useCustomRefresher;
// #ifndef APP-NVUE
this.active && this.customRefresherHeight === -1 && showRefresher && this.updateCustomRefresherHeight();
// #endif
return showRefresher;
},
hasTouchmove() {
// #ifdef VUE2
// #ifdef APP-VUE || H5
if (this.$listeners && !this.$listeners.refresherTouchmove) return false;
// #endif
// #ifdef MP-WEIXIN || MP-QQ
return this.watchRefresherTouchmove;
// #endif
return true;
// #endif
return this.watchRefresherTouchmove;
},
},
methods: {
// 终止下拉刷新状态
endRefresh() {
this.totalData = this.realTotalData;
this._refresherEnd();
this._endSystemLoadingAndRefresh();
this._handleScrollViewBounce({ bounce: true });
this.$nextTick(() => {
this.refresherTriggered = false;
})
},
// 手动更新自定义下拉刷新view高度
updateCustomRefresherHeight() {
u.delay(() => this.$nextTick(this._updateCustomRefresherHeight));
},
// 关闭二楼
closeF2() {
this._handleCloseF2();
},
// 自定义下拉刷新被触发
_onRefresh(fromScrollView = false, isUserPullDown = true) {
if (fromScrollView && !(this.finalRefresherEnabled && !this.useCustomRefresher)) return;
this.$emit('onRefresh');
this.$emit('Refresh');
// #ifdef APP-NVUE
if (this.loading) {
u.delay(this._nRefresherEnd, 500)
return;
}
// #endif
if (this.loading || this.isRefresherInComplete) return;
this.loadingType = Enum.LoadingType.Refresher;
if (this.nShowRefresherReveal) return;
this.isUserPullDown = isUserPullDown;
this.isUserReload = !isUserPullDown;
this._startLoading(true);
this.refresherTriggered = true;
if(this.reloadWhenRefresh && isUserPullDown){
this.useChatRecordMode ? this._onLoadingMore('click') : this._reload(false, false, isUserPullDown);
}
},
// 自定义下拉刷新被复位
_onRestore() {
this.refresherTriggered = 'restore';
this.$emit('onRestore');
this.$emit('Restore');
},
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// touch开始
_refresherTouchstart(e) {
this._handleListTouchstart();
if (this._touchDisabled()) return;
this._handleRefresherTouchstart(u.getTouch(e));
},
// #endif
// 进一步处理touch开始结果
_handleRefresherTouchstart(touch) {
if (!this.loading && this.isTouchEnded) {
this.isTouchmoving = false;
}
this.loadingType = Enum.LoadingType.Refresher;
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this.isTouchEnded = false;
this.refresherTransition = '';
this.refresherTouchstartY = touch.touchY;
this.$emit('refresherTouchstart', this.refresherTouchstartY);
this.lastRefresherTouchmove = touch;
this._cleanRefresherCompleteTimeout();
this._cleanRefresherEndTimeout();
},
// 非appvue或微信小程序或QQ小程序或h5平台,使用js控制下拉刷新
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// touch中
_refresherTouchmove(e) {
const currentTimeStamp = u.getTime();
let touch = null;
let refresherTouchmoveY = 0;
if (this.watchTouchDirectionChange) {
// 检测下拉刷新方向改变
touch = u.getTouch(e);
refresherTouchmoveY = touch.touchY;
const direction = refresherTouchmoveY > this.oldRefresherTouchmoveY ? 'top' : 'bottom';
// 只有在方向改变的时候才emit相关事件
if (direction === this.oldTouchDirection && direction !== this.oldEmitedTouchDirection) {
this._handleTouchDirectionChange({ direction });
this.oldEmitedTouchDirection = direction;
}
this.oldTouchDirection = direction;
this.oldRefresherTouchmoveY = refresherTouchmoveY;
}
// 节流处理,在pullDownDisTimeStamp时间内的下拉刷新中事件不进行处理
if (this.pullDownTimeStamp && currentTimeStamp - this.pullDownTimeStamp <= this.pullDownDisTimeStamp) return;
// 如果不允许下拉,则return
if (this._touchDisabled()) return;
this.pullDownTimeStamp = Number(currentTimeStamp);
touch = u.getTouch(e);
refresherTouchmoveY = touch.touchY;
// 获取当前touch的y - 初始touch的y,计算它们的差
let moveDis = refresherTouchmoveY - this.refresherTouchstartY;
if (moveDis < 0) return;
// 对下拉刷新的角度进行限制
if (this.refresherMaxAngle >= 0 && this.refresherMaxAngle <= 90 && this.lastRefresherTouchmove && this.lastRefresherTouchmove.touchY <= refresherTouchmoveY) {
if (!moveDis && !this.refresherAngleEnableChangeContinued && this.moveDis < 1 && !this.refresherReachMaxAngle) return;
const x = Math.abs(touch.touchX - this.lastRefresherTouchmove.touchX);
const y = Math.abs(refresherTouchmoveY - this.lastRefresherTouchmove.touchY);
const z = Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
if ((x || y) && x > 1) {
// 获取下拉刷新前后两次位移的角度
const angle = Math.asin(y / z) / Math.PI * 180;
// 如果角度小于配置要求,则return
if (angle < this.refresherMaxAngle) {
this.lastRefresherTouchmove = touch;
this.refresherReachMaxAngle = false;
return;
}
}
}
// 获取最终的moveDis
moveDis = this._getFinalRefresherMoveDis(moveDis);
// 处理下拉刷新位移
this._handleRefresherTouchmove(moveDis, touch);
// 下拉刷新时,禁止页面滚动以防止页面向下滚动和下拉刷新同时作用导致下拉刷新位置偏移超过预期
if (!this.disabledBounce) {
// #ifndef MP-LARK
this._handleScrollViewBounce({ bounce: false });
// #endif
this.disabledBounce = true;
}
this._emitTouchmove({ pullingDistance: moveDis, dy: this.moveDis - this.oldMoveDis });
},
// #endif
// 进一步处理touch中结果
_handleRefresherTouchmove(moveDis, touch) {
this.refresherReachMaxAngle = true;
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this.isTouchmoving = true;
this.isTouchEnded = false;
// 更新下拉刷新状态
// 下拉刷新距离超过阈值
if (moveDis >= this.finalRefresherThreshold) {
// 如果开启了下拉进入二楼并且下拉刷新距离超过进入二楼阈值,则当前下拉刷新状态为松手进入二楼,否则为松手立即刷新
this.refresherStatus = this.refresherF2Enabled && moveDis >= this.finalRefresherF2Threshold ? Enum.Refresher.GoF2 : Enum.Refresher.ReleaseToRefresh;
} else {
// 下拉刷新距离未超过阈值,显示默认状态
this.refresherStatus = Enum.Refresher.Default;
}
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// this.scrollEnable = false;
// 通过transform控制下拉刷新view垂直偏移
this.refresherTransform = `translateY(${moveDis}px)`;
this.lastRefresherTouchmove = touch;
// #endif
this.moveDis = moveDis;
},
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// touch结束
_refresherTouchend(e) {
// 下拉刷新用户手离开屏幕,允许列表滚动
this._handleScrollViewBounce({bounce: true});
if (this._touchDisabled() || !this.isTouchmoving) return;
const touch = u.getTouch(e);
let refresherTouchendY = touch.touchY;
let moveDis = refresherTouchendY - this.refresherTouchstartY;
moveDis = this._getFinalRefresherMoveDis(moveDis);
this._handleRefresherTouchend(moveDis);
this.disabledBounce = false;
},
// #endif
// 进一步处理touch结束结果
_handleRefresherTouchend(moveDis) {
// #ifndef APP-PLUS || H5 || MP-WEIXIN
if (!this.isTouchmoving) return;
// #endif
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this.refresherReachMaxAngle = true;
this.isTouchEnded = true;
const refresherThreshold = this.finalRefresherThreshold;
if (moveDis >= refresherThreshold && (this.refresherStatus === Enum.Refresher.ReleaseToRefresh || this.refresherStatus === Enum.Refresher.GoF2)) {
// 如果是松手进入二楼状态,则触发进入二楼
if (this.refresherStatus === Enum.Refresher.GoF2) {
this._handleGoF2();
this._refresherEnd();
} else {
// 如果是松手立即刷新状态,则触发下拉刷新
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransform = `translateY(${refresherThreshold}px)`;
this.refresherTransition = 'transform .1s linear';
// #endif
u.delay(() => {
this._emitTouchmove({ pullingDistance: refresherThreshold, dy: this.moveDis - refresherThreshold });
}, 0.1);
this.moveDis = refresherThreshold;
this.refresherStatus = Enum.Refresher.Loading;
this._doRefresherLoad();
}
} else {
this._refresherEnd();
this.isTouchmovingTimeout = u.delay(() => {
this.isTouchmoving = false;
}, this.refresherDefaultDuration);
}
this.scrollEnable = true;
this.$emit('refresherTouchend', moveDis);
},
// 处理列表触摸开始事件
_handleListTouchstart() {
if (this.useChatRecordMode && this.autoHideKeyboardWhenChat) {
uni.hideKeyboard();
this.$emit('hidedKeyboard');
}
},
// 处理scroll-view bounce是否生效
_handleScrollViewBounce({ bounce }) {
if (!this.usePageScroll && !this.scrollToTopBounceEnabled) {
if (this.wxsScrollTop <= 5) {
// #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransition = '';
// #endif
this.scrollEnable = bounce;
} else if (bounce) {
this.scrollEnable = bounce;
}
}
},
// wxs正在下拉状态改变处理
_handleWxsPullingDownStatusChange(onPullingDown) {
this.wxsOnPullingDown = onPullingDown;
if (onPullingDown && !this.useChatRecordMode) {
this.renderPropScrollTop = 0;
}
},
// wxs正在下拉处理
_handleWxsPullingDown({ moveDis, diffDis }){
this._emitTouchmove({ pullingDistance: moveDis,dy: diffDis });
},
// wxs触摸方向改变
_handleTouchDirectionChange({ direction }) {
this.$emit('touchDirectionChange',direction);
},
// wxs通知更新其props
_handlePropUpdate(){
this.wxsPropType = u.getTime().toString();
},
// 下拉刷新结束
_refresherEnd(shouldEndLoadingDelay = true, fromAddData = false, isUserPullDown = false, setLoading = true) {
if (this.loadingType === Enum.LoadingType.Refresher) {
const refresherCompleteDelay = (fromAddData && (isUserPullDown || this.showRefresherWhenReload)) ? this.refresherCompleteDelay : 0;
const refresherStatus = refresherCompleteDelay > 0 ? Enum.Refresher.Complete : Enum.Refresher.Default;
if (this.finalShowRefresherWhenReload) {
const stackCount = this.refresherRevealStackCount;
this.refresherRevealStackCount --;
if (stackCount > 1) return;
}
this._cleanRefresherEndTimeout();
this.refresherEndTimeout = u.delay(() => {
this.refresherStatus = refresherStatus;
}, this.refresherStatus !== Enum.Refresher.Default && refresherStatus === Enum.Refresher.Default ? this.refresherCompleteDuration : 0);
// #ifndef APP-NVUE
if (refresherCompleteDelay > 0) {
this.isRefresherInComplete = true;
}
// #endif
this._cleanRefresherCompleteTimeout();
this.refresherCompleteTimeout = u.delay(() => {
let animateDuration = 1;
const animateType = this.refresherEndBounceEnabled && fromAddData ? 'cubic-bezier(0.19,1.64,0.42,0.72)' : 'linear';
if (fromAddData) {
animateDuration = this.refresherEndBounceEnabled ? this.refresherCompleteDuration / 1000 : this.refresherCompleteDuration / 3000;
}
this.refresherTransition = `transform ${fromAddData ? animateDuration : this.refresherDefaultDuration / 1000}s ${animateType}`;
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransform = 'translateY(0px)';
this.currentDis = 0;
// #endif
// #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.wxsPropType = this.refresherTransition + 'end' + u.getTime();
// #endif
// #ifdef APP-NVUE
this._nRefresherEnd();
// #endif
this.moveDis = 0;
// #ifndef APP-NVUE
if (refresherStatus === Enum.Refresher.Complete) {
if (this.refresherCompleteSubTimeout) {
clearTimeout(this.refresherCompleteSubTimeout);
this.refresherCompleteSubTimeout = null;
}
this.refresherCompleteSubTimeout = u.delay(() => {
this.$nextTick(() => {
this.refresherStatus = Enum.Refresher.Default;
this.isRefresherInComplete = false;
})
}, animateDuration * 800);
}
// #endif
this._emitTouchmove({ pullingDistance: 0, dy: this.moveDis });
}, refresherCompleteDelay);
}
if (setLoading) {
u.delay(() => this.loading = false, shouldEndLoadingDelay ? 10 : 0);
isUserPullDown && this._onRestore();
}
},
// 处理进入二楼
_handleGoF2() {
if (this.showF2 || !this.refresherF2Enabled) return;
this.$emit('refresherF2Change', 'go');
if (!this.showRefresherF2) return;
// #ifndef APP-NVUE
this.f2Transform = `translateY(${-this.superContentHeight}px)`;
this.showF2 = true;
u.delay(() => {
this.f2Transform = 'translateY(0px)';
}, 100, 'f2ShowDelay')
// #endif
// #ifdef APP-NVUE
this.showF2 = true;
this.$nextTick(() => {
weexAnimation.transition(this.$refs['zp-n-f2'], {
styles: { transform: `translateY(${-this.superContentHeight}px)` },
duration: 0,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
this.nF2Opacity = 1;
})
u.delay(() => {
weexAnimation.transition(this.$refs['zp-n-f2'], {
styles: { transform: 'translateY(0px)' },
duration: this.refresherF2Duration,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
}, 10, 'f2GoDelay')
// #endif
},
// 处理退出二楼
_handleCloseF2() {
if (!this.showF2 || !this.refresherF2Enabled) return;
this.$emit('refresherF2Change', 'close');
if (!this.showRefresherF2) return;
// #ifndef APP-NVUE
this.f2Transform = `translateY(${-this.superContentHeight}px)`;
// #endif
// #ifdef APP-NVUE
weexAnimation.transition(this.$refs['zp-n-f2'], {
styles: { transform: `translateY(${-this.superContentHeight}px)` },
duration: this.refresherF2Duration,
timingFunction: 'linear',
needLayout: true,
delay: 0
})
// #endif
u.delay(() => {
this.showF2 = false;
this.nF2Opacity = 0;
}, this.refresherF2Duration, 'f2CloseDelay')
},
// 模拟用户手动触发下拉刷新
_doRefresherRefreshAnimate() {
this._cleanRefresherCompleteTimeout();
// 用户处理用户在短时间内多次调用reload的情况,此时下拉刷新view不需要重复显示,只需要保证最后一次reload对应的请求结束后收回下拉刷新view即可
// #ifndef APP-NVUE
const doRefreshAnimateAfter = !this.doRefreshAnimateAfter && (this.finalShowRefresherWhenReload) && this
.customRefresherHeight === -1 && this.refresherThreshold === u.addUnit(80, this.unit);
if (doRefreshAnimateAfter) {
this.doRefreshAnimateAfter = true;
return;
}
// #endif
this.refresherRevealStackCount ++;
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.refresherTransform = `translateY(${this.finalRefresherThreshold}px)`;
// #endif
// #ifdef APP-VUE || MP-WEIXIN || MP-QQ || H5
this.wxsPropType = 'begin' + u.getTime();
// #endif
this.moveDis = this.finalRefresherThreshold;
this.refresherStatus = Enum.Refresher.Loading;
this.isTouchmoving = true;
this.isTouchmovingTimeout && clearTimeout(this.isTouchmovingTimeout);
this._doRefresherLoad(false);
},
// 触发下拉刷新
_doRefresherLoad(isUserPullDown = true) {
this._onRefresh(false,isUserPullDown);
this.loading = true;
},
// #ifndef APP-VUE || MP-WEIXIN || MP-QQ || H5
// 获取处理后的moveDis
_getFinalRefresherMoveDis(moveDis) {
let diffDis = moveDis - this.oldCurrentMoveDis;
this.oldCurrentMoveDis = moveDis;
if (diffDis > 0) {
// 根据配置的下拉刷新用户手势位移与实际需要的位移比率计算最终的diffDis
diffDis = diffDis * this.finalRefresherPullRate;
if (this.currentDis > this.finalRefresherThreshold) {
diffDis = diffDis * (1 - this.finalRefresherOutRate);
}
}
// 控制diffDis过大的情况,比如进入页面突然猛然下拉,此时diffDis不应进行太大的偏移
diffDis = diffDis > 100 ? diffDis / 100 : diffDis;
this.currentDis += diffDis;
this.currentDis = Math.max(0, this.currentDis);
return this.currentDis;
},
// 判断touch手势是否要触发
_touchDisabled() {
const checkOldScrollTop = this.oldScrollTop > 5;
return this.loading || this.isRefresherInComplete || this.useChatRecordMode || !this.refresherEnabled || !this.useCustomRefresher ||(this.usePageScroll && this.useCustomRefresher && this.pageScrollTop > 10) || (!(this.usePageScroll && this.useCustomRefresher) && checkOldScrollTop);
},
// #endif
// 更新自定义下拉刷新view高度
_updateCustomRefresherHeight() {
this._getNodeClientRect('.zp-custom-refresher-slot-view').then((res) => {
this.customRefresherHeight = res ? res[0].height : 0;
this.showCustomRefresher = this.customRefresherHeight > 0;
if (this.doRefreshAnimateAfter) {
this.doRefreshAnimateAfter = false;
this._doRefresherRefreshAnimate();
}
});
},
// emit pullingDown事件
_emitTouchmove(e) {
// #ifndef APP-NVUE
e.viewHeight = this.finalRefresherThreshold;
// #endif
e.rate = e.viewHeight > 0 ? e.pullingDistance / e.viewHeight : 0;
this.hasTouchmove && this.oldPullingDistance !== e.pullingDistance && this.$emit('refresherTouchmove', e);
this.oldPullingDistance = e.pullingDistance;
},
// 清除refresherCompleteTimeout
_cleanRefresherCompleteTimeout() {
this.refresherCompleteTimeout = this._cleanTimeout(this.refresherCompleteTimeout);
// #ifdef APP-NVUE
this._nRefresherEnd(false);
// #endif
},
// 清除refresherEndTimeout
_cleanRefresherEndTimeout() {
this.refresherEndTimeout = this._cleanTimeout(this.refresherEndTimeout);
},
}
}
@@ -0,0 +1,518 @@
// [z-paging]scroll相关模块
import u from '.././z-paging-utils'
import Enum from '.././z-paging-enum'
// #ifdef APP-NVUE
const weexDom = weex.requireModule('dom');
// #endif
export default {
props: {
// 使用页面滚动,默认为否,当设置为是时则使用页面的滚动而非此组件内部的scroll-view的滚动,使用页面滚动时z-paging无需设置确定的高度且对于长列表展示性能更高,但配置会略微繁琐
usePageScroll: {
type: Boolean,
default: u.gc('usePageScroll', false)
},
// 是否可以滚动,使用内置scroll-view和nvue时有效,默认为是
scrollable: {
type: Boolean,
default: u.gc('scrollable', true)
},
// 控制是否出现滚动条,默认为是
showScrollbar: {
type: Boolean,
default: u.gc('showScrollbar', true)
},
// 是否允许横向滚动,默认为否
scrollX: {
type: Boolean,
default: u.gc('scrollX', false)
},
// iOS设备上滚动到顶部时是否允许回弹效果,默认为否。关闭回弹效果后可使滚动到顶部与下拉刷新更连贯,但是有吸顶view时滚动到顶部时可能出现抖动。
scrollToTopBounceEnabled: {
type: Boolean,
default: u.gc('scrollToTopBounceEnabled', false)
},
// iOS设备上滚动到底部时是否允许回弹效果,默认为是。
scrollToBottomBounceEnabled: {
type: Boolean,
default: u.gc('scrollToBottomBounceEnabled', true)
},
// 在设置滚动条位置时使用动画过渡,默认为否
scrollWithAnimation: {
type: Boolean,
default: u.gc('scrollWithAnimation', false)
},
// 值应为某子元素id(id不能以数字开头)。设置哪个方向可滚动,则在哪个方向滚动到该元素
scrollIntoView: {
type: String,
default: u.gc('scrollIntoView', '')
},
},
data() {
return {
scrollTop: 0,
oldScrollTop: 0,
scrollViewStyle: {},
scrollViewContainerStyle: {},
scrollViewInStyle: {},
pageScrollTop: -1,
scrollEnable: true,
privateScrollWithAnimation: -1,
cacheScrollNodeHeight: -1,
superContentHeight: 0,
}
},
watch: {
oldScrollTop(newVal) {
!this.usePageScroll && this._scrollTopChange(newVal,false);
},
pageScrollTop(newVal) {
this.usePageScroll && this._scrollTopChange(newVal,true);
},
usePageScroll: {
handler(newVal) {
this.loaded && this.autoHeight && this._setAutoHeight(!newVal);
// #ifdef H5
if (newVal) {
this.$nextTick(() => {
const mainScrollRef = this.$refs['zp-scroll-view'].$refs.main;
if (mainScrollRef) {
mainScrollRef.style = {};
}
})
}
// #endif
},
immediate: true
},
finalScrollTop(newVal) {
this.renderPropScrollTop = newVal < 6 ? 0 : 10;
}
},
computed: {
finalScrollWithAnimation() {
if (this.privateScrollWithAnimation !== -1) {
return this.privateScrollWithAnimation === 1;
}
return this.scrollWithAnimation;
},
finalScrollViewStyle() {
if (this.superContentZIndex != 1) {
this.scrollViewStyle['z-index'] = this.superContentZIndex;
this.scrollViewStyle['position'] = 'relative';
}
return this.scrollViewStyle;
},
finalScrollTop() {
return this.usePageScroll ? this.pageScrollTop : this.oldScrollTop;
},
// 当前是否是旧版webview
finalIsOldWebView() {
return this.isOldWebView && !this.usePageScroll;
},
// 当前scroll-view/list-view是否允许滚动
finalScrollable() {
return this.scrollable && !this.usePageScroll && this.scrollEnable
&& (this.refresherCompleteScrollable ? true : this.refresherStatus !== Enum.Refresher.Complete)
&& (this.refresherRefreshingScrollable ? true : this.refresherStatus !== Enum.Refresher.Loading);
}
},
methods: {
// 滚动到顶部,animate为是否展示滚动动画,默认为是
scrollToTop(animate, checkReverse = true) {
// 如果是聊天记录模式并且列表倒置了,则滚动到顶部实际上是滚动到底部
if (this.useChatRecordMode && checkReverse && !this.isChatRecordModeAndNotInversion) {
this.scrollToBottom(animate, false);
return;
}
this.$nextTick(() => {
this._scrollToTop(animate, false);
// #ifdef APP-NVUE
if (this.nvueFastScroll && animate) {
u.delay(() => {
this._scrollToTop(false, false);
});
}
// #endif
})
},
// 滚动到底部,animate为是否展示滚动动画,默认为是
scrollToBottom(animate, checkReverse = true) {
// 如果是聊天记录模式并且列表倒置了,则滚动到底部实际上是滚动到顶部
if (this.useChatRecordMode && checkReverse && !this.isChatRecordModeAndNotInversion) {
this.scrollToTop(animate, false);
return;
}
this.$nextTick(() => {
this._scrollToBottom(animate);
// #ifdef APP-NVUE
if (this.nvueFastScroll && animate) {
u.delay(() => {
this._scrollToBottom(false);
});
}
// #endif
})
},
// 滚动到指定view(vue中有效)。sel为需要滚动的view的id值,不包含"#";offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewById(sel, offset, animate) {
this._scrollIntoView(sel, offset, animate);
},
// 滚动到指定view(vue中有效)。nodeTop为需要滚动的view的top值(通过uni.createSelectorQuery()获取);offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewByNodeTop(nodeTop, offset, animate) {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this._scrollIntoViewByNodeTop(nodeTop, offset, animate);
})
},
// 滚动到指定位置(vue中有效)。y为与顶部的距离,单位为px;offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollToY(y, offset, animate) {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this._scrollToY(y, offset, animate);
})
},
// 滚动到指定view(nvue中和虚拟列表中有效)。index为需要滚动的view的index(第几个,从0开始);offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewByIndex(index, offset, animate) {
if (index >= this.realTotalData.length) {
u.consoleErr('当前滚动的index超出已渲染列表长度,请先通过refreshToPage加载到对应index页并等待渲染成功后再调用此方法!')
return;
}
this.$nextTick(() => {
// #ifdef APP-NVUE
// 在nvue中,根据index获取对应节点信息并滚动到此节点位置
this._scrollIntoView(index, offset, animate);
// #endif
// #ifndef APP-NVUE
if (this.finalUseVirtualList) {
const isCellFixed = this.cellHeightMode === Enum.CellHeightMode.Fixed;
u.delay(() => {
if (this.finalUseVirtualList) {
// 虚拟列表 + 每个cell高度完全相同模式下,此时滚动到对应index的cell就是滚动到scrollTop = cellHeight * index的位置
// 虚拟列表 + 高度是动态非固定的模式下,此时滚动到对应index的cell就是滚动到scrollTop = 缓存的cell高度数组中第index个的lastTotalHeight的位置
const scrollTop = isCellFixed ? this.virtualCellHeight * index : this.virtualHeightCacheList[index].lastTotalHeight;
this.scrollToY(scrollTop, offset, animate);
}
}, isCellFixed ? 0 : 100)
}
// #endif
})
},
// 滚动到指定view(nvue中有效)。view为需要滚动的view(通过`this.$refs.xxx`获取),不包含"#";offset为偏移量,单位为px;animate为是否展示滚动动画,默认为否
scrollIntoViewByView(view, offset, animate) {
this._scrollIntoView(view, offset, animate);
},
// 当使用页面滚动并且自定义下拉刷新时,请在页面的onPageScroll中调用此方法,告知z-paging当前的pageScrollTop,否则会导致在任意位置都可以下拉刷新
updatePageScrollTop(value) {
this.pageScrollTop = value;
},
// 当使用页面滚动并且设置了slot="top"时,默认初次加载会自动获取其高度,并使内部容器下移,当slot="top"的view高度动态改变时,在其高度需要更新时调用此方法
updatePageScrollTopHeight() {
this._updatePageScrollTopOrBottomHeight('top');
},
// 当使用页面滚动并且设置了slot="bottom"时,默认初次加载会自动获取其高度,并使内部容器下移,当slot="bottom"的view高度动态改变时,在其高度需要更新时调用此方法
updatePageScrollBottomHeight() {
this._updatePageScrollTopOrBottomHeight('bottom');
},
// 更新slot="left"和slot="right"宽度,当slot="left"或slot="right"宽度动态改变时调用
updateLeftAndRightWidth() {
if (!this.finalIsOldWebView) return;
this.$nextTick(() => this._updateLeftAndRightWidth(this.scrollViewContainerStyle, 'zp-page'));
},
// 更新z-paging内置scroll-view的scrollTop
updateScrollViewScrollTop(scrollTop, animate = true) {
this._updatePrivateScrollWithAnimation(animate);
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this.scrollTop = scrollTop;
this.oldScrollTop = this.scrollTop;
});
},
// 当滚动到顶部时
_onScrollToUpper() {
this.$emit('scrolltoupper');
this.$emit('scrollTopChange', 0);
this.$nextTick(() => {
this.oldScrollTop = 0;
})
},
// 当滚动到底部时
_onScrollToLower(e) {
(!e.detail || !e.detail.direction || e.detail.direction === 'bottom') && this._onLoadingMore(this.useChatRecordMode ? 'click' : 'toBottom')
},
// 滚动到顶部
_scrollToTop(animate = true, isPrivate = true) {
// #ifdef APP-NVUE
// 在nvue中需要通过weex.scrollToElement滚动到顶部,此时在顶部插入了一个view,使得滚动到这个view位置
const el = this.$refs['zp-n-list-top-tag'];
if (this.usePageScroll) {
this._getNodeClientRect('zp-page-scroll-top', false).then(node => {
const nodeHeight = node ? node[0].height : 0;
weexDom.scrollToElement(el, {
offset: -nodeHeight,
animated: animate
});
});
} else {
if (!this.isIos && this.nvueListIs === 'scroller') {
this._getNodeClientRect('zp-n-refresh-container', false).then(node => {
const nodeHeight = node ? node[0].height : 0;
weexDom.scrollToElement(el, {
offset: -nodeHeight,
animated: animate
});
});
} else {
weexDom.scrollToElement(el, {
offset: 0,
animated: animate
});
}
}
return;
// #endif
if (this.usePageScroll) {
this.$nextTick(() => {
uni.pageScrollTo({
scrollTop: 0,
duration: animate ? 100 : 0,
});
});
return;
}
this._updatePrivateScrollWithAnimation(animate);
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this.scrollTop = 0;
this.oldScrollTop = this.scrollTop;
});
},
// 滚动到底部
async _scrollToBottom(animate = true) {
// #ifdef APP-NVUE
// 在nvue中需要通过weex.scrollToElement滚动到顶部,此时在底部插入了一个view,使得滚动到这个view位置
const el = this.$refs['zp-n-list-bottom-tag'];
if (el) {
weexDom.scrollToElement(el, {
offset: 0,
animated: animate
});
} else {
u.consoleErr('滚动到底部失败,因为您设置了hideNvueBottomTag为true');
}
return;
// #endif
if (this.usePageScroll) {
this.$nextTick(() => {
uni.pageScrollTo({
scrollTop: Number.MAX_VALUE,
duration: animate ? 100 : 0,
});
});
return;
}
try {
this._updatePrivateScrollWithAnimation(animate);
const pagingContainerNode = await this._getNodeClientRect('.zp-paging-container');
const scrollViewNode = await this._getNodeClientRect('.zp-scroll-view');
const pagingContainerH = pagingContainerNode ? pagingContainerNode[0].height : 0;
const scrollViewH = scrollViewNode ? scrollViewNode[0].height : 0;
if (pagingContainerH > scrollViewH) {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
this.scrollTop = pagingContainerH - scrollViewH + this.virtualPlaceholderTopHeight;
this.oldScrollTop = this.scrollTop;
});
}
} catch (e) {}
},
// 滚动到指定view
_scrollIntoView(sel, offset = 0, animate = false, finishCallback) {
try {
this.scrollTop = this.oldScrollTop;
this.$nextTick(() => {
// #ifdef APP-NVUE
const refs = this.$parent.$refs;
if (!refs) return;
const dataType = Object.prototype.toString.call(sel);
let el = null;
if (dataType === '[object Number]') {
const els = refs[`z-paging-${sel}`];
el = els ? els[0] : null;
} else if (dataType === '[object Array]') {
el = sel[0];
} else {
el = sel;
}
if (el) {
weexDom.scrollToElement(el, {
offset: -offset,
animated: animate
});
} else {
u.consoleErr('在nvue中滚动到指定位置,cell必须设置 :ref="`z-paging-${index}`"');
}
return;
// #endif
this._getNodeClientRect('#' + sel.replace('#', ''), this.$parent).then((node) => {
if (node) {
let nodeTop = node[0].top;
this._scrollIntoViewByNodeTop(nodeTop, offset, animate);
finishCallback && finishCallback();
}
});
});
} catch (e) {}
},
// 通过nodeTop滚动到指定view
_scrollIntoViewByNodeTop(nodeTop, offset = 0, animate = false) {
// 如果是聊天记录模式并且列表倒置了,此时nodeTop需要等于scroll-view高度 - nodeTop
if (this.isChatRecordModeAndInversion) {
this._getNodeClientRect('.zp-scroll-view').then(sNode => {
if (sNode) {
this._scrollToY(sNode[0].height - nodeTop, offset, animate, true);
}
})
} else {
this._scrollToY(nodeTop, offset, animate, true);
}
},
// 滚动到指定位置
_scrollToY(y, offset = 0, animate = false, addScrollTop = false) {
this._updatePrivateScrollWithAnimation(animate);
u.delay(() => {
if (this.usePageScroll) {
if (addScrollTop && this.pageScrollTop !== -1) {
y += this.pageScrollTop;
}
const scrollTop = y - offset;
uni.pageScrollTo({
scrollTop,
duration: animate ? 100 : 0
});
} else {
if (addScrollTop) {
y += this.oldScrollTop;
}
this.scrollTop = y - offset;
}
}, 10)
},
// scroll-view滚动中
_scroll(e) {
this.$emit('scroll', e);
const scrollTop = e.detail.scrollTop;
// #ifndef APP-NVUE
this.finalUseVirtualList && this._updateVirtualScroll(scrollTop, this.oldScrollTop - scrollTop);
// #endif
this.oldScrollTop = scrollTop;
// 滚动区域内容的总高度 - 当前滚动的scrollTop = 当前滚动区域的顶部与内容底部的距离
const scrollDiff = e.detail.scrollHeight - this.oldScrollTop;
// 在非ios平台滚动中,再次验证一下是否滚动到了底部。因为在一些安卓设备中,有概率滚动到底部不触发@scrolltolower事件,因此添加双重检测逻辑
!this.isIos && this._checkScrolledToBottom(scrollDiff);
},
// 更新内置的scroll-view是否启用滚动动画
_updatePrivateScrollWithAnimation(animate) {
this.privateScrollWithAnimation = animate ? 1 : 0;
u.delay(() => this.$nextTick(() => {
// 在滚动结束后将滚动动画状态设置回初始状态
this.privateScrollWithAnimation = -1;
}), 100, 'updateScrollWithAnimationDelay')
},
// 检测scrollView是否要铺满屏幕
_doCheckScrollViewShouldFullHeight(totalData) {
if (this.autoFullHeight && this.usePageScroll && this.isTotalChangeFromAddData) {
// #ifndef APP-NVUE
this.$nextTick(() => {
this._checkScrollViewShouldFullHeight((scrollViewNode, pagingContainerNode) => {
this._preCheckShowNoMoreInside(totalData, scrollViewNode, pagingContainerNode)
});
})
// #endif
// #ifdef APP-NVUE
this._preCheckShowNoMoreInside(totalData)
// #endif
} else {
this._preCheckShowNoMoreInside(totalData)
}
},
// 检测z-paging是否要全屏覆盖(当使用页面滚动并且不满全屏时,默认z-paging需要铺满全屏,避免数据过少时内部的empty-view无法正确展示)
async _checkScrollViewShouldFullHeight(callback) {
try {
const scrollViewNode = await this._getNodeClientRect('.zp-scroll-view');
const pagingContainerNode = await this._getNodeClientRect('.zp-paging-container-content');
if (!scrollViewNode || !pagingContainerNode) return;
const scrollViewHeight = pagingContainerNode[0].height;
const scrollViewTop = scrollViewNode[0].top;
if (this.isAddedData && scrollViewHeight + scrollViewTop <= this.windowHeight) {
this._setAutoHeight(true, scrollViewNode);
callback(scrollViewNode, pagingContainerNode);
} else {
this._setAutoHeight(false);
callback(null, null);
}
} catch (e) {
callback(null, null);
}
},
// 更新缓存中z-paging整个内容容器高度
async _updateCachedSuperContentHeight() {
const superContentNode = await this._getNodeClientRect('.z-paging-content');
if (superContentNode) {
this.superContentHeight = superContentNode[0].height;
}
},
// scrollTop改变时触发
_scrollTopChange(newVal, isPageScrollTop){
this.$emit('scrollTopChange', newVal);
this.$emit('update:scrollTop', newVal);
this._checkShouldShowBackToTop(newVal);
// 之前在安卓中scroll-view有概率滚动到顶部时scrollTop不为0导致下拉刷新判断异常,因此判断scrollTop在105之内都允许下拉刷新,但此方案会导致某些情况(例如滚动到距离顶部10px处)下拉抖动,因此改为通过获取zp-scroll-view的节点信息中的scrollTop进行验证的方案
// const scrollTop = this.isIos ? (newVal > 5 ? 6 : 0) : (newVal > 105 ? 106 : (newVal > 5 ? 6 : 0));
const scrollTop = newVal > 5 ? 6 : 0;
if (isPageScrollTop && this.wxsPageScrollTop !== scrollTop) {
this.wxsPageScrollTop = scrollTop;
} else if (!isPageScrollTop && this.wxsScrollTop !== scrollTop) {
this.wxsScrollTop = scrollTop;
if (scrollTop > 6) {
this.scrollEnable = true;
}
}
},
// 更新使用页面滚动时slot="top"或"bottom"插入view的高度
_updatePageScrollTopOrBottomHeight(type) {
// #ifndef APP-NVUE
if (!this.usePageScroll) return;
// #endif
this._doCheckScrollViewShouldFullHeight(this.realTotalData);
const node = `.zp-page-${type}`;
const marginText = `margin${type.slice(0,1).toUpperCase() + type.slice(1)}`;
let safeAreaInsetBottomAdd = this.safeAreaInsetBottom;
this.$nextTick(() => {
let delayTime = 0;
// #ifdef MP-BAIDU || APP-NVUE
delayTime = 50;
// #endif
u.delay(() => {
this._getNodeClientRect(node).then((res) => {
if (res) {
let pageScrollNodeHeight = res[0].height;
if (type === 'bottom') {
if (safeAreaInsetBottomAdd) {
pageScrollNodeHeight += this.safeAreaBottom;
}
} else {
this.cacheTopHeight = pageScrollNodeHeight;
}
this.$set(this.scrollViewStyle, marginText, `${pageScrollNodeHeight}px`);
} else if (safeAreaInsetBottomAdd) {
this.$set(this.scrollViewStyle, marginText, `${this.safeAreaBottom}px`);
}
});
}, delayTime)
})
},
}
}
@@ -0,0 +1,512 @@
// [z-paging]虚拟列表模块
import u from '.././z-paging-utils'
import c from '.././z-paging-constant'
import Enum from '.././z-paging-enum'
export default {
props: {
// 是否使用虚拟列表,默认为否
useVirtualList: {
type: Boolean,
default: u.gc('useVirtualList', false)
},
// 在使用虚拟列表时,是否使用兼容模式,默认为否
useCompatibilityMode: {
type: Boolean,
default: u.gc('useCompatibilityMode', false)
},
// 使用兼容模式时传递的附加数据
extraData: {
type: Object,
default: u.gc('extraData', {})
},
// 是否在z-paging内部循环渲染列表(内置列表),默认为否。若use-virtual-list为true,则此项恒为true
useInnerList: {
type: Boolean,
default: u.gc('useInnerList', false)
},
// 强制关闭inner-list,默认为false,如果为true将强制关闭innerList,适用于开启了虚拟列表后需要强制关闭inner-list的情况
forceCloseInnerList: {
type: Boolean,
default: u.gc('forceCloseInnerList', false)
},
// 内置列表cell的key名称,仅nvue有效,在nvue中开启use-inner-list时必须填此项
cellKeyName: {
type: String,
default: u.gc('cellKeyName', '')
},
// innerList样式
innerListStyle: {
type: Object,
default: u.gc('innerListStyle', {})
},
// innerCell样式
innerCellStyle: {
type: Object,
default: u.gc('innerCellStyle', {})
},
// 预加载的列表可视范围(列表高度)页数,默认为12,即预加载当前页及上下各12页的cell。此数值越大,则虚拟列表中加载的dom越多,内存消耗越大(会维持在一个稳定值),但增加预加载页面数量可缓解快速滚动短暂白屏问题
preloadPage: {
type: [Number, String],
default: u.gc('preloadPage', 12),
validator: (value) => {
if (value <= 0) u.consoleErr('preload-page必须大于0!');
return value > 0;
}
},
// 虚拟列表cell高度模式,默认为fixed,也就是每个cell高度完全相同,将以第一个cell高度为准进行计算。可选值【dynamic】,即代表高度是动态非固定的,【dynamic】性能低于【fixed】。
cellHeightMode: {
type: String,
default: u.gc('cellHeightMode', Enum.CellHeightMode.Fixed)
},
// 固定的cell高度,cellHeightMode=fixed才有效,若设置了值,则不计算第一个cell高度而使用设置的cell高度
fixedCellHeight: {
type: [Number, String],
default: u.gc('fixedCellHeight', 0)
},
// 虚拟列表列数,默认为1。常用于每行有多列的情况,例如每行有2列数据,需要将此值设置为2
virtualListCol: {
type: [Number, String],
default: u.gc('virtualListCol', 1)
},
// 虚拟列表scroll取样帧率,默认为80,过低容易出现白屏问题,过高容易出现卡顿问题
virtualScrollFps: {
type: [Number, String],
default: u.gc('virtualScrollFps', 80)
},
},
data() {
return {
virtualListKey: u.getInstanceId(),
virtualPageHeight: 0,
virtualCellHeight: 0,
virtualScrollTimeStamp: 0,
virtualList: [],
virtualPlaceholderTopHeight: 0,
virtualPlaceholderBottomHeight: 0,
virtualTopRangeIndex: 0,
virtualBottomRangeIndex: 0,
lastVirtualTopRangeIndex: 0,
lastVirtualBottomRangeIndex: 0,
virtualItemInsertedCount: 0,
virtualHeightCacheList: [],
getCellHeightRetryCount: {
fixed: 0,
dynamic: 0
},
pagingOrgTop: -1,
updateVirtualListFromDataChange: false
}
},
watch: {
// 监听总数据的改变,刷新虚拟列表布局
realTotalData() {
this.updateVirtualListRender();
},
// 监听虚拟列表渲染数组的改变并emit
virtualList(newVal){
this.$emit('update:virtualList', newVal);
this.$emit('virtualListChange', newVal);
}
},
computed: {
virtualCellIndexKey() {
return c.listCellIndexKey;
},
finalUseVirtualList() {
if (this.useVirtualList && this.usePageScroll){
u.consoleErr('使用页面滚动时,开启虚拟列表无效!');
}
return this.useVirtualList && !this.usePageScroll;
},
finalUseInnerList() {
return this.useInnerList || (this.finalUseVirtualList && !this.forceCloseInnerList);
},
finalCellKeyName() {
// #ifdef APP-NVUE
if (this.finalUseVirtualList && !this.cellKeyName.length){
u.consoleErr('在nvue中开启use-virtual-list必须设置cell-key-name,否则将可能导致列表渲染错误!');
}
// #endif
return this.cellKeyName;
},
finalVirtualPageHeight(){
return this.virtualPageHeight > 0 ? this.virtualPageHeight : this.windowHeight;
},
finalFixedCellHeight() {
return u.convertToPx(this.fixedCellHeight);
},
virtualRangePageHeight(){
return this.finalVirtualPageHeight * this.preloadPage;
},
virtualScrollDisTimeStamp() {
return 1000 / this.virtualScrollFps;
},
},
methods: {
// 在使用动态高度虚拟列表时,若在列表数组中需要插入某个item,需要调用此方法;item:需要插入的item,index:插入的cell位置,若index为2,则插入的item在原list的index=1之后,index从0开始
doInsertVirtualListItem(item, index) {
if (this.cellHeightMode !== Enum.CellHeightMode.Dynamic) return;
this.virtualItemInsertedCount ++;
if (!item || Object.prototype.toString.call(item) !== '[object Object]') {
item = { item };
}
const cellIndexKey = this.virtualCellIndexKey;
item[cellIndexKey] = `custom-${this.virtualItemInsertedCount}`;
item[c.listCellIndexUniqueKey] = `${this.virtualListKey}-${item[cellIndexKey]}`;
this.$nextTick(async () => {
let retryCount = 0;
while (retryCount <= 10) {
await u.wait(c.delayTime);
const cellNode = await this._getNodeClientRect(`#zp-id-${item[cellIndexKey]}`, this.finalUseInnerList);
// 如果获取当前cell的节点信息失败,则重试(不超过10次)
if (!cellNode) {
retryCount ++;
continue;
}
const currentHeight = cellNode ? cellNode[0].height : 0;
const lastHeightCache = this.virtualHeightCacheList[index - 1];
const lastTotalHeight = lastHeightCache ? lastHeightCache.totalHeight : 0;
// 在缓存的cell高度数组中,插入此cell高度信息
this.virtualHeightCacheList.splice(index, 0, {
height: currentHeight,
lastTotalHeight,
totalHeight: lastTotalHeight + currentHeight
});
// 从当前index起后续的cell缓存高度的lastTotalHeight和totalHeight需要加上当前cell的高度
for (let i = index + 1; i < this.virtualHeightCacheList.length; i++) {
const thisNode = this.virtualHeightCacheList[i];
thisNode.lastTotalHeight += currentHeight;
thisNode.totalHeight += currentHeight;
}
this._updateVirtualScroll(this.oldScrollTop);
break;
}
})
},
// 在使用动态高度虚拟列表时,手动更新指定cell的缓存高度(当cell高度在初始化之后再次改变时调用);index:需要更新的cell在列表中的位置,从0开始
didUpdateVirtualListCell(index) {
if (this.cellHeightMode !== Enum.CellHeightMode.Dynamic) return;
const currentNode = this.virtualHeightCacheList[index];
this.$nextTick(() => {
this._getNodeClientRect(`#zp-id-${index}`, this.finalUseInnerList).then(cellNode => {
// 更新当前cell的高度
const cellNodeHeight = cellNode ? cellNode[0].height : 0;
const heightDis = cellNodeHeight - currentNode.height;
currentNode.height = cellNodeHeight;
currentNode.totalHeight = currentNode.lastTotalHeight + cellNodeHeight;
// 从当前index起后续的cell缓存高度的lastTotalHeight和totalHeight需要加上当前cell变化的高度
for (let i = index + 1; i < this.virtualHeightCacheList.length; i++) {
const thisNode = this.virtualHeightCacheList[i];
thisNode.totalHeight += heightDis;
thisNode.lastTotalHeight += heightDis;
}
});
})
},
// 在使用动态高度虚拟列表时,若删除了列表数组中的某个item,需要调用此方法以更新高度缓存数组;index:删除的cell在列表中的位置,从0开始
didDeleteVirtualListCell(index) {
if (this.cellHeightMode !== Enum.CellHeightMode.Dynamic) return;
const currentNode = this.virtualHeightCacheList[index];
// 从当前index起后续的cell缓存高度的lastTotalHeight和totalHeight需要减去当前cell的高度
for (let i = index + 1; i < this.virtualHeightCacheList.length; i++) {
const thisNode = this.virtualHeightCacheList[i];
thisNode.totalHeight -= currentNode.height;
thisNode.lastTotalHeight -= currentNode.height;
}
// 将当前cell的高度信息从高度缓存数组中删除
this.virtualHeightCacheList.splice(index, 1);
},
// 手动触发虚拟列表渲染更新,可用于解决例如修改了虚拟列表数组中元素,但展示未更新的情况
updateVirtualListRender() {
// #ifndef APP-NVUE
if (this.finalUseVirtualList) {
this.updateVirtualListFromDataChange = true;
this.$nextTick(() => {
this.getCellHeightRetryCount.fixed = 0;
if (this.realTotalData.length) {
this.cellHeightMode === Enum.CellHeightMode.Fixed && this.isFirstPage && this._updateFixedCellHeight()
} else {
this._resetDynamicListState(!this.isUserPullDown);
}
this._updateVirtualScroll(this.oldScrollTop);
})
}
// #endif
},
// 初始化虚拟列表
_virtualListInit() {
this.$nextTick(() => {
u.delay(() => {
// 获取虚拟列表滚动区域的高度
this._getNodeClientRect('.zp-scroll-view').then(node => {
if (node) {
this.pagingOrgTop = node[0].top;
this.virtualPageHeight = node[0].height;
}
});
});
})
},
// cellHeightMode为fixed时获取第一个cell高度
_updateFixedCellHeight() {
if (!this.finalFixedCellHeight) {
this.$nextTick(() => {
u.delay(() => {
this._getNodeClientRect(`#zp-id-${0}`,this.finalUseInnerList).then(cellNode => {
if (!cellNode) {
if (this.getCellHeightRetryCount.fixed > 10) return;
this.getCellHeightRetryCount.fixed ++;
// 如果获取第一个cell的节点信息失败,则重试(不超过10次)
this._updateFixedCellHeight();
} else {
this.virtualCellHeight = cellNode[0].height;
this._updateVirtualScroll(this.oldScrollTop);
}
});
}, c.delayTime, 'updateFixedCellHeightDelay');
})
} else {
this.virtualCellHeight = this.finalFixedCellHeight;
}
},
// cellHeightMode为dynamic时获取每个cell高度
_updateDynamicCellHeight(list, dataFrom = 'bottom') {
const dataFromTop = dataFrom === 'top';
const heightCacheList = this.virtualHeightCacheList;
const currentCacheList = dataFromTop ? [] : heightCacheList;
let listTotalHeight = 0;
this.$nextTick(() => {
u.delay(async () => {
for (let i = 0; i < list.length; i++) {
const cellNode = await this._getNodeClientRect(`#zp-id-${list[i][this.virtualCellIndexKey]}`, this.finalUseInnerList);
const currentHeight = cellNode ? cellNode[0].height : 0;
if (!cellNode) {
if (this.getCellHeightRetryCount.dynamic <= 10) {
heightCacheList.splice(heightCacheList.length - i, i);
this.getCellHeightRetryCount.dynamic ++;
// 如果获取当前cell的节点信息失败,则重试(不超过10次)
this._updateDynamicCellHeight(list, dataFrom);
}
return;
}
const lastHeightCache = currentCacheList.length ? currentCacheList.slice(-1)[0] : null;
const lastTotalHeight = lastHeightCache ? lastHeightCache.totalHeight : 0;
// 缓存当前cell的高度信息:height-当前cell高度;lastTotalHeight-前面所有cell的高度总和;totalHeight-包含当前cell的所有高度总和
currentCacheList.push({
height: currentHeight,
lastTotalHeight,
totalHeight: lastTotalHeight + currentHeight
});
if (dataFromTop) {
listTotalHeight += currentHeight;
}
}
// 如果数据是从顶部拼接的
if (dataFromTop && list.length) {
for (let i = 0; i < heightCacheList.length; i++) {
// 更新之前所有项的缓存高度,需要加上此次插入的所有cell高度之和(因为是从顶部插入的cell)
const heightCacheItem = heightCacheList[i];
heightCacheItem.lastTotalHeight += listTotalHeight;
heightCacheItem.totalHeight += listTotalHeight;
}
this.virtualHeightCacheList = currentCacheList.concat(heightCacheList);
}
this._updateVirtualScroll(this.oldScrollTop);
}, c.delayTime, 'updateDynamicCellHeightDelay')
})
},
// 设置cellItem的index
_setCellIndex(list, dataFrom = 'bottom') {
let currentItemIndex = 0;
const cellIndexKey = this.virtualCellIndexKey;
([Enum.QueryFrom.Refresh, Enum.QueryFrom.Reload].indexOf(this.queryFrom) >= 0) && this._resetDynamicListState();
if (this.totalData.length) {
if (dataFrom === 'bottom') {
currentItemIndex = this.realTotalData.length;
const lastItem = this.realTotalData.length ? this.realTotalData.slice(-1)[0] : null;
if (lastItem && lastItem[cellIndexKey] !== undefined) {
currentItemIndex = lastItem[cellIndexKey] + 1;
}
} else if (dataFrom === 'top') {
const firstItem = this.realTotalData.length ? this.realTotalData[0] : null;
if (firstItem && firstItem[cellIndexKey] !== undefined) {
currentItemIndex = firstItem[cellIndexKey] - list.length;
}
}
} else {
this._resetDynamicListState();
}
for (let i = 0; i < list.length; i++) {
let item = list[i];
if (!item || Object.prototype.toString.call(item) !== '[object Object]') {
item = { item };
}
if (item[c.listCellIndexUniqueKey]) {
item = u.deepCopy(item);
}
item[cellIndexKey] = currentItemIndex + i;
item[c.listCellIndexUniqueKey] = `${this.virtualListKey}-${item[cellIndexKey]}`;
list[i] = item;
}
this.getCellHeightRetryCount.dynamic = 0;
this.cellHeightMode === Enum.CellHeightMode.Dynamic && this._updateDynamicCellHeight(list, dataFrom);
},
// 更新scroll滚动(虚拟列表滚动时触发)
_updateVirtualScroll(scrollTop, scrollDiff = 0) {
const currentTimeStamp = u.getTime();
scrollTop === 0 && this._resetTopRange();
if (scrollTop !== 0 && this.virtualScrollTimeStamp && currentTimeStamp - this.virtualScrollTimeStamp <= this.virtualScrollDisTimeStamp) {
return;
}
this.virtualScrollTimeStamp = currentTimeStamp;
let scrollIndex = 0;
const cellHeightMode = this.cellHeightMode;
if (cellHeightMode === Enum.CellHeightMode.Fixed) {
// 如果是固定高度的虚拟列表
// 计算当前滚动到的cell的index = scrollTop / 虚拟列表cell的固定高度
scrollIndex = parseInt(scrollTop / this.virtualCellHeight) || 0;
// 更新顶部和底部占位view的高度(为兼容考虑,顶部采用transformY的方式占位)
this._updateFixedTopRangeIndex(scrollIndex);
this._updateFixedBottomRangeIndex(scrollIndex);
} else if(cellHeightMode === Enum.CellHeightMode.Dynamic) {
// 如果是不固定高度的虚拟列表
// 当前滚动的方向
const scrollDirection = scrollDiff > 0 ? 'top' : 'bottom';
// 视图区域的高度
const rangePageHeight = this.virtualRangePageHeight;
// 顶部视图区域外的高度(顶部不需要渲染而是需要占位部分的高度)
const topRangePageOffset = scrollTop - rangePageHeight;
// 底部视图区域外的高度(底部不需要渲染而是需要占位部分的高度)
const bottomRangePageOffset = scrollTop + this.finalVirtualPageHeight + rangePageHeight;
let virtualBottomRangeIndex = 0;
let virtualPlaceholderBottomHeight = 0;
let reachedLimitBottom = false;
const heightCacheList = this.virtualHeightCacheList;
const lastHeightCache = !!heightCacheList ? heightCacheList.slice(-1)[0] : null;
let startTopRangeIndex = this.virtualTopRangeIndex;
// 如果是向底部滚动(顶部占位的高度不断增大,顶部的实际渲染cell数量不断减少)
if (scrollDirection === 'bottom') {
// 从顶部视图边缘的cell的位置开始向后查找
for (let i = startTopRangeIndex; i < heightCacheList.length; i++){
const heightCacheItem = heightCacheList[i];
// 如果查找到某个cell对应的totalHeight大于顶部视图区域外的高度,则此cell为顶部视图边缘的cell
if (heightCacheItem && heightCacheItem.totalHeight > topRangePageOffset) {
// 记录顶部视图边缘cell的index并更新顶部占位区域的高度并停止继续查找
this.virtualTopRangeIndex = i;
this.virtualPlaceholderTopHeight = heightCacheItem.lastTotalHeight;
break;
}
}
} else {
// 如果是向顶部滚动(顶部占位的高度不断减少,顶部的实际渲染cell数量不断增加)
let topRangeMatched = false;
// 从顶部视图边缘的cell的位置开始向前查找
for (let i = startTopRangeIndex; i >= 0; i--){
const heightCacheItem = heightCacheList[i];
// 如果查找到某个cell对应的totalHeight小于顶部视图区域外的高度,则此cell为顶部视图边缘的cell
if (heightCacheItem && heightCacheItem.totalHeight < topRangePageOffset) {
// 记录顶部视图边缘cell的index并更新顶部占位区域的高度并停止继续查找
this.virtualTopRangeIndex = i;
this.virtualPlaceholderTopHeight = heightCacheItem.lastTotalHeight;
topRangeMatched = true;
break;
}
}
// 如果查找不到,则认为顶部占位高度为0了,顶部cell不需要继续复用,重置topRangeIndex和placeholderTopHeight
!topRangeMatched && this._resetTopRange();
}
// 从顶部视图边缘的cell的位置开始向后查找
for (let i = this.virtualTopRangeIndex; i < heightCacheList.length; i++){
const heightCacheItem = heightCacheList[i];
// 如果查找到某个cell对应的totalHeight大于底部视图区域外的高度,则此cell为底部视图边缘的cell
if (heightCacheItem && heightCacheItem.totalHeight > bottomRangePageOffset) {
// 记录底部视图边缘cell的index并更新底部占位区域的高度并停止继续查找
virtualBottomRangeIndex = i;
virtualPlaceholderBottomHeight = lastHeightCache.totalHeight - heightCacheItem.totalHeight;
reachedLimitBottom = true;
break;
}
}
if (!reachedLimitBottom || this.virtualBottomRangeIndex === 0) {
this.virtualBottomRangeIndex = this.realTotalData.length ? this.realTotalData.length - 1 : this.pageSize;
this.virtualPlaceholderBottomHeight = 0;
} else {
this.virtualBottomRangeIndex = virtualBottomRangeIndex;
this.virtualPlaceholderBottomHeight = virtualPlaceholderBottomHeight;
}
this._updateVirtualList();
}
},
// 更新fixedCell模式下topRangeIndex&placeholderTopHeight
_updateFixedTopRangeIndex(scrollIndex) {
let virtualTopRangeIndex = this.virtualCellHeight === 0 ? 0 : scrollIndex - (parseInt(this.finalVirtualPageHeight / this.virtualCellHeight) || 1) * this.preloadPage;
virtualTopRangeIndex *= this.virtualListCol;
virtualTopRangeIndex = Math.max(0, virtualTopRangeIndex);
this.virtualTopRangeIndex = virtualTopRangeIndex;
this.virtualPlaceholderTopHeight = (virtualTopRangeIndex / this.virtualListCol) * this.virtualCellHeight;
},
// 更新fixedCell模式下bottomRangeIndex&placeholderBottomHeight
_updateFixedBottomRangeIndex(scrollIndex) {
let virtualBottomRangeIndex = this.virtualCellHeight === 0 ? this.pageSize : scrollIndex + (parseInt(this.finalVirtualPageHeight / this.virtualCellHeight) || 1) * (this.preloadPage + 1);
virtualBottomRangeIndex *= this.virtualListCol;
virtualBottomRangeIndex = Math.min(this.realTotalData.length, virtualBottomRangeIndex);
this.virtualBottomRangeIndex = virtualBottomRangeIndex;
this.virtualPlaceholderBottomHeight = (this.realTotalData.length - virtualBottomRangeIndex) * this.virtualCellHeight / this.virtualListCol;
this._updateVirtualList();
},
// 更新virtualList
_updateVirtualList() {
const shouldUpdateList = this.updateVirtualListFromDataChange || (this.lastVirtualTopRangeIndex !== this.virtualTopRangeIndex || this.lastVirtualBottomRangeIndex !== this.virtualBottomRangeIndex);
if (shouldUpdateList) {
this.updateVirtualListFromDataChange = false;
this.lastVirtualTopRangeIndex = this.virtualTopRangeIndex;
this.lastVirtualBottomRangeIndex = this.virtualBottomRangeIndex;
this.virtualList = this.realTotalData.slice(this.virtualTopRangeIndex, this.virtualBottomRangeIndex + 1);
}
},
// 重置动态cell模式下的高度缓存数据、虚拟列表和滚动状态
_resetDynamicListState(resetVirtualList = false) {
this.virtualHeightCacheList = [];
if (resetVirtualList) {
this.virtualList = [];
}
this.virtualTopRangeIndex = 0;
this.virtualPlaceholderTopHeight = 0;
},
// 重置topRangeIndex和placeholderTopHeight
_resetTopRange() {
this.virtualTopRangeIndex = 0;
this.virtualPlaceholderTopHeight = 0;
this._updateVirtualList();
},
// 检测虚拟列表当前滚动位置,如发现滚动位置不正确则重新计算虚拟列表相关参数(为解决在App中可能出现的长时间进入后台后打开App白屏的问题)
_checkVirtualListScroll() {
if (this.finalUseVirtualList) {
this.$nextTick(() => {
this._getNodeClientRect('.zp-paging-touch-view').then(node => {
const currentTop = node ? node[0].top : 0;
if (!node || (currentTop === this.pagingOrgTop && this.virtualPlaceholderTopHeight !== 0)) {
this._updateVirtualScroll(0);
}
});
})
}
},
// 处理使用内置列表时点击了cell事件
_innerCellClick(item, index) {
this.$emit('innerCellClick', item, index);
}
}
}
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more