Compare commits

...

24 Commits

Author SHA1 Message Date
6b31ad9067 Merge remote-tracking branch 'origin/dev-wdb' into dev-hjf 2026-02-09 09:58:02 +08:00
0dfb05fe97 Merge commit 'f552ad2b4e333631f5e7fbd0f3f089e93e15e6b0' into dev-wdb 2026-02-09 09:17:55 +08:00
24f201729b Merge branch 'dev-wdb' of http://175.27.226.205:3000/huxuejian/ykt-wxapp into dev-wdb 2026-02-09 09:16:57 +08:00
cc9b56c5b4 提交 2026-02-09 09:16:52 +08:00
huxuejian
9a251ddcb0 Merge branch 'dev-wdb' of http://175.27.226.205:3000/huxuejian/ykt-wxapp into dev-wdb 2026-02-09 09:12:20 +08:00
huxuejian
56fe64e2b1 fix: 问题修复 2026-02-09 09:11:13 +08:00
9cdee03753 no message 2026-02-08 15:03:47 +08:00
b98f835ab1 no message 2026-02-08 14:11:59 +08:00
b3a7310f0b Merge branch 'dev-wdb' of http://175.27.226.205:3000/huxuejian/ykt-wxapp into dev-wdb 2026-02-08 13:53:25 +08:00
8963a60391 no message 2026-02-08 13:53:22 +08:00
huxuejian
c53eb4defa Update useDebounce.js 2026-02-08 13:35:05 +08:00
huxuejian
dae3b4d125 Merge branch 'dev-wdb' of http://175.27.226.205:3000/huxuejian/ykt-wxapp into dev-wdb 2026-02-08 10:41:55 +08:00
huxuejian
8f25cc68d5 fix: 问题修复 2026-02-08 10:41:53 +08:00
30288bfa9f no message 2026-02-08 10:38:33 +08:00
ae109a13e3 Merge branch 'dev-wdb' of http://175.27.226.205:3000/huxuejian/ykt-wxapp into dev-wdb
# Conflicts:
#	pages/case/components/archive-detail/follow-up-manage-tab.vue
#	store/account.js
2026-02-06 17:36:57 +08:00
e9bea571ea no message 2026-02-06 17:33:42 +08:00
huxuejian
3da2ca791a fix: 页面调整 2026-02-06 17:10:48 +08:00
huxuejian
43d367fe5c fix: 分享功能开发 2026-02-06 15:05:43 +08:00
huxuejian
ee50b21f41 fix:问题修复 2026-02-06 14:45:40 +08:00
huxuejian
73bebf30a9 Update invite-patient.vue 2026-02-06 14:38:48 +08:00
huxuejian
d90399e08a Update invite-patient.vue 2026-02-06 14:38:11 +08:00
huxuejian
21a59da3d2 Merge branch 'dev-wdb' of http://175.27.226.205:3000/huxuejian/ykt-wxapp into dev-wdb 2026-02-06 14:37:26 +08:00
huxuejian
b79a71122d feat:医生患者邀请分享调整 2026-02-06 14:37:24 +08:00
4b75ebf647 no message 2026-02-06 13:57:11 +08:00
50 changed files with 5377 additions and 1692 deletions

View File

@ -1,9 +1,10 @@
MP_API_BASE_URL=https://patient.youcan365.com
MP_IMAGE_URL=https://patient.youcan365.com
MP_CACHE_PREFIX=development
MP_WX_APP_ID=wx6ee11733526b4f04
MP_WX_APP_ID=wx1d8337a40c11d66c
MP_CORP_ID=wwe3fb2faa52cf9dfb
MP_TIM_SDK_APP_ID=1600123876
MP_INVITE_TEAMMATE_QRCODE=https://patient.youcan365.com/invite-teammate
MP_INVITE_PATIENT_QRCODE=https://patient.youcan365.com/invite-patient
MP_PATIENT_PAGE_BASE_URL= 'https://www.youcan365.com/patientDeploy/#/'
MP_SURVEY_URL= 'https://www.youcan365.com/surveyDev/#/pages/survey/survey'

View File

@ -1,9 +1,10 @@
MP_API_BASE_URL=http://192.168.60.2:8080
MP_IMAGE_URL=https://patient.youcan365.com
MP_CACHE_PREFIX=development
MP_WX_APP_ID=wx6ee11733526b4f04
MP_WX_APP_ID=wx1d8337a40c11d66c
MP_CORP_ID=wwe3fb2faa52cf9dfb
MP_TIM_SDK_APP_ID=1600123876
MP_INVITE_TEAMMATE_QRCODE=https://patient.youcan365.com/invite-teammate
MP_INVITE_PATIENT_QRCODE=https://patient.youcan365.com/invite-patient
MP_PATIENT_PAGE_BASE_URL= 'https://www.youcan365.com/patientDeploy/#/'
MP_SURVEY_URL= 'https://www.youcan365.com/surveyDev/#/pages/survey/survey'

View File

@ -1,9 +1,10 @@
MP_API_BASE_URL=http://localhost:8080
MP_IMAGE_URL=https://patient.youcan365.com
MP_CACHE_PREFIX=development
MP_WX_APP_ID=wx6ee11733526b4f04
MP_WX_APP_ID=wx1d8337a40c11d66c
MP_CORP_ID=wwe3fb2faa52cf9dfb
MP_TIM_SDK_APP_ID=1600123876
MP_INVITE_TEAMMATE_QRCODE=https://patient.youcan365.com/invite-teammate
MP_INVITE_PATIENT_QRCODE=https://patient.youcan365.com/invite-patient
MP_PATIENT_PAGE_BASE_URL= 'https://www.youcan365.com/patientDeploy/#/'
MP_SURVEY_URL= 'https://www.youcan365.com/surveyDev/#/pages/survey/survey'

20
App.vue
View File

