原图:
选框示例图:
裁剪后的图片:
合成视频示例:
AI虚拟人展示视频
裁剪代码:
<template>
<view class="main_box">
<view class="head_top" :style="{ height: headerHeight + 'px' }">
<navheadbar :width='headerWidth' backTextColor='#fff' bgColor='transparent' bordColor='transparent'></navheadbar>
</view>
<view class="content_box">
<view class="image_box">
<image id='imgTemp' :src="bgImg" mode="widthFix" @load='loadImageHandle'></image>
<view class="avatar_picker" :style="{ position: 'absolute', left: `${offsetSl}%`, top: `${offsetSt}%` }" id="avatar_picker" @touchmove='touchMoveHandle' @touchstart="touchStartHandle" @touchend="touchEndHandle">请拖动选框选中头部</view>
<!-- <view class="avatar_picker" :style="{ position: 'absolute', left: `${offsetSl}%`, top: `${offsetSt}%` }" id="avatar_picker" @touchmove='touchMoveHandle' @touchstart="touchStartHandle" @touchend="touchEndHandle">{{ `x:${offsetSl}-y:${offsetSt}` }}</view> -->
</view>
<canvas v-if='ispost' class='canvs_box' id="myCanvas" canvas-id="myCanvas" :style="{ width: `${imagePickerData.width}px`, height: `${imagePickerData.height}px` }"></canvas>
</view>
<view class="config_btn" @click='clipAvatarHandle(bgImg)'>确认</view>
</view>
</template>
<script>
import { getSameDataMixin } from '@/utils/sameDataMixin.js'
import { initDataBaseMixin } from '@/utils/sameMethodsMixin.js'
// 导入vuex中状态遍历方法:
import { mapState } from 'vuex'
// 导入校验规则:
import { useGetBoxSizeHandle, imgToBase64, setDataLocal } from '@/utils/offlineTools.js'
import { fetchCommitImageMission } from '@/api/index.js'
import { uploadImg } from '@/utils/onlinetools.js'
export default {
data() {
return {
...getSameDataMixin(),
// 网络背景图地址:
bgImg: '',
// 采集盒子距离窗口的百分比距离
offsetSl: 0,
offsetSt: 0,
// 鼠标开始坐标百分比距离:
startclientX: 0,
startclientY: 0,
// 采集盒子可移动百分比距离
canMoveLeft: 0,
canMoveTop: 0,
// 鼠标结束距离百分比距离:
endclientX: 0,
endclientY: 0,
// canvas:
ctx: {},
// canvas容器是否渲染::
ispost: false,
// 防抖锁:
isBtned: false,
// 背景图片的数据
imageData: {},
// 选中图片框的数据:
imagePickerData: {}
}
},
onLoad(e) {
if (e.param) {
let obj = JSON.parse(decodeURIComponent(e.param))
this.bgImg = obj.bgImg
}
},
created() {
initDataBaseMixin(this)
},
computed: {
...mapState(['globalUserInfo', 'globalMyCard'])
},
async mounted(){
// 2选中图片框的数据:
const picker = uni.createSelectorQuery().in(this).select("#avatar_picker")
this.imagePickerData = await this.getAvatarPickerInfoHandle(picker)
// 渲染canvas:
this.ispost = true
console.log('选中框的数据:', this.imagePickerData)
// bottom: 348 height: 250 left: 0 right: 250 top: 98 width: 250
},
methods: {
// 图片数据:
async loadImageHandle(){
// 图片数据:
let avatbox = uni.createSelectorQuery().in(this).select(".image_box")
this.imageData = await this.getAvatarPickerInfoHandle(avatbox)
console.log('图片数据:', this.imageData)
// bottom: 812 height: 718.84375 left: 0 right: 375 top: 93.15625 width: 375
},
// 按下位置
async touchStartHandle(e) {
// 鼠标开始在图片上的坐标百分比:
this.startclientX = e.changedTouches[0].clientX / this.imageData.width * 100
this.startclientY = (e.changedTouches[0].clientY - this.imageData.top) / this.imageData.height * 100
// 选框在图片上可移动距离相对图片百分百:
let { left, top, width, height } = this.imagePickerData
// 可移动的距离:
this.canMoveLeft = (this.imageData.width - width - this.offsetSl) / this.imageData.width * 100
this.canMoveTop = (this.imageData.height - height - this.offsetSt) / this.imageData.height * 100
},
// 移动了
touchMoveHandle(e){
// 鼠标移动的百分比距离:
let x = e.changedTouches[0].clientX / this.imageData.width * 100 - this.startclientX
let y = (e.changedTouches[0].clientY - this.imageData.top) / this.imageData.height * 100 - this.startclientY
// 移动选框:
if (x > 0) {
if (x <= this.canMoveLeft) {
this.offsetSl = x
this.endclientX = x
} else {
this.offsetSl = this.canMoveLeft
this.endclientX = this.canMoveLeft
}
}
if (x < 0) {
if (0 <= (this.endclientX + x)) {
this.offsetSl = this.endclientX + x
this.endclientX = this.endclientX + x
} else {
this.offsetSl = 0
this.endclientX = 0
}
}
if (y > 0) {
if (y <= this.canMoveTop) {
this.offsetSt = y
this.endclientY = y
} else {
this.offsetSt = this.canMoveTop
this.endclientY = this.canMoveTop
}
}
if (y < 0) {
if (0 <= (this.endclientY + y)) {
this.offsetSt = this.endclientY + y
this.endclientY = this.endclientY + y
} else {
this.offsetSt = 0
this.endclientY = 0
}
}
},
// 抬起位置
touchEndHandle(e) {
// 鼠标结束相对图片百分比距离:截取
this.endclientX = this.offsetSl
this.endclientY = this.offsetSt
},
// 裁剪图片:
clipAvatarHandle(avatarUrl){
const _this = this
if (this.isBtned) return uni.showToast({ title: '请勿频繁操作!', icon: 'none', duration: 3000 })
this.isBtned = true
if (!avatarUrl) return uni.showToast({ title: '图片地址无效!', icon: 'none', duration: 3000 })
this.ispost = true
let avatarTemp = wx.env.USER_DATA_PATH + "/" + new Date().valueOf() + (Math.floor(Math.random() * 90000) + 10000) + avatarUrl.slice(avatarUrl.length - 5)
// / 向用户发起授权请求
uni.downloadFile({
url: avatarUrl,
filePath: avatarTemp,
success: res => {
console.log('下载成功', res)
_this.getImgInfoHandle(avatarTemp)
},
fail: err => {
console.log('err', err)
uni.hideLoading()
uni.showToast({ title: '保存失败!', icon: 'none' })
}
})
},
// 获取图片信息
getImgInfoHandle(avatarTemp){
const _this = this
// 获取图片信息
uni.getImageInfo({
src: avatarTemp,
success: avat => {
_this.drawPosterHandle(avat)
}
})
},
// 绘制头像:
drawPosterHandle(avat){
const _this = this
// 图片尺寸(需要绘制的图片尺寸):
const imgW = this.imageData.width
const imgH = this.imageData.height
console.log('imgW', imgW)
console.log('imgH', imgH)
// 裁剪框尺寸:
const { width, height } = this.imagePickerData
console.log('width', width)
console.log('height ', height )
// 创建canvas上下文:
this.ctx = uni.createCanvasContext("myCanvas", this)
// 裁剪框:
this.ctx.rect(0, 0, width, height)
this.ctx.beginPath()
// 绘制最外层容器:
this.ctx.rect(0, 0, imgW, imgH)
this.ctx.clip()
// 计算canvas底图需要移动的实际像素:
const x = imgW * this.offsetSl / 100
const y = imgH * this.offsetSt / 100
console.log('x', x)
console.log('y', y)
// 移动底图绘制到裁剪框
this.ctx.drawImage(avat.path, -x, -y, imgW, imgH)
this.ctx.save()
// 下载海报:
this.ctx.draw(true, () => {
uni.canvasToTempFilePath({
canvasId: "myCanvas",
quality: 1,
complete: async function (res) {
_this.ispost = false
let list = [ res.tempFilePath ]
console.log('裁剪图本地地址:', res.tempFilePath)
uni.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => {
_this.ispost = false
uni.showToast({ title: "保存成功", icon: "none" })
uni.hideLoading()
console.log('进入9')
},
fail: err => {
_this.ispost = true
}
})
// 以下代码与上述:网络图片地址--裁剪选中---本地裁剪预览图地址---导入裁剪图到相册无关
return
let info = await uploadImg (list, '/appserv-card-xq/v1/cardTask/imageCheck', 'file' ,{ 'content-type': 'application/x-www-form-urlencoded' }, true).catch(err => {
if (typeof err == 'object' && err !== null) {
let errObj = JSON.parse(err.msg)
uni.showToast({ title: errObj.msg || '形象复刻上传头像异常', icon: 'none', duration: 2000 })
}
})
let uploadInfo = info[0]
if (info && info[0]) {
if (info[0].code == 0) {
// 提交形象复刻训练
_this.submitTranHandle(uploadInfo.data)
}
}
}
})
})
},
// 提交形象训练任务:
submitTranHandle(paths){
if (!paths) return
let obj = {
fileUrl: paths
}
fetchCommitImageMission(obj).then(res => {
if (res && res.code == 0) {
let obj = {
img: paths,
currentStep: 3 , //复刻等待中
trainTaskId: res.data,
x: this.offsetSl,
y: this.offsetSt,
w: this.imageData.width,
h: this.imageData.height,
headImgUrl: paths,
isSelect: 1,
bottomImgUrl: this.bgImg
}
// 解锁:
this.isBtned = false
// 记录训练任务存本地:
setDataLocal('lastTask', obj)
uni.$emit('changeCurrentStep', obj)
uni.navigateBack()
} else {
console.log('训练任务提交失败', res)
}
})
},
// 获取图片采集盒子信息:
getAvatarPickerInfoHandle(e){
return new Promise(resovle => {
e.boundingClientRect(function(data) {
resovle(data)
}).exec()
})
}
}
}
</script>
<style lang="scss" scoped>
.main_box {
@include mx_wh_bc_r(100%, 100%, $color: #333, $radius: false);
@include mx_flex($direction: column);
position: relative;
.head_top {
width: 100%;
z-index: 99999;
}
.content_box {
flex: 1;
.image_box {
position: relative;
image {
height: auto;
width: 100%;
}
.avatar_picker {
@include mx_lt($type: absolute, $left: 0, $top: 0);
@include mx_wh_bc_r($width: 500rpx, $height: 500rpx, $color: false, $radius: false);
background: rgba(255, 255, 255, .4);
text-align: center;
line-height: 500rpx;
z-index: 99999;
}
}
.canvs_box {
position: absolute;
}
}
.config_btn {
@include mx_ct($type: absolute, $left: 50%, $top: 90%, $translateX: -50%, $translateY: 0);
@include mx_wh_bc_r($width: 640rpx, $height: 100rpx, $color: #5A6AF6, $radius: 50rpx);
@include mx_font($size: 42rpx, $color: #fff, $weight: false, $lineHeight: 100rpx, $fontFamily: false, $letterSpacing: false);
text-align: center;
}
}
</style>
虚拟人展示页代码:只看视频盒子部分代码即可
<template>
<view class="main_box">
<!-- 视频盒子: -->
<view class="video_box">
<view class="image_box">
<image :src="cardInfo.bottomImgUrl ? cardInfo.bottomImgUrl : ''" mode="widthFix"></image>
<video v-if='(cardInfo.unMouthImage || cardInfo.mouthImage)' :style="{ zIndex: `${isPlay ? 999 : -999}`, position: 'absolute', left: `${cardInfo.x || 35}%`, top: `${cardInfo.y || 20}%` }" object-fit="cover" ref="video" :src="cardInfo.mouthImage" :controls='false' :autoplay='true' :enableProgressGesture='false' :muted='true' loop :showFullscreenBtn='false' :showCenterPlayBtn='false' :showMuteBtn='false'></video>
<video v-if='(cardInfo.unMouthImage || cardInfo.mouthImage)' :style="{position: 'absolute', left: `${cardInfo.x || 35}%`, top: `${cardInfo.y || 20}%` }" object-fit="cover" ref="video" :src="cardInfo.unMouthImage" :controls='false' :autoplay='true' :enableProgressGesture='false' :muted='true' loop :showFullscreenBtn='false' :showCenterPlayBtn='false' :showMuteBtn='false'></video>
</view>
</view>
<view class="head_top" :style="{ height: headerHeight + 'px' }">
<view class="go_home" @click.stop="goPageHandle">
<image src="https://img.js.design/assets/img/6548a53d1719b1e81764c8ef.png" mode=""></image>
<text>点我</text>
</view>
<view class="share_box" @click.stop="menuBtnClickHandle({ id: 1 })">
<image src="../../static/images/shareIcon.svg" mode=""></image>
<text>分享</text>
</view>
</view>
<view class="content_box">
<GoDetail btnTxt='查看详情' :isLook='true' :robotInfo='cardInfo'></GoDetail>
<view class="chat_box">
<scroll-view scroll-y="true" :style="{height: boxHeight + 'px'}" :scroll-into-view="currentMsgId" @scrolltoupper='getMoreChatListHandle'>
<view :class='its.isMy ? "msg_box right_box" : "msg_box left_box"' v-for='(its, index) in chatList' :id='its.id'>
<view class="msg">{{ its.printText }}</view>
<view class="date"></view>
</view>
</scroll-view>
</view>
<view :class="keyboardHeight ? 'input_box top_key_bord_bg' : 'input_box'" :style="{ position: 'relative', top: `${keyboardHeight}px` }" >
<image :src="isInput ? '../../static/images/icon-mike.png' : '../../static/images/icon_keyboard.png'" @click='isInput = !isInput'></image>
<input :adjust-position="false" v-if='isInput' type="text" v-model="inputValue" placeholder='说点什么...' />
<view v-else class="voice_btn" @touchstart="touchStartHandle" @touchend="touchEndHandle">按住说话</view>
<view class="send_box" @click="sendMsgHandle">
<image src="../../static/images/icon-send.png"></image>
<text>发送</text>
</view>
</view>
</view>
<!-- 或者头像和昵称模态框 -->
<modalbox v-if='contentBoxHeight' :isOpen='isOpenAvatorAndNickNameModal' :height='contentBoxHeight + headerHeight' top='0' left='0' translateY='0' translateX='0' bgColor='rgba(0, 0, 0, 0.6)' bordColor='rgba(0, 0, 0, 0.6)' v-slot:content>
<GetAvatarNickName @close='closeHandle' @save='saveHandle'></GetAvatarNickName>
</modalbox>
<!-- 分享: -->
<modalbox @close='menuBtnClickHandle({id: null})' v-if='contentBoxHeight' :isOpen='isOpenConfigShareModal' :height='contentBoxHeight + headerHeight + tabBarHeight' top='0' left='0' translateY='0' translateX='0' bgColor='rgba(0, 0, 0, 0.6)' bordColor='rgba(0, 0, 0, 0.6)' v-slot:content>
<view class="share_main_box">
<button open-type="share" @click.stop='menuBtnClickHandle({ id: 4 })'>
<image src="../../static/images/wxFriend.png" mode=""></image>
<text>微信好友</text>
</button>
<view @click.stop='menuBtnClickHandle({ id: 5 })'>
<image src="../../static/images/cardPost.png" mode=""></image>
<text>名片海报</text>
</view>
</view>
</modalbox>
<view class="copy_voice" v-if='isTouch'>
<image src="../../static/images/speakGif.gif" mode=""></image>
<text>手指上划,取消发送</text>
</view>
</view>
</template>
<script>
import { terminalId, wxAppid } from '@/config/globalConfig.js'
// 引入公共基本数据(头部导航栏尺寸、底部tabBar栏尺寸、是否为ios系统):
import { getSameDataMixin } from '@/utils/sameDataMixin.js'
// 引入: initDataBaseMixin基本数据初始化方法(headerHeight, headerWidth, tabBarHeight, isIos等变量的初识)、refreshIsLightOrDarkByTime朝夕模式自动刷新
import { initDataBaseMixin } from '@/utils/sameMethodsMixin.js'
// 导入本地工具
import { navigationToHandle, useGetBoxSizeHandle, loginByWeChatHandle, imgToBase64, setDataLocal } from '@/utils/offlineTools.js'
// 导入vuex中状态遍历方法:
import { mapState } from 'vuex'
// 导入接口:
import { loginByWechatIvCodeApi, fetchLogin, getValidChatCountApi, deductChatNumApi, updateUserInfoApi, fetchCardDetail, fetchRecorderVisitor, fetchAnalyzeOpenId, fetchContactList, queryAvatarAndBgimgApi, recordChatMsgApi, fetchSearchRecord , fetchVideoStatusByFileSeq, getUserInfoApi } from '@/api/index.js'
// 引入组件:
import navheadbar from '../../components/navheadbar/navheadbar.vue'
import { uploadImg2 } from '@/utils/onlinetools.js'
// 导入websocket
import socketio from '@/socketio/index.js'
import Vue from 'vue'
export default {
components: { navheadbar },
onShareAppMessage(res) {
return {
title: `您好,我是${this.cardInfo.personName || '您的朋友'}, 我是第${this.cardInfo.id || 9999}个拥有数字分身的人类`,
path: `/pages/aiChat/aiChat?scene=${this.cardInfo.cardId}`
}
},
data() {
return {
// 基本数据:
...getSameDataMixin(),
// 中间有效区域的高度:
boxHeight: 0,
// 模态框高度:
contentBoxHeight: 0,
isOpenAvatorAndNickNameModal: false,
// 分享弹框:
isOpenConfigShareModal: false,
// 消息列表:
chatList: [],
// 是否为输入类型:
isInput: true,
// 输入文本:
inputValue: '',
// 是否已经确认过:
isLocked: true,
// 卡片信息
cardInfo: {},
// 背景图:
bgImg: '',
// 是否播放:
isPlay: false,
// 队列消息:
queueList: [],
// 防抖:
msgTimeId: 0,
msgEndTimeId: 0,
stopTimeId: 0,
// 位置:
touchStart: 0,
touchEnd: 0,
// 是否按下:
isTouch: false,
// 聊天消息id:
currentMsgId: 0,
// 缓存文本:
bufferString: '',
// 缓存文本定时器:
bufferMsgTimeId: 0,
// 语音播报锁:
voiceLock: true,
// 视频防抖:
videoTimeId: 0,
// 查询聊天记录请求参数:
queryHistoryObj: {},
// 页码
pageNo: 1,
// 键盘高度
keyboardHeight: 0,
// 当前是否为自己名片:
currentIsMyCard: false
}
},
watch: {
'chatList': {
handler(val){
let tempObj = val[val.length - 1]
if (!tempObj.isMy && !this.queryHistoryObj.isQueryHistory) {
this.printTextHandle(val)
} else {
val[val.length - 1].printText = val[val.length - 1].content
}
}
},
'globalMyCard': {
handler(val){
if (this.currentIsMyCard) {
this.cardInfo = val
}
}
}
},
onShow(){
this.$store.commit('updateGlobalCurrentCardMut', this.cardInfo)
},
computed: {
...mapState(['globalScene', 'globalUserInfo', 'globalContacts', 'globalMyCard', 'globalCurrentCard'])
},
async mounted() {
const info = uni.createSelectorQuery().select('.chat_box')
this.boxHeight = await useGetBoxSizeHandle(info, 'height', [0, 0, 0, 0])
let contentBox = uni.createSelectorQuery().select('.main_box')
this.contentBoxHeight = await useGetBoxSizeHandle(contentBox, 'height', [0, 0, 0, 0])
uni.onKeyboardHeightChange(res => {
this.keyboardHeight = -res.height
})
},
onLoad(e){
if (e.param) {
let obj = JSON.parse(decodeURIComponent(e.param))
// 查聊天记录:
if (obj.uid && obj.isQueryHistory) {
this.queryHistoryObj = obj
this.queryChatHistory(obj.uid)
this.pageNo = 1
} else {
this.queryHistoryObj = {}
}
} else {
this.queryHistoryObj = {}
}
// 是否分享获取列表页进入
if (e.scene) {
this.$store.commit('updateGlobalSceneMut', e.scene)
} else {
this.$store.commit('updateGlobalSceneMut', null)
}
// 语音模块:
this.$WSI.addCallBack('cardChat', this.onMsg)
// 初始化数据(登录、自动播放一系列)
this.initData()
},
onHide(){
this.$WSI.pause()
},
destroyed(){
this.$WSI.removeCallBack('cardChat')
this.$ws.removeCallBack('acknowledge')
},
created(){
// 初始化基本数据:
initDataBaseMixin(this)
// 初始化网络状态:
this.initNetWorkStatus()
// 监听聊天次数用完:
uni.$on('aiChatFinish', () => uni.showToast({ title: '您的次数已用完', icon: 'none', duration: 2000 }))
// 监听切换到自己名片:
uni.$on('changeMyCard', myCard => {
this.cardInfo = myCard
this.$store.commit('updateGlobalCurrentCardMut', myCard)
})
},
methods: {
// 查询更多聊天记录:
getMoreChatListHandle(){
this.queryChatHistory()
},
// 查询聊天记录:
queryChatHistory(uid){
if (!this.queryHistoryObj.isQueryHistory) return
let obj = {
cardId: this.globalMyCard.cardId,
uid: uid || this.globalCurrentCard.uid,
pageNo: this.pageNo,
pageSize: 10
}
fetchSearchRecord(obj).then(res => {
if (res.code != 0) return
if (res.data.list.length != 0) {
this.pageNo ++
let ls = []
res.data.list.forEach(item => {
if (item.message && item.message.trim() != '') {
ls.push({ id: ('current' + item.id), isMy: (item.type == 'request'), printText: item.message })
}
})
this.chatList.unshift(...ls)
}
})
},
// 打字效果:
printTextHandle(val){
let arrList = []
let tempObj = val[val.length - 1]
arrList = tempObj.content.split('')
let i = 0
function pushMsg(){
val[val.length - 1].printText += arrList[i]
i++
if (i < arrList.length) {
setTimeout(() => {
pushMsg()
}, 30)
}
}
pushMsg()
},
// 说话:
async touchStartHandle(e){
if (!this.globalUserInfo.token) return uni.showToast({ title: '请先登录', icon: 'none', duration: 2000 })
// 查询聊天次数是否有效:
let num = await this.isValidChatCountHandle()
if (num <= 0) return uni.showToast({ title: '您的次数已用完!', icon: 'none', duration: 2000 })
this.touchStart = e.changedTouches[0].clientY
this.touchEnd = e.changedTouches[0].clientY
this.isTouch = true
this.$WSI.pause()
this.$WSI.touchStart()
},
// 停止说话:
touchEndHandle(e){
this.touchEnd = e.changedTouches[0].clientY
this.isTouch = false
if (this.touchStart - this.touchEnd > 10) {
this.$WSI.close()
this.$WSI.touchEnd()
return
}
this.$WSI.touchEnd()
},
// 初始化:
async initData(){
const result = await loginByWeChatHandle().catch(err => {
uni.showToast({ title: '登录失败', icon: 'none', duration: 3000, mask: true })
})
const obj = { wxCode: result.code, WxAppid: wxAppid, terminalId: terminalId }
const idRes = await fetchAnalyzeOpenId(obj)
console.log('idRes', idRes)
if (idRes && idRes.code == 0) {
const { openId, unionId } = idRes.data
let tempOpenId = { ...this.globalUserInfo, openId, unionId }
this.$store.commit('updateGlobalUserInfoMut', tempOpenId)
// 用户登录
const myLogin = await loginByWechatIvCodeApi({ unionId: unionId })
let tempLoginMy = {
...myLogin.data,
token2: myLogin.data.token
}
const loginRes = await fetchLogin({ keytp: 'openid', uname: openId, terminalId: terminalId })
if (loginRes.code != 0) return uni.showToast({ title: '登录失败1', icon: 'none', duration: 3000, mask: true })
let tempLoginInfo = { ...this.globalUserInfo, ...tempLoginMy, ...loginRes.data }
this.$store.commit('updateGlobalUserInfoMut', tempLoginInfo)
// 连接socket.io
this.connectIoHandle()
// 添加wocket回调
this.$ws.addCallBack('acknowledge', this.onMsgWebSocket)
// 初始化名片信息:
this.initCard()
}
},
// 连接ws:
connectIoHandle() {
// 通过单例模式连接ws服务器:
socketio.Instance.connect()
// // 将websocket挂载到vue原型上:
Vue.prototype.$ws = socketio.Instance
// 添加wocket回调
this.$ws.addCallBack('aiChatResp', this.onMsgWebSocket)
},
// 初始化名片(是否扫码或列表进入)
initCard () {
// 分享id(外部分享优先级最高,但仅限一次)
let fetchShareId = ''
// 是否记录访客
let isRecorderVisitor = false
// 扫描进入:
if (this.globalScene) {
// 分享名片进入-更改分享人名片id:
fetchShareId = this.globalScene
isRecorderVisitor = true
// 调用完分享后清除扫码绑定的id
this.$store.commit('updateGlobalSceneMut', null)
} else if (this.globalContacts) {
// 来自联系人页进入
fetchShareId = this.globalContacts
isRecorderVisitor = false
}
// 获取首页名片流程
this.getCardInfo(fetchShareId, isRecorderVisitor)
// 获取用户信息昵称头像:
this.getUserInfoHandle()
},
// 查询名片详细信息:
getCardInfo(share, isRecorder){
// 有名片id时:
if (share) {
fetchCardDetail({ cardId: share }).then(async res => {
if (res.code == 0 && res.data) {
this.cardInfo = res.data
this.currentIsMyCard = false
this.avatarAndBgimgHandle()
this.$store.commit('updateGlobalCurrentCardMut', res.data)
// 注册webso事件
this.addSocketHandle()
// 分享进入记录访客:
if (isRecorder) {
this.recorderVisitor(share)
}
// 有自我介绍视频id时获取视频:
if (res.data?.sound) {
await this.getIntroVideo(res.data).catch(() => {
this.playAudio(res.data)
})
this.playAudio(res.data)
}
}
})
} else {
// 没有id时查询的是自己的名片
fetchCardDetail().then(res => {
if (res && res.code == 0 && res.data) {
this.cardInfo = res.data
console.log('自己的名片数据', res.data)
this.currentIsMyCard = true
this.avatarAndBgimgHandle(true)
// setDataLocal('currentCard', res.data)
this.$store.commit('updateGlobalCurrentCardMut', res.data)
// 注册webso事件
this.addSocketHandle()
// 修改store中自己名片信息:
this.$store.commit('updateGlobalMyCardMut', res.data)
if (res.data.sound) {
this.getIntroVideo(res.data).then((updateTip) => {
if (updateTip) {
uni.showToast({ title: '形象复刻已升级,您可在<我的-形象复刻>重新生成', icon: 'none', duration: 4000 })
}
this.playAudio(res.data)
}).catch((err) => {
this.playAudio(res.data)
})
}
} else {
// 不存在我的卡片,获取老板boss卡片
fetchContactList({ isDefault: 1 }).then((res) => {
if (res && res.code == 0) {
const bossCard = res.data.list[0]
this.cardInfo = bossCard
this.currentIsMyCard = false
this.avatarAndBgimgHandle()
// if (!this.cardInfo.videoUrl) {
// this.getCardInfo(this.cardInfo.cardId, false)
// }
// setDataLocal('CURRENT_CARD', bossCard)
this.$store.commit('updateGlobalCurrentCardMut', bossCard)
// 注册webso事件
this.addSocketHandle()
if (bossCard.sound) {
// 获取自我介绍视频后播放音频
this.getIntroVideo(bossCard).then(() => {
this.playAudio(bossCard)
}).catch((err) => {
this.playAudio(bossCard)
})
}
}
})
}
})
}
},
// 根据名片id查询名片照片信息:
avatarAndBgimgHandle(isMy){
let obj = {
bmCardId: this.cardInfo.cardId,
type: 0 // 0-形象照,1-证件照
}
queryAvatarAndBgimgApi(obj).then(res => {
if (res.code == 200 && res.data) {
console.log('执行了')
let temp = {
...this.cardInfo,
x: res.data.x,
w: res.data.w,
h: res.data.h,
y: res.data.y,
myCardId: res.data.id,
bottomImgUrl: res.data.bottomImgUrl
}
this.cardInfo = temp
this.$store.commit('updateGlobalCurrentCardMut', temp)
console.log('更新当前头像定位数据:', temp)
if (isMy) {
console.log('更新自己头像定位数据:', temp)
// 修改store中自己名片信息:
this.$store.commit('updateGlobalMyCardMut', temp)
}
}
})
},
// 获取用户信息:
getUserInfoHandle(){
getUserInfoApi().then(res => {
if (res.code == 0) {
let temp = {
...this.globalUserInfo,
...res.data
}
this.$store.commit('updateGlobalUserInfoMut', temp)
// 是否有昵称或头像
if ((!this.globalUserInfo.nickname) || (!this.globalUserInfo.imgUrl)) {
this.isOpenAvatorAndNickNameModal = true
}
}
})
},
// 获取自我介绍视频(resolve是否需要提示形象复刻升级)
getIntroVideo(cardData) {
const { videoUrl = '', robotId = '', mouthImage = '', unMouthImage = '' } = cardData
return new Promise((resolve, reject) => {
if (mouthImage && unMouthImage) {
// 存在已生成的形象,直接使用
this.introVideo = mouthImage
this.staticVideo = unMouthImage
this.defaultVideo = ''
resolve(false)
return
}
// 废弃-------------------------------------------------------------------------------------------------------
if (videoUrl || robotId) {
// 未生成形象,从老形象接口获取
fetchVideoStatusByFileSeq({ fileSeq: videoUrl || robotId }).then((res) => {
if (res && res.code === 0 && res.data?.status === 1) {
// 视频资源地址(在线形式)
const resUrl = res.data?.minioData?.[0]?.name
this.introVideo = ''
this.staticVideo = ''
this.defaultVideo = resUrl
// 仅当视频是老的版本,进行提示
resolve(true)
} else {
this.introVideo = ''
this.staticVideo = ''
this.defaultVideo = ''
reject(new Error('视频接口请求失败'))
}
})
} else {
this.introVideo = ''
this.staticVideo = ''
this.defaultVideo = ''
resolve(false)
}
// 废弃-------------------------------------------------------------------------------------------------------
})
},
// 自动播放自我介绍音频
playAudio(cardInfo) {
this.isPlay = true
this.$WSI.textToVoice(cardInfo.personalProfile, cardInfo.soundUrl, true, this.cardInfo.sound)
},
// 注册webSocket事件回调:
addSocketHandle(){
// 1.注册websock事件:
let objTemp = {
method: "authReq",
robotId: this.globalCurrentCard.robotId,
userIdentity: this.globalUserInfo.openId
}
this.$ws.send(objTemp)
this.$nextTick(() => {
let objTemp1 = {
method: "heartbeat",
userIdentity: this.globalUserInfo.openId
}
this.$ws.send(objTemp1)
})
},
// 新增好友:
recorderVisitor(shareId) {
const payload = {
cardId: shareId
}
fetchRecorderVisitor(payload)
},
// 初始化网络状态和监听网络状态变化:
initNetWorkStatus(){
let status = this.$NET.getNetWorkStatus()
this.$store.commit('updateGlobalNetWorkStatusMut', status)
// 监听网络状态发生变化:
this.$NET.addCallBack('netWorkStatus', this.updateNetWork)
},
// 需改网络状态:
updateNetWork(e){
this.$store.commit('updateGlobalNetWorkStatusMut', e)
if (!e) {
uni.showLoading({ title: '网络连接已断开,正在尝试重连!', mask: false })
} else {
uni.hideLoading()
}
},
// 发文本:
async sendMsgHandle(){
if (!this.inputValue) return
// 查询聊天次数是否有效:
let num = await this.isValidChatCountHandle()
if (num <= 0) return uni.showToast({ title: '您的次数已用完!', icon: 'none', duration: 2000 })
const obj = {
type: 'voiceToText',
content: this.inputValue,
isMy: true
}
this.onMsg(obj)
this.inputValue = ''
},
// 监听语音模块文字推送:
onMsg(msg){
if (msg == 'stopVideo') {
// 停止播放后从队列中继续拿第一条文字转语音
clearTimeout(this.msgTimeId)
this.msgTimeId = setTimeout(() => {
if (this.queueList[0] && this.isPlay) {
this.$WSI.textToVoice(this.queueList[0], null, true, this.cardInfo.sound)
this.isPlay = false
} else {
clearTimeout(this.stopTimeId)
this.stopTimeId = setTimeout(() => {
this.isPlay = false
}, 300)
}
}, 1000)
} else if (msg == 'playVideo') {
// 视频开始播放时,删除队列中上一次第一条文字:
this.msgEndTimeId = setTimeout(() => {
if (this.queueList.length != 0 && (!this.isPlay)) {
// 播放视频:
this.isPlay = true
this.queueList.shift()
}
}, 800)
} else if (msg?.type == 'voiceToText') {
let objTemp = {
cardId: this.globalCurrentCard.cardId,
chatModel: 1,
contents: [{
type: '文本',
data: {
content: msg.content,
},
}],
method: "aiChatReq",
robotId: this.globalCurrentCard.robotId,
terminalId: terminalId,
userIdentity: this.globalUserInfo.openId
}
let rid = 'id' + Math.floor(Math.random()*(9-99999999)+99999999) + '' + Date.parse(new Date())
this.currentMsgId = rid
const obj = {
type: 'voiceToText',
content: msg.content,
isMy: true,
id: rid
}
this.chatList.push(obj)
this.$ws.send(objTemp)
// 扣减聊天次数:
let objNum = {
userId: this.globalUserInfo.id
}
deductChatNumApi(objNum)
// 记录聊天消息
let redMsg = {
message: msg.content,
receiveUserId: this.globalCurrentCard.cardId,
sendUserId: this.globalUserInfo.id,
type: 0
}
recordChatMsgApi(redMsg)
}
},
// 判断聊天次数余额是否有效:
isValidChatCountHandle(){
return new Promise(resolve => {
let obj = {
userId: this.globalUserInfo.id
}
getValidChatCountApi(obj).then(res => {
if (res.code != 200) return uni.showToast({ title: res.msg || '查询聊天次数接口异常!', duration: 2000, icon: 'none' })
resolve(res.data)
})
})
},
// 消息推送过来:
onMsgWebSocket(data) {
if (!(data.contents && data.contents.length != 0)) return
let contentTemp = data.contents[0]
if (contentTemp.type == '文本') {
this.bufferString = this.bufferString + contentTemp.data.content
clearTimeout(this.bufferMsgTimeId)
this.bufferMsgTimeId = setTimeout(() => {
this.sendMsg(this.bufferString)
this.bufferString = ''
this.voiceLock = true
}, 300)
} else if (contentTemp.type == '缓冲文本') {
this.sendMsg(contentTemp.data.content)
}
},
sendMsg(msgContent) {
if (msgContent == '') return
let rid = 'current' + Math.floor(Math.random()*(9-99999999)+99999999) + '' + Date.parse(new Date())
this.currentMsgId = rid
// 队列中添加数据:
this.queueList.push(msgContent)
const obj = {
type: 'voiceToText',
content: msgContent,
printText: '',
isMy: false,
id: rid
}
this.chatList.push(obj)
// 当为播放时才可以执行文字转语音
if (this.voiceLock && !this.isPlay) {
this.$WSI.textToVoice(this.queueList[0], null, true, this.cardInfo.sound)
this.voiceLock = false
}
// 记录聊天消息
let redMsg = {
message: msgContent,
receiveUserId: this.globalUserInfo.id,
sendUserId: this.globalCurrentCard.cardId,
type: 0
}
recordChatMsgApi(redMsg)
},
// 关闭获取头像和昵称弹框
closeHandle(){
this.isOpenAvatorAndNickNameModal = false
// 是否去到进入选择页面;
this.isSelectPageHandle()
},
// 保存头像和昵称(提交信息)
async saveHandle(e){
let list = []
// 提交信息:
let obj = {
nickname: e.nickname,
id: this.globalUserInfo.id
}
if (!e.isReandomCreate) {
// 上传头像
list = await uploadImg2([e.avatarUrl], '/common/upload', 'file')
obj.imgUrl = list[0]
} else {
obj.imgUrl = e.avatarUrl
}
updateUserInfoApi(obj).then(res => {
if (res.code != 200) return uni.showToast({ title: res.msg || '设置头像和昵称接口异常!', duration: 2000, icon: 'none' })
let temp = {
...this.globalUserInfo,
nickname: e.nickname,
imgUrl: obj.imgUrl
}
this.$store.commit('updateGlobalUserInfoMut', temp)
this.isOpenAvatorAndNickNameModal = false
// 是否自动进入选择页面;
// this.isSelectPageHandle()
})
},
// 跳转页面:
goPageHandle(){
if (!this.globalUserInfo.id) return uni.showToast({ title: '登录异常!', duration: 2000, icon: 'none' })
// 没有填写昵称和头像时:
if (((!this.globalUserInfo.nickname) || (!this.globalUserInfo.imgUrl)) && this.isLocked) {
this.isOpenAvatorAndNickNameModal = true
this.isLocked = false
return
}
// 是否去到进入选择页面;
this.isSelectPageHandle()
},
// 是否到选择进入页面:
isSelectPageHandle(){
// 判断是否付过款:
if (!this.globalUserInfo.vipConfigId) {
navigationToHandle('pageA', 'homePage')
} else {
navigationToHandle('pages', 'index')
}
},
// 按钮处理:
menuBtnClickHandle(e){
if (e.id == 1) {
// 分享弹框:
this.isOpenConfigShareModal = true
} else if (e.id == 5) {
// 海报下载
this.isOpenConfigShareModal = false
navigationToHandle('pageA', 'sharePoster', this.cardInfo)
} else {
// 关闭分享弹框:
this.isOpenConfigShareModal = false
}
}
}
}
</script>
<style lang='scss' scoped>
.main_box {
@include mx_wh_bc_r(100%, 100%, $color: false, $radius: false);
@include mx_flex($direction: column);
position: relative;
.share_main_box {
@include mx_flex($direction: row, $justify: false, $align: false);
@include mx_lb($type: absolute, $left: false, $bottom: 100rpx);
@include mx_wh_bc_r(100%, 550rpx, $color: #fff, $radius: 10rpx);
view, button {
@include mx_flex($direction: column, $justify: false, $align: center);
@include mx_mp($boxSize: border-box, $padding: 78rpx 0 0, $margin: false);
flex: 1;
@include mx_wh_bc_r($width: false, $height: 100%, $color: #fff, $radius: false);
&::after {
border: none;
background: transparent;
}
image{
@include mx_wh_bc_r(120rpx, 120rpx, $color: false, $radius: 50%);
}
text{
margin-top: 11rpx;
heihgt: 33rpx;
@include mx_font($size: 24rpx, $color: #333, $weight: 400, $lineHeight: 33rpx, $fontFamily: false);
}
}
}
.head_top {
position: relative;
@include mx_wh_bc_r($width: 100%, $height: false, $color: false, $radius: false);
.go_home {
@include mx_flex($direction: row, $justify: center, $align: center);
@include mx_wh_bc_r($width: 112rpx, $height: 51rpx, $color: false, $radius: 26rpx);
@include mx_lb($type: absolute, $left: 25rpx, $bottom: 20rpx);
@include mx_clearbtn($bgcolor: transparent);
z-index: 99999;
image {
@include mx_wh_bc_r($width: 40rpx, $height: 40rpx, $color: false, $radius: false);
}
text {
@include mx_flex($direction: row, $justify: center, $align: center);
@include mx_font($size: 18rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
}
border: 1rpx solid #fff;
}
button:after {
border: none;
border-radius: none;
}
.share_box {
@include mx_flex($direction: row, $justify: false, $align: center);
@include mx_lb($type: absolute, $left: 400rpx, $bottom: 20rpx);
@include mx_wh_bc_r($width: false, $height: 50rpx, $color: false, $radius: false);
image {
@include mx_wh_bc_r($width: 32rpx, $height: 32rpx, $color: false, $radius: false);
}
text {
margin-left: 10rpx;
@include mx_font($size: 22rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
}
}
}
.content_box {
flex: 1;
@include mx_flex($direction: column, $justify: flex-end, $align: false);
position: relative;
overflow: hidden;
.chat_box {
margin: 0 auto;
@include mx_wh_bc_r($width: 90%, $height: 500rpx, $color: false, $radius: false);
.msg_box {
@include mx_flex($direction: row, $justify: false, $align: center);
width: 100%;
margin: 10rpx 0;
.msg {
@include mx_mp($boxSize: border-box, $padding: 20rpx, $margin: 33rpx 0 0);
@include mx_font($size: 26rpx, $color: #fff, $weight: 700, $lineHeight: false, $fontFamily: false);
}
}
.left_box {
flex-direction: row;
.msg {
@include mx_wh_bc_r($width: false, $height: false, $color: rgba(0, 186, 173, 0.3), $radius: 0 14rpx 14rpx 14rpx);
}
.date {
padding-left: 32rpx;
}
}
.right_box {
flex-direction: row-reverse;
.msg {
@include mx_wh_bc_r($width: false, $height: false, $color: rgba(213, 119, 247, 0.3), $radius: 14rpx 0 14rpx 14rpx);
}
.date {
padding-right: 32rpx;
}
}
}
.input_box {
@include mx_flex($direction: row, $justify: false, $align: center);
@include mx_wh_bc_r(100%, 150rpx, $color: flase, $radius: false);
border-top: 1rpx solid transparent;
z-index: 9999;
image {
margin: 0 20rpx;
@include mx_wh_bc_r($width: 55rpx, $height: 55rpx, $color: false, $radius: 50%);
}
input, .voice_btn {
flex: 1;
@include mx_font($size: 28rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
@include mx_mp($boxSize: border-box, $padding: 0 30rpx, $margin: false);
@include mx_wh_bc_r($width: false, $height: 66rpx, $color: #333, $radius: 36rpx);
}
.voice_btn {
text-align: center;
line-height: 58rpx;
}
.send_box {
margin: 0 20rpx;
@include mx_wh_bc_r($width: 120rpx, $height: 66rpx, $color: false, $radius: 36rpx);
@include mx_flex($direction: row, $justify: false, $align: center);
box-sizing: border-box;
border: 1rpx solid #fff;
image {
margin: 0 10rpx;
@include mx_wh_bc_r($width: 40rpx, $height: 40rpx, $color: false, $radius: 50%);
}
text {
@include mx_font($size: 22rpx, $color: #fff, $weight: false, $lineHeight: false, $fontFamily: false, $letterSpacing: false);
}
}
}
.top_key_bord_bg {
background: #D1D1DB;
}
}
background-color: #222629;
opacity: 0.9;
.video_box {
@include mx_ct($type: absolute, $left: 50%, $top: 50%, $translateX: -50%, $translateY: -50%);
@include mx_wh_bc_r($width: 100%, $height: 100%, $color: false, $radius: false);
z-index: -99;
.image_box {
@include mx_ct($type: relative, $left: 50%, $top: 50%, $translateX: -50%, $translateY: -50%);
image {
height: auto;
width: 100%;
}
video {
@include mx_lt($type: absolute, $left: 0, $top: 0);
@include mx_wh_bc_r($width: 500rpx, $height: 500rpx, $color: false, $radius: false);
@include mx_mp($boxSize: border-box, $padding: 0, $margin: 0);
}
}
}
.copy_voice {
@include mx_wh_bc_r(262rpx, 262rpx, $color: false, $radius: false);
@include mx_ct($type: absolute, $left: 50%, $top: 44%, $translateX: -50%, $translateY: -50%);
@include mx_bic($url: '../../static/images/speakWrap.png', $color: false, $size: cover, $repeat: no-repeat);
@include mx_mp($boxSize: border-box, $padding: 80rpx 0 0, $margin: false);
text-align: center;
z-index: 9999;
image {
@include mx_wh_bc_r(100%, 45%, $color: false, $radius: false);
}
text {
@include mx_font($size: 22rpx, $color: #fff, $weight: 400, $lineHeight: 45rpx, $fontFamily: false);
}
}
}
</style>
实现思路:
图片采集页面和聊天页面放置底图的容器尺寸都是:宽度100%,高度由图片撑开,视频和图片抓取尺寸定位一致,定位都相对于底图按百分比计算,图片采集实现步骤推导如下:
疑问解答:嘴巴会动、眼睛会动、安卓端裁剪的图片生成的视频在ios端底部有1像素留白(兼容性问题,后面可优化,上述代码开发阶段,有bug正常,再优化迭代中…)
提示:本文图片等素材来源于网络,若有侵权,请发邮件至邮箱:[email protected]联系笔者删除。
笔者:苦海123
其它问题可通过以下方式联系本人咨询:
QQ:810665436
微信:ConstancyMan