@ -5,18 +5,20 @@ import { globalTimChatManager } from "@/utils/tim-chat.js";
export default {
onLaunch: function () {
// pinia store getActivePinia
const { account, login, initIMAfterLogin } = useAccountStore();
const { account, login, initIMAfterLogin, getDoctorInfo } =
useAccountStore();
// IM
if (account && account.openid) {
console.log("App Launch: 已有登录信息,初始化 IM");
initIMAfterLogin().catch(err => {
console.error('IM初始化失败:', err);
initIMAfterLogin().catch((err) => {
console.error("IM初始化失败:", err);
});
getDoctorInfo();
} else {
console.log("App Launch: 无登录信息,开始登录");
login().catch(err => {
console.error('自动登录失败:', err);
login().catch((err) => {
console.error("自动登录失败:", err);
});
}
},
@ -40,7 +42,7 @@ export default {
</script>
<style lang="scss">
$primary-color: #0877F1;
$primary-color: #0877f1;
page {
height: 100%;
@ -82,7 +84,7 @@ uni-button[type="primary"]:not([disabled]):active {
.relative {
position: relative;
}
.absolute{
.absolute {
position: absolute;
}
@ -159,7 +161,7 @@ uni-button[type="primary"]:not([disabled]):active {
}
.bg-light-text-color::after {
content: '';
content: "";
position: absolute;
top: 0;
left: 0;

View File

@ -526,9 +526,9 @@ async function sendFollowUp(todo) {
content: todo.sendContent,
});
}
console.log("==============>fileList", todo.fileList);
// 2.
debugger;
if (Array.isArray(todo.fileList)) {
for (const file of todo.fileList) {
if (file.type === "image" && file.URL) {
@ -538,20 +538,21 @@ async function sendFollowUp(todo) {
content: file.URL,
name: file.file?.name || file.name || "图片",
});
} else if (file.type === "article" && file.file?.url) {
//
} else if (file.file.type === "article" && file.file?.url) {
// - URL id
const articleId = extractIdFromUrl(file.file.url);
messages.push({
type: "article",
content: {
_id: file.file?._id || file._id,
title: file.file?.name || file.name || "宣教文章",
_id: articleId,
title: file.file?.name || "宣教文章",
url: file.file?.url || file.URL,
subtitle: file.file?.subtitle || "",
cover: file.file?.cover || "",
articleId: file.file?._id || file._id,
articleId: articleId,
},
});
} else if (file.type === "questionnaire" && file.file?.surveryId) {
} else if (file.file.type === "questionnaire" && file.file?.surveryId) {
//
messages.push({
type: "questionnaire",
@ -581,6 +582,30 @@ async function sendFollowUp(todo) {
}
}
/**
* URL 中提取 id 参数
* @param {string} url - 完整的 URL
* @returns {string} 提取出的 id
*/
function extractIdFromUrl(url) {
if (!url) return "";
try {
// : https://www.youcan365.com/patientDeploy/#/pages/article/index?id=267epkhd3xbklcnbf0f45gzp1769567841991&corpId=...
const urlObj = new URL(url);
const id = urlObj.searchParams.get("id");
if (id) return id;
// 使
const match = url.match(/[?&]id=([^&]+)/);
return match ? decodeURIComponent(match[1]) : "";
} catch (error) {
console.error("解析 URL 失败:", error);
// 使
const match = url.match(/[?&]id=([^&]+)/);
return match ? decodeURIComponent(match[1]) : "";
}
}
// ---- filter popup ----
const filterPopupRef = ref(null);
const state = ref(null);

File diff suppressed because it is too large Load Diff

View File

@ -19,7 +19,8 @@
</text>
<text v-else class="user-name text-black text-lg font-semibold" @click="editProfile()">请完善信息</text>
<view class="flex items-center mt-5">
<view v-if="!doctorInfo || !doctorInfo.anotherName" class="status-tag tag-orange mr-10">
<view v-if="!doctorInfo || !doctorInfo.anotherName" class="status-tag tag-orange mr-10"
@click="editProfile()">
<text class="tag-text text-white">信息待完善</text>
</view>
<view v-if="certStatus" class="px-10 py-3 text-sm rounded-full" :class="certStatus.classnames"
@ -34,7 +35,7 @@
<view class="flex items-center">
<view class="action-btn flex-col items-center mr-10" @click="invitePatient()">
<image class="mb-5 qrcode-icon" src="/static/work/qrcode.svg" />
<text class="action-text text-dark text-sm">邀请</text>
<text class="action-text text-dark text-sm">邀请患者</text>
</view>
<view class="action-btn flex-col items-center" @click="handleMore">
<image class="mb-5 qrcode-icon" src="/static/work/more.svg" />

View File

@ -1,11 +1,17 @@
<template>
<view class="pt-lg px-15 flex flex-col items-center text-center">
<image src="/static/logo-plain.png" class="logo"></image>
<view class="mt-15 text-xl font-semibold text-dark">健康</view>
<view class="mt-12 text-base text-dark">生命全周期健康管理伙伴</view>
<view class="mt-15 text-xl font-semibold text-dark">助手</view>
<view class="mt-12 text-base text-dark">全周期健康管理伙伴</view>
</view>
<view class="login-btn-wrap">
<button v-if="checked" class="login-btn" type="primary" open-type="getPhoneNumber" @getphonenumber="getPhoneNumber">
<button
v-if="checked"
class="login-btn"
type="primary"
open-type="getPhoneNumber"
@getphonenumber="getPhoneNumber"
>
手机号快捷登录
</button>
<!-- <button v-if="checked" class="login-btn" type="primary" @click="getPhoneNumber()">
@ -15,7 +21,10 @@
手机号快捷登录
</button>
</view>
<view class="flex items-center justify-center mt-12 px-15" @click="checked = !checked">
<view
class="flex items-center justify-center mt-12 px-15"
@click="checked = !checked"
>
<checkbox :checked="checked" style="transform: scale(0.7)" />
<view class="text-sm text-gray">我已阅读并同意</view>
<view class="text-sm text-primary">用户协议</view>
@ -76,8 +85,8 @@ async function getPhoneNumber(e) {
await attempToPage(redirectUrl.value);
} else if (res && !(doctorInfo.value && doctorInfo.value.anotherName)) {
uni.redirectTo({
url: '/pages/work/profile'
})
url: "/pages/work/profile",
});
} else if (res) {
toHome();
}

View File

@ -61,8 +61,8 @@ useLoad(options => {
acc[key] = value;
return acc;
}, {})
if (data.type === 'inviteTeam') {
getTeam(data.teamId)
if (options.type === 'inviteTeam' || data.type === 'inviteTeam') {
getTeam(data.teamId || options.teamId)
}
})
</script>

View File

@ -1,19 +1,16 @@
// SCSS 变量定义
$font-size-text: 28rpx;
$font-size-tip: 24rpx;
$font-size-text: 30rpx;
$font-size-tip: 28rpx;
$font-size-title: 32rpx;
$text-color-sub: #999;
$primary-color: #0877F1;
.chat-page {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f5f5f5;
overflow: hidden;
}
/* 患者信息栏样式 */
@ -90,6 +87,7 @@ $primary-color: #0877F1;
flex: 1;
box-sizing: border-box;
overflow-x: hidden;
min-height: 0;
}
.chat-content-compressed {
@ -333,7 +331,7 @@ $primary-color: #0877F1;
}
.message-text {
font-size: $font-size-text;
font-size: 30rpx;
line-height: 1.4;
word-wrap: break-word;
word-break: break-all;
@ -359,6 +357,7 @@ $primary-color: #0877F1;
position: relative;
z-index: 200;
padding-bottom: env(safe-area-inset-bottom);
flex-shrink: 0;
}
.input-toolbar {
@ -378,6 +377,14 @@ $primary-color: #0877F1;
padding: 0;
}
.voice-toggle-icon {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
}
.plus-btn {
width: 56rpx;
height: 56rpx;
@ -411,6 +418,25 @@ $primary-color: #0877F1;
background: #2456c7;
}
.disabled-btn {
background: #ccc;
color: #fff;
font-size: 28rpx;
font-weight: 600;
border: none;
border-radius: 40rpx;
height: 56rpx;
min-width: 112rpx;
padding: 0 32rpx;
box-shadow: 0 2rpx 8rpx rgba(200, 200, 200, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
white-space: nowrap;
cursor: not-allowed;
}
.input-area {
flex: 1;
margin: 0 8rpx;

View File

@ -57,10 +57,12 @@ const props = defineProps({
default: "",
},
});
const emit = defineEmits(["streamText", "clearInput"]);
const emit = defineEmits(["streamText", "clearInput", "generatingStateChange"]);
const typeSelectorRef = ref(null);
const progressRef = ref(null);
const isGenerating = ref(false);
const buttons = ref([
{
id: "followUp",
@ -183,6 +185,8 @@ const streamTextToInput = (text) => {
//
emit("clearInput");
isGenerating.value = true;
emit("generatingStateChange", true);
let currentIndex = 0;
const speed = 50; //
@ -196,6 +200,8 @@ const streamTextToInput = (text) => {
currentIndex++;
} else {
clearInterval(streamInterval);
isGenerating.value = false;
emit("generatingStateChange", false);
}
}, speed);
}, 100);
@ -363,6 +369,11 @@ const handleRegenerateMedicalCase = (data) => {
const type = { id: data.caseType };
handleCaseTypeSelect(type);
};
//
defineExpose({
isGenerating,
});
</script>
<style scoped lang="scss">

View File

@ -2,24 +2,27 @@
<view class="input-section">
<view class="input-toolbar">
<view @click="toggleVoiceInput" class="voice-toggle-btn">
<uni-icons v-if="showVoiceInput" fontFamily="keyboard" :size="28">{{ '&#xe61a;' }}</uni-icons>
<image v-if="showVoiceInput" src="/static/jianpan.png" class="voice-toggle-icon" mode="aspectFit"></image>
<uni-icons v-else type="mic" size="28" color="#666" />
</view>
<view class="input-area">
<textarea v-if="!showVoiceInput" class="text-input" v-model="inputText" placeholder="我来说两句..."
@confirm="sendTextMessage" @focus="handleInputFocus" @input="handleInput"
:auto-height="true" :show-confirm-bar="false" :adjust-position="true" :cursor-spacing="40"
:auto-height="true" :show-confirm-bar="false" :adjust-position="true" :cursor-spacing="30"
/>
<input v-else class="voice-input-btn" :class="{ recording: isRecording }" @touchstart="startRecord"
@touchmove="onRecordTouchMove" @touchend="stopRecord" @touchcancel="cancelRecord" :placeholder="isRecording ? '松开发送' : '按住说话'" disabled>
</input>
</view>
<button v-if="inputText.trim()" class="send-btn" @click="sendTextMessage">
<button v-if="inputText.trim() && !props.isGenerating" class="send-btn" @click="sendTextMessage">
发送
</button>
<view v-else class="plus-btn" @click="toggleMorePanel()">
<view v-else-if="!inputText.trim() && !props.isGenerating" class="plus-btn" @click="toggleMorePanel()">
<uni-icons type="plusempty" size="28" color="#666" />
</view>
<view v-else class="send-btn disabled-btn">
<text>生成中...</text>
</view>
</view>
<view class="more-panel" v-if="showMorePanel">
<view v-for="btn in morePanelButtons" :key="btn.text" class="more-btn" @click="btn.action">
@ -80,6 +83,7 @@ const props = defineProps({
patientId: { type: String, default: "" },
corpId: { type: String, default: "" },
orderStatus: { type: String, default: "" },
isGenerating: { type: Boolean, default: false },
});
// Emits
@ -463,7 +467,7 @@ const morePanelButtons = computed(() => {
// ""
buttons.push({
text: "开启会话",
icon: "/static/icon/kaiqihuihua.png",
icon: "/static/icon/openChat.png",
action: handleOpenConsult,
});
} else {

View File

@ -10,13 +10,13 @@
</template>
<script setup>
import { computed } from 'vue';
import { computed } from "vue";
const props = defineProps({
message: {
type: Object,
default: () => ({})
}
default: () => ({}),
},
});
const payload = computed(() => props.message?.payload || {});
@ -26,16 +26,17 @@ const systemMessageData = computed(() => {
try {
// payload.data
if (payload.value.data) {
const data = typeof payload.value.data === 'string'
? JSON.parse(payload.value.data)
: payload.value.data;
if (data.type === 'system_message') {
const data =
typeof payload.value.data === "string"
? JSON.parse(payload.value.data)
: payload.value.data;
if (data.type === "system_message") {
return data;
}
}
} catch (e) {
console.error('解析系统消息失败:', e);
console.error("解析系统消息失败:", e);
}
return null;
});
@ -44,12 +45,12 @@ const systemMessageData = computed(() => {
const extension = computed(() => {
try {
if (payload.value.extension) {
return typeof payload.value.extension === 'string'
return typeof payload.value.extension === "string"
? JSON.parse(payload.value.extension)
: payload.value.extension;
}
} catch (e) {
console.error('解析扩展信息失败:', e);
console.error("解析扩展信息失败:", e);
}
return {};
});
@ -65,20 +66,20 @@ const text = computed(() => {
if (systemMessageData.value?.messageType) {
const messageType = systemMessageData.value.messageType;
switch (messageType) {
case 'consult_pending':
return '患者已发起咨询申请,请及时接诊';
case 'consult_accepted':
return '医生已接诊';
case 'consult_rejected':
return '医生暂时无法接诊';
case 'consult_ended':
return '问诊已结束';
case 'consult_timeout':
return '问诊已超时';
case 'consult_reopened':
return '会话已重新开启';
case "consult_pending":
return "患者已发起咨询申请,请及时接诊";
case "consult_accepted":
return "医生已接诊";
case "consult_rejected":
return "医生暂时无法接诊";
case "consult_ended":
return "问诊已结束";
case "consult_timeout":
return "问诊已超时";
case "consult_reopened":
return "会话已重新开启";
default:
return systemMessageData.value.content || '[系统消息]';
return systemMessageData.value.content || "[系统消息]";
}
}
@ -88,7 +89,7 @@ const text = computed(() => {
}
// payload.data
if (payload.value.data && typeof payload.value.data === 'string') {
if (payload.value.data && typeof payload.value.data === "string") {
// data JSON
try {
JSON.parse(payload.value.data);
@ -97,7 +98,7 @@ const text = computed(() => {
}
}
return '[系统消息]';
return "[系统消息]";
});
//
@ -111,24 +112,23 @@ const notifyText = computed(() => {
if (systemMessageData.value) {
const messageType = systemMessageData.value.messageType;
switch (messageType) {
case 'consult_pending':
return '待接诊';
case 'consult_rejected':
return '已拒绝';
case 'consult_timeout':
return '已超时';
case 'consult_accepted':
return '已接诊';
case 'consult_ended':
return '已结束';
case "consult_pending":
return "待接诊";
case "consult_rejected":
return "已拒绝";
case "consult_timeout":
return "已超时";
case "consult_accepted":
return "已接诊";
case "consult_ended":
return "已结束";
default:
return '';
return "";
}
}
return '';
return "";
});
</script>
<style scoped lang="scss">

View File

@ -140,12 +140,14 @@
!showConsultAccept &&
orderStatus === 'processing'
"
ref="aiAssistantRef"
:groupId="groupId"
:patientAccountId="chatInfo.userID || ''"
:patientId="patientId"
:corpId="corpId"
@streamText="handleStreamText"
@clearInput="handleClearInput"
@generatingStateChange="handleGeneratingStateChange"
/>
<!-- 聊天输入组件 -->
@ -165,6 +167,7 @@
:corpId="corpId"
:patientInfo="patientInfo"
:orderStatus="orderStatus"
:isGenerating="isGenerating"
@scrollToBottom="() => scrollToBottom(true)"
@messageSent="() => scrollToBottom(true)"
@endConsult="handleEndConsult"
@ -217,6 +220,8 @@ const { initIMAfterLogin } = useAccountStore();
//
const chatInputRef = ref(null);
const aiAssistantRef = ref(null);
const isGenerating = ref(false);
const groupId = ref("");
const { chatMember, getGroupInfo, getUserAvatar } = useGroupChat(groupId);
@ -316,7 +321,11 @@ const fetchGroupOrderStatus = async () => {
const teamName = result.data.team?.name || "群聊";
updateNavigationTitle(teamName);
teamId.value = result.data.teamId || result.data.team?.teamId || result.data.team?._id || "";
teamId.value =
result.data.teamId ||
result.data.team?.teamId ||
result.data.team?._id ||
"";
//
if (result.data.patient) {
@ -396,13 +405,13 @@ const checkLoginAndInitTIM = async () => {
});
const success = await initIMAfterLogin();
uni.hideLoading();
if (!success) {
uni.showToast({
title: "IM连接失败请重试",
icon: "none",
});
return;
}
// if (!success) {
// uni.showToast({
// title: "IM",
// icon: "none",
// });
// return;
// }
} else if (!timChatManager.isLoggedIn) {
uni.showLoading({
title: "重连中...",
@ -777,7 +786,7 @@ onShow(() => {
startIMMonitoring(30000);
// 访
uni.$on('send-followup-message', handleSendFollowUpMessage);
uni.$on("send-followup-message", handleSendFollowUpMessage);
});
// 访
@ -786,7 +795,7 @@ const handleSendFollowUpMessage = async (data) => {
if (chatInputRef.value) {
// 访
chatInputRef.value.setInputText(data.content);
//
setTimeout(() => {
if (chatInputRef.value) {
@ -795,10 +804,10 @@ const handleSendFollowUpMessage = async (data) => {
}, 100);
}
} catch (error) {
console.error('发送回访任务消息失败:', error);
console.error("发送回访任务消息失败:", error);
uni.showToast({
title: '发送失败,请重试',
icon: 'none',
title: "发送失败,请重试",
icon: "none",
});
}
};
@ -832,6 +841,11 @@ const handleClearInput = () => {
}
};
//
const handleGeneratingStateChange = (generating) => {
isGenerating.value = generating;
};
//
defineExpose({
sendCommonPhrase,
@ -898,7 +912,7 @@ const handleRejectReasonConfirm = async (reason) => {
const currentGroupId = chatInfo.value.conversationID.replace("GROUP", "");
//
const result = await api("sendConsultRejectedMessage", {
const result = await api("rejectConsultation", {
groupId: currentGroupId,
memberName,
reason,
@ -1028,7 +1042,7 @@ onUnmounted(() => {
timChatManager.setCallback("onError", null);
// 访
uni.$off('send-followup-message', handleSendFollowUpMessage);
uni.$off("send-followup-message", handleSendFollowUpMessage);
});
</script>

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@
<view class="bg-white px-10 mb-10 rounded">
<!-- 填写认证资料的时候岗位必填 -->
<common-cell :required="type === 'cert'" title="job" :name="rule.job.name">
<picker mode="selector" :disable="rule.job.disable" :range="jobOptions" range-key="name"
<picker mode="selector" :disabled="rule.job.disabled" :range="jobOptions" range-key="name"
@change="changeJob($event)">
<view class="flex-grow flex items-center justify-end">
<view v-if="jobStr" class="text-base text-base">{{ jobStr }}</view>
@ -27,7 +27,7 @@
</picker>
</common-cell>
<common-cell title="title" :name="rule.title.name">
<picker mode="selector" :disable="rule.title.disable" :range="titleOptions" @change="changeTitle($event)">
<picker mode="selector" :disabled="rule.title.disabled" :range="titleOptions" @change="changeTitle($event)">
<view class="flex-grow flex items-center justify-end">
<view class="text-base text-base">{{ formData.title }}</view>
<uni-icons color="#999" type="right" size="16" />
@ -97,23 +97,27 @@ const jobStr = computed(() => {
return jobs[0] && job[jobs[0]] ? job[jobs[0]] : "";
});
const rule = computed(() => {
if (
doctorInfo.value &&
["verified", "verifying"].includes(doctorInfo.value.verifyStatus)
) {
return {
anotherName: { name: "姓名 (不可修改)", required: false, disabled: true },
job: { name: "岗位 (不可修改)", disabled: true },
title: { name: "职称 (不可修改)", disabled: true },
dept: { name: "科室 (不可修改)", disabled: true },
};
}
return {
const data = {
anotherName: { name: "姓名", required: true, disabled: false },
job: { name: "岗位", disabled: false },
title: { name: "职称", disabled: false },
dept: { name: "科室", disabled: false },
};
}
if (doctorInfo.value && ["verified", "verifying"].includes(doctorInfo.value.verifyStatus)) {
data.anotherName.name = "姓名 (不可修改)";
data.anotherName.required = false;
data.anotherName.disabled = true;
data.job.name = "岗位 (不可修改)";
data.job.disabled = true;
data.title.name = doctorInfo.value.verifyStatus === 'verified' ? "职称 (不可修改)" : "职称";
data.title.disabled = doctorInfo.value.verifyStatus === 'verified';
data.dept.name = doctorInfo.value.verifyStatus === 'verified' ? "科室 (不可修改)" : "科室";
data.dept.disabled = doctorInfo.value.verifyStatus === 'verified';
}
return data
});
const deptNames = computed(() => {
const hlwDepts = formData.value.hlwDepts || [];
@ -126,20 +130,6 @@ const genderOptions = [
{ label: "女", value: "1" },
];
//
const openDepartmentSelect = () => {
uni.navigateTo({
url: "/pages/work/department-select",
events: {
deptSelected: ({ name, deptId }) => {
formData.value.department = name || "";
formData.value.departmentName = name || "";
formData.value.departmentId = deptId || "";
},
},
});
};
function back() {
const pages = getCurrentPages();
if (pages.length > 1) {
@ -172,7 +162,7 @@ function onChange({ title, value }) {
function changeJob(e) {
const data = jobOptions[e.detail.value];
form.value.job = data.value;
form.value.job = [data.value];
}
function changeTitle(e) {
@ -195,6 +185,7 @@ function toCert() {
}
function selectDept() {
if(rule.value.dept.disabled) return;
const eventName = `selectDept_${Date.now()}`
const deptIds = (formData.value.hlwDepts || []).map(i => i.deptId).filter(Boolean).join(',')
uni.navigateTo({

View File

@ -5,21 +5,21 @@
柚康企微客服
</view>
<view class="flex justify-center overflow-hidden">
<uqrcode canvas-id="qrcode" value="暂无二维码" :options="options"></uqrcode>
<image :show-menu-by-longpress="true" class="qrcode" src="/static/work/service.png" />
</view>
<view class="mt-10 px-15 text-base text-dark leading-normal text-center">
<!-- <view class="mt-10 px-15 text-base text-dark leading-normal text-center">
扫码或长按添加柚康企微客服
</view>
<view class="mt-10 px-15 text-base text-dark leading-normal text-center">
我们将为您提供软件使用咨询服务并支持补充病历宣教问卷回访等多种工作模板
</view>
</view> -->
</view>
</view>
</template>
<script setup>
import { ref } from "vue";
const options = { margin: 10 }
</script>
<style></style>
<style>
.qrcode {
width: 400rpx;
height: 400rpx;
}
</style>

View File

@ -23,10 +23,10 @@
<view class="w-0 flex-grow">
<view class="flex items-center">
<view class="mr-5 text-lg font-semibold text-dark truncate">{{ i.anotherName }}</view>
<view v-if="i.isCreator"
<!-- <view v-if="i.isCreator"
class="mr-5 px-10 flex-shrink-0 border-auto text-sm leading-normal text-primary rounded-full">
创建人
</view>
</view> -->
<view v-if="i.isLeader"
class="px-10 flex-shrink-0 border-auto text-sm leading-normal text-primary rounded-full">
团队负责人
@ -76,7 +76,7 @@ const memberList = computed(() => team.value && Array.isArray(team.value.memberL
const teammates = computed(() => {
const memberLeaderList = team.value && Array.isArray(team.value.memberLeaderList) ? team.value.memberLeaderList : [];
const res = memberList.value.reduce((data, item) => {
if (item.userid === doctorInfo.value.userid) {
if (item.userid && item.userid === team.value.creator) {
data.creator.push(item)
} else if (memberLeaderList.includes(item.userid)) {
data.leaders.push(item)
@ -85,6 +85,7 @@ const teammates = computed(() => {
}
return data
}, { creator: [], leaders: [], members: [] });
console.log(res)
return [
...res.creator.map(i => ({ ...i, isCreator: true, isLeader: true })),
...res.leaders.map(i => ({ ...i, isLeader: true })),
@ -204,4 +205,5 @@ useShow(() => {
-webkit-line-clamp: 3;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,175 @@
export function getInvitePatientPoster(name, qrcode) {
return {
css: {
width: "750rpx",
paddingTop: '40rpx',
paddingBottom: "160rpx",
background: "white"
},
views: [
{
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)",
paddingBottom: "120rpx",
paddingTop: '40rpx',
},
views: [
{
text: name,
type: "text",
css: {
width: "440rpx",
paddingRight: "30rpx",
marginLeft: "100rpx",
boxSizing: "border-box",
lineClamp: 3,
color: "#333333",
lineHeight: "1.5em",
fontSize: "36rpx",
fontWeight: "bold",
textAlign: 'center'
},
},
{
text: qrcode,
type: "qrcode",
css: {
marginTop: '30rpx',
marginLeft: '70rpx',
width: "480rpx",
height: "480rpx"
},
},
{
text: " 微信扫一扫上面的二维码",
type: "text",
css: {
marginTop: '60rpx',
width: "440rpx",
paddingRight: "30rpx",
marginLeft: "100rpx",
boxSizing: "border-box",
color: "#666",
lineHeight: "1.5em",
fontSize: "28rpx",
textAlign: 'center'
},
},
],
type: "view"
}
]
}
}
export function getInviteMatePoster(name, qrcode) {
return {
css: {
width: "750rpx",
paddingTop: '40rpx',
paddingBottom: "160rpx",
background: "white"
},
views: [
{
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)",
paddingBottom: "120rpx",
paddingTop: '40rpx',
},
views: [
{
text: name,
type: "text",
css: {
width: "440rpx",
paddingRight: "30rpx",
marginLeft: "100rpx",
boxSizing: "border-box",
lineClamp: 3,
color: "#333333",
lineHeight: "1.5em",
fontSize: "36rpx",
fontWeight: "bold",
textAlign: 'center'
},
},
{
text: '成员邀请码',
type: "text",
css: {
width: "440rpx",
paddingRight: "30rpx",
marginLeft: "100rpx",
marginTop: '30rpx',
boxSizing: "border-box",
lineClamp: 1,
color: "#333333",
lineHeight: "1.5em",
fontSize: "36rpx",
fontWeight: "bold",
textAlign: 'center'
},
},
{
text: qrcode,
type: "qrcode",
css: {
marginTop: '30rpx',
marginLeft: '70rpx',
width: "480rpx",
height: "480rpx"
},
},
{
text: " 微信扫一扫上面的二维码",
type: "text",
css: {
marginTop: '60rpx',
width: "440rpx",
paddingRight: "30rpx",
marginLeft: "100rpx",
boxSizing: "border-box",
color: "#666",
lineHeight: "1.5em",
fontSize: "28rpx",
textAlign: 'center'
},
},
{
text: " 加入我的团队,协同开展患者管理服务",
type: "text",
css: {
marginTop: '20rpx',
width: "440rpx",
paddingRight: "30rpx",
marginLeft: "100rpx",
boxSizing: "border-box",
color: "#666",
lineHeight: "1.5em",
fontSize: "28rpx",
textAlign: 'center'
},
},
],
type: "view"
}
]
}
}

View File

@ -41,27 +41,39 @@
微信扫一扫上面的二维码
</view>
<view class="px-15 text-base text-gray leading-normal text-center">进入团队首页即可发起线上咨询建档授权等服务</view>
<view class="mt-10 flex px-15">
<view class="mr-10 border-auto rounded py-10 text-base text-primary text-center flex-grow" @click="saveImage">保存图片</view>
<button class=" bg-primary rounded py-10 text-base text-white text-center flex-grow" open-type="share">分享微信</button>
<view class="mt-10 flex px-15 leading-normal text-center">
<button class="mr-10 border-auto rounded py-5 text-base text-primary flex-grow" @click="saveImage('save')">
保存图片
</button>
<button class="bg-primary rounded py-5 text-base text-white flex-grow" @click="saveImage('share')">分享微信</button>
</view>
</view>
<view class="canvas-box">
<l-painter ref="painterRef" :board="poster" />
</view>
</view>
<rename-popup :team="team" :visible="visible" @close="visible = false" @change="change" />
</template>
<script setup>
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import dayjs from "dayjs";
import { onLoad } from "@dcloudio/uni-app";
import useAccountStore from "@/store/account.js";
import useGuard from '@/hooks/useGuard';
import api from "@/utils/api.js";
import { toast, saveImageToPhotosAlbum, shareToWeChat } from "@/utils/widget";
import { toast, shareToWeChat } from "@/utils/widget";
import { getInvitePatientPoster } from './base-poster-data';
import emptyData from "@/components/empty-data.vue";
import renamePopup from "./rename-popup.vue";
const env = __VITE_ENV__;
const inviteQrcode = env.MP_INVITE_PATIENT_QRCODE;
const options = { margin: 10 }
const painterRef = ref()
const poster = ref({})
const { useShow } = useGuard();
const { doctorInfo, account } = storeToRefs(useAccountStore());
@ -69,7 +81,6 @@ const current = ref(0);
const list = ref([]);
const visible = ref(false);
const teamId = ref('')
const qrcodes = ref(null);
const indicator = computed(() => ({
prev: current.value > 0,
next: current.value < list.value.length - 1
@ -91,7 +102,9 @@ async function getTeams() {
id: i._id,
teamId: i.teamId,
name: i.name,
qrcode: i.qrcodes && i.qrcodes[0] && i.qrcodes[0].qrcode ? i.qrcodes[0].qrcode : ''
qrcode: `${inviteQrcode}?teamId=${i.teamId}&corpId=${i.corpId}`
// qrcode: i.qrcodes && i.qrcodes[0] && i.qrcodes[0].qrcode ? i.qrcodes[0].qrcode : ''
})) : [];
if (teamId.value) {
const idx = arr.findIndex(i => i.teamId === teamId.value);
@ -115,62 +128,41 @@ async function change(name) {
}
//
async function saveImage() {
if (!team.value || !team.value.qrcode) {
toast('暂无二维码');
return;
}
/**
*
* @param action save | share
*/
async function saveImage(action = 'save') {
const team = list.value[current.value] || null;
if (!team) return;
const data = getInvitePatientPoster(team.name, team.qrcode)
try {
const qrcodeComponent = qrcodes.value[current.value];
if (!qrcodeComponent) {
toast('二维码未加载完成');
return;
}
//
const tempFilePath = qrcodeComponent.toTempFilePath();
if (tempFilePath) {
await saveImageToPhotosAlbum(tempFilePath);
} else {
toast('获取二维码失败');
}
} catch (err) {
console.error('保存图片失败:', err);
toast('保存失败');
}
}
//
function onShareAppMessage() {
if (!team.value) {
return shareToWeChat({
title: '邀请患者加入团队',
path: '/pages/work/team/invite/invite-patient'
await painterRef.value.render(data);
painterRef.value.canvasToTempFilePathSync({
fileType: "jpg",
// base64使 saveImageToPhotosAlbum pathTypeurl
pathType: 'url',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
if (action === 'save') {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: function () {
console.log('save success');
}
});
} else if (action === 'share') {
wx.showShareImageMenu({
path: res.tempFilePath,
needShowEntrance: false
})
}
},
});
} catch (e) {
toast(e?.message)
}
return shareToWeChat({
title: `邀请您加入${team.value.name}`,
path: `/pages/work/team/invite/invite-patient?teamId=${team.value.teamId}`,
imageUrl: team.value.qrcode || ''
});
}
//
function onShareTimeline() {
if (!team.value) {
return {
title: '邀请患者加入团队',
path: '/pages/work/team/invite/invite-patient'
};
}
return {
title: `邀请您加入${team.value.name}`,
query: `teamId=${team.value.teamId}`,
imageUrl: team.value.qrcode || ''
};
}
onLoad(opts => {
@ -181,12 +173,6 @@ useShow(() => {
getTeams()
})
// 使
defineExpose({
onShareAppMessage,
onShareTimeline
})
</script>
<style>
.w-100 {
@ -225,4 +211,13 @@ defineExpose({
.share-btn::after {
border: none;
}
.canvas-box {
top: 10000rpx;
position: absolute;
z-index: -1;
width: 0;
height: 0;
overflow: hidden;
}
</style>

View File

@ -16,16 +16,27 @@
<view class="mt-10 px-15 text-base text-dark leading-normal text-center">
加入我的团队协同开展患者管理服务
</view>
<view class="mt-10 flex px-15 leading-normal text-center">
<button class="mr-10 border-auto rounded py-5 text-base text-primary flex-grow" @click="saveImage('save')">
保存图片
</button>
<button class="bg-primary rounded py-5 text-base text-white flex-grow" open-type="share">分享微信</button>
</view>
<view class="canvas-box">
<l-painter ref="painterRef" :board="poster" />
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from "vue";
import { storeToRefs } from "pinia";
import { onLoad, onShareAppMessage } from "@dcloudio/uni-app";
import useGuard from "@/hooks/useGuard.js";
import useAccountStore from "@/store/account.js";
import api from '@/utils/api';
import { toast } from "@/utils/widget";
import { getInviteMatePoster } from './base-poster-data';
const env = __VITE_ENV__;
const inviteQrcode = env.MP_INVITE_TEAMMATE_QRCODE;
@ -33,6 +44,9 @@ const inviteQrcode = env.MP_INVITE_TEAMMATE_QRCODE;
const options = { margin: 10 };
const team = ref(null);
const teamId = ref('');
const painterRef = ref()
const poster = ref({})
const { useLoad, useShow } = useGuard();
const { account } = storeToRefs(useAccountStore());
@ -46,6 +60,37 @@ async function getTeam() {
toast(res?.message || '获取团队信息失败')
}
}
async function saveImage(action = 'save') {
const data = getInviteMatePoster(team.value.name, qrcode.value)
try {
await painterRef.value.render(data);
painterRef.value.canvasToTempFilePathSync({
fileType: "jpg",
// base64使 saveImageToPhotosAlbum pathTypeurl
pathType: 'url',
quality: 1,
success: (res) => {
console.log(res.tempFilePath);
if (action === 'save') {
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: function () {
console.log('save success');
}
});
} else if (action === 'share') {
wx.showShareImageMenu({
path: res.tempFilePath,
needShowEntrance: false
})
}
},
});
} catch (e) {
toast(e?.message)
}
}
useLoad(options => {
teamId.value = options.teamId;
})
@ -53,5 +98,21 @@ useLoad(options => {
useShow(() => {
getTeam()
});
onShareAppMessage(() => {
return {
title: '邀请团队成员',
path: `pages/login/redirect-page?type=inviteTeam&teamId=${teamId.value}`
}
})
</script>
<style></style>
<style>
.canvas-box {
top: 10000rpx;
position: absolute;
z-index: -1;
width: 0;
height: 0;
overflow: hidden;
}
</style>

View File

@ -34,7 +34,7 @@
<view class="flex items-center">
<view class="action-btn flex-col items-center mr-10" @click="invitePatient()">
<image class="mb-5 qrcode-icon" src="/static/work/qrcode.svg" />
<text class="action-text text-dark text-sm">邀请</text>
<text class="action-text text-dark text-sm">邀请患者</text>
</view>
<view class="action-btn flex-col items-center" @click="handleMore">
<image class="mb-5 qrcode-icon" src="/static/work/more.svg" />

BIN
static/icon/openChat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

BIN
static/jianpan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

BIN
static/work/service.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -24,8 +24,8 @@ export default defineStore("accountStore", () => {
// IM 相关
const openid = ref(cache.get(CACHE_KEYS.OPENID, ""));
const isIMInitialized = ref(false);
// 医生信息
const doctorInfo = ref(cache.get(CACHE_KEYS.DOCTOR_INFO, null));
// 医生信息 - 不做缓冲处理,每次都重新获取
const doctorInfo = ref(null);
function getLoginPromise(phoneCode = '') {
if (loginPromise.value) return loginPromise.value;
@ -41,6 +41,7 @@ export default defineStore("accountStore", () => {
async function loginByCode(phoneCode = '') {
try {
const { code } = await uni.login({
appid,
provider: "weixin",
@ -61,13 +62,12 @@ export default defineStore("accountStore", () => {
}
account.value = res.data;
openid.value = res.data.openid;
// 持久化账户信息
cache.set(CACHE_KEYS.ACCOUNT, res.data);
cache.set(CACHE_KEYS.OPENID, res.data.openid);
// 登录成功后初始化腾讯IM
await getDoctorInfo(openid.value);
await getDoctorInfo({ openid: openid.value });
await initIMAfterLogin();
return res.data
}
@ -88,9 +88,18 @@ export default defineStore("accountStore", () => {
});
doctorInfo.value = res?.data || null;
// 持久化医生信息
if (res?.data) {
cache.set(CACHE_KEYS.DOCTOR_INFO, res.data);
// 检查账号是否被禁用
if (doctorInfo.value?.accountState === "disable") {
uni.showModal({
title: '账号被禁用',
content: '您的账号已被禁用,请联系管理员',
showCancel: false,
confirmText: '确定',
success: () => {
uni.redirectTo({ url: "/pages/login/login" });
}
});
return;
}
} catch (e) {
@ -99,16 +108,30 @@ export default defineStore("accountStore", () => {
}
async function initIMAfterLogin() {
if (isIMInitialized.value) return true;
if (!doctorInfo.value) return;
if (!doctorInfo.value) {
console.error('医生信息未获取无法初始化IM');
return false;
}
try {
const userID = doctorInfo.value.userid;
if (!userID) await getDoctorInfo();
await initGlobalTIM(userID);
if (!userID) {
await getDoctorInfo();
if (!doctorInfo.value?.userid) {
throw new Error('无法获取用户ID');
}
}
const success = await initGlobalTIM(userID);
if (!success) {
console.error('initGlobalTIM 返回失败');
return false;
}
isIMInitialized.value = true;
return true;
} catch (error) {
console.error('IM初始化失败:', error);
isIMInitialized.value = false;
return false;
}
}
@ -135,7 +158,6 @@ export default defineStore("accountStore", () => {
// 清空缓存
cache.remove(CACHE_KEYS.ACCOUNT);
cache.remove(CACHE_KEYS.OPENID);
cache.remove(CACHE_KEYS.DOCTOR_INFO);
}
return { account, openid, isIMInitialized, doctorInfo, login, getDoctorInfo, initIMAfterLogin, logout }

View File

@ -0,0 +1,225 @@
## 1.9.6.62024-09-25
- fix: 修复background-position无效的问题
## 1.9.6.52024-04-14
- fix: 修复`nvue`无法生图的问题
## 1.9.6.42024-03-10
- fix: 修复代理ctx导致H5不能使用ctx.save
## 1.9.6.32024-03-08
- fix: 修复支付宝真机无法使用的问题
## 1.9.6.22024-02-22
- fix: 修复使用render函数报错的问题
## 1.9.6.12023-12-22
- fix: 修复字节小程序非2d字体偏移
- fix: 修复`canvasToTempFilePathSync`会触发两次的问题
- fix: 修复`parser`图片没有宽度的问题
## 1.9.62023-12-06
- fix: 修复背景图受padding影响
- fix: 修复因字节报错改了代理实现导致微信报错
- 1.9.5.82023-11-16
- fix: 修复margin问题
- fix: 修复borderWidth问题
- fix: 修复textBox问题
- fix: 修复字节开发工具报`could not be cloned.`问题
## 1.9.5.72023-07-27
- fix: 去掉多余的方法
- chore: 更新文档,增加自定义字体说明
## 1.9.5.62023-07-21
- feat: 有限的支持富文本
- feat: H5和APP 增加 `hidpi` prop主要用于大尺寸无法生成图片时用
- fix: 修复 钉钉小程序 缺少 `measureText` 方法
- chore: 由于微信小程序 pc 端的 canvas 2d 时不时抽风故不使用canvas 2d
## 1.9.5.52023-06-27
- fix: 修复把`emoji`表情字符拆分成多个字符的情况
## 1.9.5.42023-06-05
- fix: 修复因`canvasToTempFilePathSync`监听导致重复调用
## 1.9.5.32023-05-23
- fix: 因isPc错写成了isPC导致小程序PC不能生成图片
## 1.9.5.22023-05-22
- feat: 删除多余文件
## 1.9.5.12023-05-22
- fix: 修复 文字行数与`line-clamp`相同但不满一行时也加了省略号的问题
## 1.9.52023-05-14
- feat: 增加 `text-indent``calc` 方法
- feat: 优化 布局时间
## 1.9.4.42023-04-15
- fix: 修复无法匹配负值
- fix: 修复 Nvue IOS getImageInfo `useCORS` 为 undefined
## 1.9.4.32023-04-01
- feat: 增加支持文字描边 `text-stroke: '5rpx #fff'`
## 1.9.4.22023-03-30
- fix: 修复 支付宝小程序 isPC 在手机也为true的问题
- feat: 由 微信开发工具 3060 版 无法获取图片尺寸,现 微信开发工具 3220 版 修复该问题,故还原上一版的获取图片方式。
## 1.9.4.12023-03-28
- fix: 修复固定高度不正确问题
## 1.9.42023-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.52022-06-29
- feat: justifyContent 增加 `space-around``space-between`
- feat: canvas 2d 也使用`getImageInfo`
- fix: 修复 `text``text-decoration`错位
## 1.9.3.42022-06-20
- fix: 修复 因创建节点速度问题导致顺序出错。
- fix: 修复 微信小程序 PC 无法显示本地图片
- fix: 修复 flex-box 对齐问题
- feat: 增加 `text-shadow`
- feat: 重写 `text` 对齐方式
- chore: 更新文档
## 1.9.3.32022-06-17
- fix: 修复 支付宝小程序 canvas 2d 存在ctx.draw问题导致报错
- fix: 修复 支付宝小程序 toDataURL 存在权限问题改用 `toTempFilePath`
- fix: 修复 支付宝小程序 image size 问题导致 `objectFit` 无效
## 1.9.3.22022-06-14
- fix: 修复 image 设置背景色不生效问题
- fix: 修复 nvue 环境判断缺少参数问题
## 1.9.3.12022-06-14
- fix: 修复 bottom 定位不对问题
- fix: 修复 因小数导致计算出错换行问题
- feat: 增加 `useCORS` h5端图片跨域 在设置请求头无效果后试一下设置这个值
- chore: 更新文档
## 1.9.32022-06-13
- feat: 增加 `zIndex`
- feat: 增加 `flex-box` 该功能处于原始阶段,非常简陋。
- tips: QQ小程序 vue3 不支持, 为 uni 官方BUG
## 1.9.2.92022-06-10
- fix: 修复`text-align``margin`居中问题
## 1.9.2.82022-06-10
- fix: 修复 Nvue `canvasToTempFilePathSync` 不生效问题
## 1.9.2.72022-06-10
- fix: 修复 margin及padding的bug
- fix: 修复 Nvue `isCanvasToTempFilePath` 不生效问题
## 1.9.2.62022-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.52022-06-09
- chore: 更变获取父级宽度的设定
- chore: `pathType` 在canvas 2d 默认为 `url`
## 1.9.2.42022-06-08
- fix: 修复 `pathType` 不生效问题
## 1.9.2.32022-06-08
- fix: 修复 `canvasToTempFilePath` 漏写 `success` 参数
## 1.9.2.22022-06-07
- chore: 更新文档
## 1.9.2.12022-06-07
- fix: 修复 vue3 赋值给this再传入导致image无法绘制
- fix: 修复 `canvasToTempFilePathSync` 时机问题
- feat: canvas 2d 更改图片生成方式 `toDataURL`
## 1.9.22022-05-30
- fix: 修复 `canvasToTempFilePathSync` 在 vue3 下只生成一次
## 1.9.1.72022-05-28
- fix: 修复 `qrcode`显示不全问题
## 1.9.1.62022-05-28
- fix: 修复 `canvasToTempFilePathSync` 会重复多次问题
- fix: 修复 `view` css `backgroundImage` 图片下载失败导致 子节点不渲染
## 1.9.1.52022-05-27
- fix: 修正支付宝小程序 canvas 2d版本号 2.7.15
## 1.9.1.42022-05-22
- fix: 修复字节小程序无法使用xml方式
- fix: 修复字节小程序无法使用base64(非2D情况下工具上无法显示)
- fix: 修复支付宝小程序 `canvasToTempFilePath` 报错
## 1.9.1.32022-04-29
- fix: 修复vue3打包后uni对象为空后的报错
## 1.9.1.22022-04-25
- fix: 删除多余文件
## 1.9.1.12022-04-25
- fix: 修复图片不显示问题
## 1.9.12022-04-12
- fix: 因四舍五入导致有些机型错位
- fix: 修复无views报错
- chore: nvue下因ios无法读取插件内static文件改由下载方式
## 1.9.02022-03-20
- fix: 因无法固定尺寸导致生成图片不全
- fix: 特定情况下text判断无效
- chore: 本地化APP Nvue webview
## 1.8.92022-02-20
- fix: 修复 小程序下载最多10次并发的问题
- fix: 修复 APP端无法获取本地图片
- fix: 修复 APP Nvue端不执行问题
- chore: 增加图片缓存机制
## 1.8.8.82022-01-27
- fix: 修复 主动调用尺寸问题
## 1.8.8.62022-01-26
- fix: 修复 nvue 下无宽度时获取父级宽度
- fix: 修复 ios app 无法渲染问题
## 1.8.82022-01-23
- fix: 修复 主动调用时无节点问题
- fix: 修复 `box-shadow` 颜色问题
- fix: 修复 `transform:rotate` 角度位置问题
- feat: 增加 `overflow:hidden`
## 1.8.72022-01-07
- fix: 修复 image 方向为 `right` 时原始宽高问题
- feat: 支持 view 设置背景图 `background-image: url(xxx)`
- chore: 去掉可选链
## 1.8.62021-11-28
- feat: 支持`view``inline-block`的子集使用`text-align`
## 1.8.5.52021-08-17
- chore: 更新文档,删除 replace
- fix: 修复 text 值为 number时报错
## 1.8.5.42021-08-16
- fix: 字节小程序兼容
## 1.8.5.32021-08-15
- fix: 修复线性渐变与css现实效果不一致的问题
- chore: 更新文档
## 1.8.5.22021-08-13
- chore: 增加`background-image``background-repeat` 能力,主要用于背景纹理的绘制,并不是代替`image`。例如:大面积的重复平铺的水印
- 注意这个功能H5暂时无法使用因为[官方的API有BUG](https://ask.dcloud.net.cn/question/128793),待官方修复!!!
## 1.8.5.12021-08-10
- fix: 修复因`margin`报错问题
## 1.8.52021-08-09
- chore: 增加margin支持`auto`,以达到居中效果
## 1.8.42021-08-06
- chore: 增加判断缓存文件条件
- fix: 修复css 多余空格报错问题
## 1.8.32021-08-04
- tips: 1.6.x 以下的版本升级到1.8.x后要为每个元素都加上定位position: 'absolute'
- fix: 修复只有一个view子元素时不计算高度的问题
## 1.8.22021-08-03
- fix: 修复 path-type 为 `url` 无效问题
- fix: 修复 qrcode `text` 为空时报错问题
- fix: 修复 image `src` 动态设置时不生效问题
- feat: 增加 css 属性 `min-width` `max-width`
## 1.8.12021-08-02
- fix: 修复无法加载本地图片
## 1.8.02021-08-02
- chore 文档更新
- 使用旧版的同学不要升级!
## 1.8.0-beta2021-07-30
- ## 全新布局方式 不兼容旧版!
- chore: 布局方式变更
- tips: 微信canvas 2d 不支持真机调试
## 1.6.62021-07-09
- chore: 统一命名规范,无须主动引入组件
## 1.6.52021-06-08
- chore: 去掉console
## 1.6.42021-06-07
- fix: 修复 数字 为纯字符串时不转换的BUG
## 1.6.32021-06-06
- fix: 修复 PC 端放大的BUG
## 1.6.22021-05-31
- fix: 修复 报`adaptor is not a function`错误
- fix: 修复 text 多行高度
- fix: 优化 默认文字的基准线
- feat: `@progress`事件,监听绘制进度
## 1.6.12021-02-28
- 删除多余节点
## 1.6.02021-02-26
- 调整为uni_modules目录规范
- 修复transform的rotate不能为负数问题
- 新增:`pathType` 指定生成图片返回的路径类型,可选值有 `base64``url`

View File

@ -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()
},
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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
}
}

File diff suppressed because one or more lines are too long

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,93 @@
{
"id": "lime-painter",
"displayName": "海报画板",
"version": "1.9.6.6",
"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"
}

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;

View File

@ -0,0 +1,961 @@
# Painter 画板 测试版
> uniapp 海报画板,更优雅的海报生成方案
> [查看更多](https://limeui.qcoon.cn/#/painter)
## 平台兼容
| 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、imageqrcode 共同构建一颗完整的节点树
- 在 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)

View File

@ -106,7 +106,8 @@ const urlsConfig = {
getChatRecordsByGroupId: "getChatRecordsByGroupId",
getGroupList: "getGroupList",
followUpInquiry: "followUpInquiry",
supplementMedicalCase: "supplementMedicalCase"
supplementMedicalCase: "supplementMedicalCase",
rejectConsultation: "rejectConsultation"
},
todo: {
getCustomerTodos: 'getCustomerTodos',

View File

@ -5,7 +5,7 @@
import { globalTimChatManager } from './tim-chat.js';
import api from './api.js';
import { toast } from './widget.js';
const env = __VITE_ENV__;
/**
* 发送文字消息
* @param {string} content - 文字内容
@ -41,13 +41,13 @@ export async function sendTextMessage(content) {
/**
* 发送图片消息
* @param {string} imageUrl - 图片URL字符串
* @param {string|File} imageSource - 图片URL字符串或文件对象
* @param {string} imageName - 图片名称可选
* @returns {Promise<boolean>} 发送是否成功
*/
export async function sendImageMessage(imageUrl, imageName = '图片') {
if (!imageUrl) {
toast('图片URL不能为空');
export async function sendImageMessage(imageSource, imageName = '图片') {
if (!imageSource) {
toast('图片不能为空');
return false;
}
@ -57,9 +57,17 @@ export async function sendImageMessage(imageUrl, imageName = '图片') {
return false;
}
// 直接调用 tim-chat 的 sendImageMessage 方法
// tim-chat.js 中的 getImageUrl 方法可以处理 URL 字符串
const result = await globalTimChatManager.sendImageMessage(imageUrl);
// 如果是URL字符串需要先下载转换为文件对象
let imageFile = imageSource;
if (typeof imageSource === 'string') {
imageFile = await downloadImageAsFile(imageSource, imageName);
if (!imageFile) {
toast('图片下载失败');
return false;
}
}
const result = await globalTimChatManager.sendImageMessage(imageFile);
if (result?.success) {
return true;
@ -74,6 +82,44 @@ export async function sendImageMessage(imageUrl, imageName = '图片') {
}
}
/**
* 将图片URL下载转换为文件对象小程序环境
* @param {string} imageUrl - 图片URL
* @param {string} fileName - 文件名
* @returns {Promise<Object|null>} 文件对象或null
*/
async function downloadImageAsFile(imageUrl, fileName = '图片') {
return new Promise((resolve) => {
uni.downloadFile({
url: imageUrl,
success: (res) => {
if (res.statusCode === 200) {
// 返回包含 tempFiles 数组的对象,模拟 uni.chooseMedia 的返回格式
const fileObj = {
tempFiles: [{
tempFilePath: res.tempFilePath,
path: res.tempFilePath,
size: 0,
type: 'image'
}],
// 也保留直接的 tempFilePath 以兼容其他代码
tempFilePath: res.tempFilePath
};
console.log('图片下载成功,文件对象:', fileObj);
resolve(fileObj);
} else {
console.error('下载图片失败,状态码:', res.statusCode);
resolve(null);
}
},
fail: (error) => {
console.error('下载图片失败:', error);
resolve(null);
}
});
});
}
/**
* 发送宣教文章消息
* @param {Object} article - 文章对象 { _id, title, cover, url }
@ -137,7 +183,7 @@ export async function sendArticleMessage(article, options = {}) {
/**
* 发送问卷消息
* @param {Object} survey - 问卷对象 { _id, name, surveryId, url, createBy }
* @param {Object} options - 额外选项 { userId, customerId, customerName, corpId, env }
* @param {Object} options - 额外选项 { userId, customerId, customerName, corpId }
* @returns {Promise<boolean>} 发送是否成功
*/
export async function sendSurveyMessage(survey, options = {}) {
@ -186,8 +232,7 @@ export async function sendSurveyMessage(survey, options = {}) {
sendSurveyId,
{
corpId: options.corpId,
userId: options.userId,
env: options.env,
userId: options.userId
}
);
@ -235,10 +280,9 @@ function generateRandomString(length) {
* @returns {string} 问卷链接
*/
function generateSendLink(survey, answerId, customerId, customerName, sendSurveyId, context = {}) {
const { corpId, userId, env } = context;
const { corpId, userId } = context;
const isSystem = survey.createBy === 'system';
let url = '';
if (isSystem) {
// 系统问卷:使用 VITE_SURVEY_URL
url = `${env?.MP_SURVEY_URL}?corpId=${corpId}&surveryId=${survey.surveryId}&memberId=${customerId}&sendSurveyId=${sendSurveyId}&userId=${userId}`;
@ -318,8 +362,7 @@ export async function handleFollowUpMessages(messages, context = {}) {
userId: context.userId,
customerId: context.customerId,
customerName: context.customerName,
corpId: context.corpId,
env: context.env,
corpId: context.corpId
});
}

View File

@ -1,234 +0,0 @@
# 微信小程序分享功能使用指南
## 功能说明
提供了完整的微信小程序分享功能,包括:
- 分享给好友
- 分享到朋友圈
- 保存图片到相册
## 使用方法
### 1. 基础分享(在页面中)
```vue
<template>
<view>
<button open-type="share">分享给好友</button>
</view>
</template>
<script setup>
import { createShareMessage, createShareTimeline } from '@/utils/share'
// 分享给好友
function onShareAppMessage() {
return createShareMessage({
title: '分享标题',
path: '/pages/index/index?id=123',
imageUrl: 'https://example.com/share.jpg'
})
}
// 分享到朋友圈(需要在 app.json 中配置)
function onShareTimeline() {
return createShareTimeline({
title: '朋友圈标题',
query: 'id=123',
imageUrl: 'https://example.com/share.jpg'
})
}
// 导出分享方法
defineExpose({
onShareAppMessage,
onShareTimeline
})
</script>
```
### 2. 使用分享组件
```vue
<template>
<view>
<share-actions
@save="handleSave"
:show-save="true"
:show-share="true"
save-text="保存图片"
share-text="分享微信"
/>
</view>
</template>
<script setup>
import { saveImageToAlbum, createShareMessage } from '@/utils/share'
import shareActions from '@/components/share-actions.vue'
// 保存图片
async function handleSave() {
const imagePath = 'https://example.com/image.jpg'
await saveImageToAlbum(imagePath)
}
// 分享配置
function onShareAppMessage() {
return createShareMessage({
title: '分享标题',
path: '/pages/index/index'
})
}
defineExpose({
onShareAppMessage
})
</script>
```
### 3. 保存二维码图片
```vue
<template>
<view>
<uqrcode ref="qrcode" canvasId="qrcode" :value="qrcodeUrl" />
<button @click="saveQrcode">保存二维码</button>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { saveImageToAlbum } from '@/utils/share'
import { toast } from '@/utils/widget'
const qrcode = ref(null)
const qrcodeUrl = ref('https://example.com')
async function saveQrcode() {
try {
if (!qrcode.value) {
toast('二维码未加载完成')
return
}
// 获取二维码临时文件路径
const tempFilePath = qrcode.value.toTempFilePath()
if (tempFilePath) {
await saveImageToAlbum(tempFilePath)
} else {
toast('获取二维码失败')
}
} catch (err) {
console.error('保存失败:', err)
toast('保存失败')
}
}
</script>
```
### 4. 动态分享内容
```vue
<script setup>
import { ref, computed } from 'vue'
import { createShareMessage } from '@/utils/share'
const currentItem = ref({
id: '123',
title: '商品标题',
image: 'https://example.com/product.jpg'
})
// 动态生成分享配置
function onShareAppMessage() {
return createShareMessage({
title: currentItem.value.title,
path: `/pages/detail/detail?id=${currentItem.value.id}`,
imageUrl: currentItem.value.image
})
}
defineExpose({
onShareAppMessage
})
</script>
```
## 配置说明
### 1. 启用分享到朋友圈
`pages.json` 中配置页面:
```json
{
"path": "pages/index/index",
"style": {
"navigationBarTitleText": "首页",
"enableShareTimeline": true
}
}
```
### 2. 全局分享配置
`App.vue` 中配置全局分享:
```vue
<script>
export default {
onShareAppMessage() {
return {
title: '默认分享标题',
path: '/pages/index/index'
}
},
onShareTimeline() {
return {
title: '默认朋友圈标题'
}
}
}
</script>
```
## API 说明
### createShareMessage(options)
创建分享给好友的配置
**参数:**
- `title` (string): 分享标题
- `path` (string): 分享路径
- `imageUrl` (string): 分享图片URL
**返回:** 分享配置对象
### createShareTimeline(options)
创建分享到朋友圈的配置
**参数:**
- `title` (string): 分享标题
- `query` (string): 分享路径参数
- `imageUrl` (string): 分享图片URL
**返回:** 分享配置对象
### saveImageToAlbum(filePath)
保存图片到相册
**参数:**
- `filePath` (string): 图片路径(本地临时路径或网络路径)
**返回:** Promise<boolean>
## 注意事项
1. 分享图片建议尺寸5:4推荐 500x400px
2. 分享路径必须是已注册的页面路径
3. 保存图片需要用户授权相册权限
4. 分享到朋友圈需要在页面配置中启用
5. 网络图片会自动下载后保存到相册

View File

@ -20,16 +20,16 @@ if (!TIM_CONFIG.SDKAppID || isNaN(TIM_CONFIG.SDKAppID)) {
// IM连接配置常量
const IM_CONNECTION_CONFIG = {
MAX_RECONNECT_ATTEMPTS: 10, // 最大重连次数
RECONNECT_DELAYS: [2000, 4000, 8000, 16000, 30000], // 重连延迟(指数退避)
LOGIN_COOLDOWN: 5000, // 登录冷却时间
SDK_READY_TIMEOUT: 15000, // SDK就绪超时时间
MAX_RECONNECT_ATTEMPTS: 15, // 增加最大重连次数到15次
RECONNECT_DELAYS: [1000, 2000, 4000, 8000, 16000, 30000], // 重连延迟(指数退避)
LOGIN_COOLDOWN: 3000, // 降低登录冷却时间到3秒
SDK_READY_TIMEOUT: 20000, // 增加SDK就绪超时时间到20秒
LOGIN_CHECK_INTERVAL_STABLE: 60000, // 稳定状态检查间隔
LOGIN_CHECK_INTERVAL_UNSTABLE: 15000, // 不稳定状态检查间隔
LOGIN_CHECK_FIRST_DELAY: 30000, // 首次检查延迟
LOGIN_CHECK_INTERVAL_UNSTABLE: 10000, // 降低不稳定状态检查间隔到10秒
LOGIN_CHECK_FIRST_DELAY: 20000, // 降低首次检查延迟到20秒
HEARTBEAT_INTERVAL: 60000, // 心跳间隔毫秒60秒
HEARTBEAT_MAX_FAIL: 3, // 心跳最大失败次数
NETWORK_RECONNECT_DELAY: 2000, // 网络恢复后延迟重连
HEARTBEAT_MAX_FAIL: 2, // 降低心跳最大失败次数到2次
NETWORK_RECONNECT_DELAY: 1000, // 降低网络恢复后延迟重连到1秒
MESSAGE_BATCH_COUNT: 20, // 每批消息数量
MAX_MESSAGE_REQUESTS: 50, // 最大消息请求次数
MAX_CACHE_SIZE: 1000, // 最大缓存消息数
@ -191,7 +191,12 @@ class TimChatManager {
// 等待SDK Ready
console.log('等待SDK Ready...')
await this.waitForSDKReady(IM_CONNECTION_CONFIG.SDK_READY_TIMEOUT)
try {
await this.waitForSDKReady(IM_CONNECTION_CONFIG.SDK_READY_TIMEOUT)
} catch (timeoutError) {
// SDK Ready 超时,但不一定是致命错误,继续进行
console.warn('SDK Ready 超时,但继续进行:', timeoutError.message)
}
console.log('=== IM初始化完成 ===')
return true
@ -264,7 +269,7 @@ class TimChatManager {
waitForSDKReady(timeout = IM_CONNECTION_CONFIG.SDK_READY_TIMEOUT) {
return new Promise((resolve, reject) => {
const startTime = Date.now()
const checkInterval = 1000 // 每秒检查一次
const checkInterval = 500 // 每500ms检查一次更快响应
let checkCount = 0
const checkSDKReady = () => {
@ -277,8 +282,8 @@ class TimChatManager {
} else if (elapsed > timeout) {
const error = new Error(`等待SDK Ready超时${timeout}ms`)
console.error('✗', error.message)
// 超时不算致命错误,尝试继续
resolve()
// 超时时拒绝而不是继续,让调用者知道出错了
reject(error)
} else {
console.log(`等待SDK Ready... ${Math.floor(elapsed / 1000)}/${Math.floor(timeout / 1000)}`)
setTimeout(checkSDKReady, checkInterval)
@ -838,16 +843,16 @@ class TimChatManager {
}
if (netState === TIM.TYPES.NET_STATE_CONNECTED) {
console.log('✓ 网络已连接,延迟检查IM状态以确保稳定')
console.log('✓ 网络已连接,立即检查IM状态')
// 网络恢复后延迟再检查,避免网络还不稳定时立即重连
// 网络恢复后立即检查,不再延迟
const delay = IM_CONNECTION_CONFIG.NETWORK_RECONNECT_DELAY
this.networkReconnectTimer = setTimeout(() => {
if (this.tim && !this.isLoggedIn && !this.isLoggingIn) {
console.log('🔄 网络已稳定,开始重连')
console.log('🔄 网络已恢复,开始重连')
this.ensureIMConnection()
} else if (this.isLoggedIn) {
console.log('✓ 网络已稳定IM连接正常')
console.log('✓ 网络已恢复IM连接正常')
}
this.networkReconnectTimer = null
}, delay)
@ -855,7 +860,7 @@ class TimChatManager {
// 重置重连次数(网络恢复后给更多机会)
if (this.reconnectAttempts > 0) {
console.log(`重置重连次数(之前: ${this.reconnectAttempts}`)
this.reconnectAttempts = 0
this.reconnectAttempts = Math.max(0, this.reconnectAttempts - 3) // 减少重连次数,给更多机会
}
} else if (netState === TIM.TYPES.NET_STATE_CONNECTING) {
@ -1025,8 +1030,36 @@ class TimChatManager {
// 获取群聊列表
getGroupList() {
return new Promise((resolve, reject) => {
// 检查userId是否存在不存在则不需要初始化
if (!this.currentUserID) {
return
}
// 如果 TIM 实例不存在,等待初始化
if (!this.tim) {
reject(new Error('TIM实例不存在'))
console.log('TIM实例不存在等待初始化...')
let waitTime = 0
const maxWaitTime = 30000 // 最多等待30秒
const checkInterval = 500 // 每500ms检查一次
let timeoutHandle = null
const checkTIMReady = () => {
if (this.tim && this.isLoggedIn) {
console.log('TIM实例已就绪开始获取群聊列表')
if (timeoutHandle) clearTimeout(timeoutHandle)
this.getGroupListInternal().then(resolve).catch(reject)
} else if (waitTime >= maxWaitTime) {
console.error('等待TIM实例就绪超时')
if (timeoutHandle) clearTimeout(timeoutHandle)
reject(new Error('IM连接失败请检查网络连接或重新登陆'))
} else {
waitTime += checkInterval
console.log(`等待TIM实例就绪... (${Math.floor(waitTime / 1000)}/${Math.floor(maxWaitTime / 1000)}秒)`)
timeoutHandle = setTimeout(checkTIMReady, checkInterval)
}
}
checkTIMReady()
return
}
@ -1034,7 +1067,7 @@ class TimChatManager {
console.log('SDK未ready等待SDK初始化...')
let waitTime = 0
const maxWaitTime = 30000 // 最多等待30秒
const checkInterval = 1000 // 每秒检查一次
const checkInterval = 500 // 每500ms检查一次
let timeoutHandle = null
const checkSDKReady = () => {
@ -2189,7 +2222,7 @@ class TimChatManager {
async sendImageMessage(imageFile) {
if (!this.tim) {
this.triggerCallback('onError', 'IM未初始化')
return
return { success: false, error: 'IM未初始化' }
}
if (!this.conversation) {
@ -2240,20 +2273,27 @@ class TimChatManager {
// 触发消息接收回调让UI立即显示
this.triggerCallback('onMessageReceived', localMessage)
const message = this.tim.createImageMessage({
to: groupID,
conversationType: TIM.TYPES.CONV_GROUP,
payload: { file: imageFile }
})
try {
console.log('准备创建图片消息imageFile:', imageFile)
const message = this.tim.createImageMessage({
to: groupID,
conversationType: TIM.TYPES.CONV_GROUP,
payload: { file: imageFile }
})
console.log('图片消息创建成功:', message)
if (!message) {
throw new Error('createImageMessage 返回空值')
}
await this.tim.sendMessage(message)
localMessage.status = 'success'
return { success: true, message: localMessage }
} catch (error) {
console.error('图片消息发送失败:', error)
localMessage.status = 'failed'
return { success: false, error }
return { success: false, error: error.message || error }
}
}
// 发送语音消息
@ -2728,20 +2768,9 @@ class TimChatManager {
if (!conversationID.startsWith('GROUP')) {
formattedConversationID = `GROUP${conversationID}`
}
console.log('📖 标记会话为已读:', formattedConversationID);
this.tim.setMessageRead({
conversationID: formattedConversationID
}).then(() => {
console.log('✓ 会话已标记为已读:', formattedConversationID);
// 触发会话列表更新回调,通知消息列表页面清空未读数
this.triggerCallback('onConversationListUpdated', {
conversationID: formattedConversationID,
unreadCount: 0
})
}).catch(error => {
console.error('✗ 标记会话已读失败:', error)
this.triggerCallback('onConversationListUpdated', {
conversationID: formattedConversationID,
unreadCount: 0
})
} catch (error) {
console.error('✗ 标记会话已读异常:', error)
@ -2851,7 +2880,11 @@ const initGlobalTIM = async (userID, forceReinit = false) => {
console.log('强制重新初始化TIM登出成功')
}
await globalTimChatManager.initTIM(userID)
const success = await globalTimChatManager.initTIM(userID)
if (!success) {
console.error('强制重新初始化失败')
return false
}
console.log('强制重新初始化完成')
return true
}
@ -2861,7 +2894,11 @@ const initGlobalTIM = async (userID, forceReinit = false) => {
return true
}
await globalTimChatManager.initTIM(userID)
const success = await globalTimChatManager.initTIM(userID)
if (!success) {
console.error('全局IM初始化失败')
return false
}
console.log('全局IM初始化成功')
return true
}

View File

@ -1,5 +1,5 @@
export default function useDebounce(callback, delay = 1000) {
export default function useDebounce(callback, delay = 500) {
let timer = null;
return (...args) => {
if (timer) clearTimeout(timer)