| @@ -0,0 +1,70 @@ | |||
| * { | |||
| margin: 0; | |||
| padding: 0; | |||
| } | |||
| html, body { | |||
| height: 100%; | |||
| } | |||
| button { | |||
| padding: 1px 6px; | |||
| } | |||
| video { | |||
| background: #222; | |||
| margin: 0 0 0 0; | |||
| width: 320px; | |||
| height: 240px; | |||
| } | |||
| .container { | |||
| display: flex; | |||
| height: 100%; | |||
| flex-direction: column; | |||
| } | |||
| .top { | |||
| margin: 0.5em; | |||
| } | |||
| .middle { | |||
| flex: 1; | |||
| overflow: auto; | |||
| margin: 0 0.5em; | |||
| } | |||
| .log { | |||
| overflow: scroll; | |||
| width: 30em; | |||
| height: 100%; | |||
| background-color: #eeeeee; | |||
| float: left; | |||
| } | |||
| .preview { | |||
| width: auto; | |||
| height: 100%; | |||
| float: left; | |||
| } | |||
| .local { | |||
| margin: 0.5em 0.5em; | |||
| float: left; | |||
| } | |||
| .remote { | |||
| margin: 0.5em 0.5em 0.5em 0; | |||
| float: left; | |||
| } | |||
| .sdp { | |||
| width: 320px; | |||
| height: 200px; | |||
| margin: 0; | |||
| resize: none; | |||
| } | |||
| .bottom { | |||
| margin: 0.5em; | |||
| } | |||
| @@ -0,0 +1,503 @@ | |||
| 'use strict' | |||
| //本地视频预览窗口 | |||
| var localVideo = document.querySelector('video#localvideo'); | |||
| //远端视频预览窗口 | |||
| var remoteVideo = document.querySelector('video#remotevideo'); | |||
| //查看Offer文本窗口 | |||
| var offer = document.querySelector('textarea#offer'); | |||
| //查看Answer文本窗口 | |||
| var answer = document.querySelector('textarea#answer'); | |||
| var pcConfig = { | |||
| 'iceServers': [{ | |||
| //TURN服务器地址 | |||
| 'urls': 'turn:43.143.227.135:3478', | |||
| //TURN服务器用户名 | |||
| 'username': "ghb", | |||
| //TURN服务器密码 | |||
| 'credential': "moonshine" | |||
| }], | |||
| //默认使用relay方式传输数据 | |||
| "iceTransportPolicy": "relay", | |||
| "iceCandidatePoolSize": "0" | |||
| }; | |||
| //websocket连接 | |||
| var conn; | |||
| //会话描述 | |||
| var session; | |||
| //本地视频流 | |||
| var localStream = null; | |||
| //远端视频流 | |||
| var remoteStream = null; | |||
| //PeerConnection | |||
| var pc = null; | |||
| //offer描述 | |||
| var offerdesc = null; | |||
| /** | |||
| * 功能: 判断此浏览器是在PC端,还是移动端。 | |||
| * 返回值: false, 说明当前操作系统是移动端 | |||
| * true, 说明当前的操作系统是PC端。 | |||
| */ | |||
| function IsPC() { | |||
| var userAgentInfo = navigator.userAgent; | |||
| var Agents = ["Android", "iPhone", "SymbianOS", "Windows Phone", "iPad", "iPod"]; | |||
| var flag = true; | |||
| for (var v = 0; v < Agents.length; v++) { | |||
| if (userAgentInfo.indexOf(Agents[v]) > 0) { | |||
| flag = false; | |||
| break; | |||
| } | |||
| } | |||
| return flag; | |||
| } | |||
| /** | |||
| * 功能: 判断是Android端还是iOS端。 | |||
| * 返回值: true,说明是Android端 | |||
| * false,说明是iOS端。 | |||
| */ | |||
| function IsAndroid() { | |||
| var u = navigator.userAgent, app = navigator, appVersion; | |||
| var isAndroid = u.indexOf('Android') > -1 || u.indexOf('Linux') > -1; | |||
| var isIOS = !!u.match(/\(i[^;]+;( U;) ? CPU.+Mac OS X/); | |||
| if (isAndroid) { | |||
| //这个是Android系统 | |||
| return true; | |||
| } | |||
| if (isIOS) { | |||
| //这个是iOS系统 | |||
| return false; | |||
| } | |||
| } | |||
| /** | |||
| * 功能: 向对端发消息 | |||
| * 返回值: 无 | |||
| */ | |||
| function sendMessage(roomid, data) { | |||
| console.log('send message to other end', roomid, data); | |||
| if (!socket) { | |||
| console.log('socket is null'); | |||
| } | |||
| socket.emit('message', roomid, data); | |||
| } | |||
| /** | |||
| *功能: 与信令服务器建立socket.io连接;并根据信令更新状态机。 | |||
| *返回值: 无 | |||
| */ | |||
| function conn() { | |||
| //连接信令服务器 | |||
| socket = io.connect(); | |||
| //joined'消息处理函数 | |||
| socket.on('joined', (roomid, id) => { | |||
| console.log('receive joined message!', roomid, id); | |||
| //状态机变更为joined' | |||
| state = 'joined'; | |||
| /** | |||
| * 如果是Mesh方案,第一个人不该在这里创建 | |||
| * peerConnection,而是要等到所有端都收到一个'otherjoin'消息时再创建 | |||
| */ | |||
| //创建PeerConnection 并绑定音视频轨 | |||
| createPeerConnection(); | |||
| bindTracks(); | |||
| //设置button状态 | |||
| btnConn.disabled = true; | |||
| btnLeave.disabled = false; | |||
| console.log('receive joined message, state = ', state); | |||
| }); | |||
| //otherjoin消息处理函数 | |||
| socket.on('other_join', (roomid) => { | |||
| console.log('receive joined message:', roomid, state); | |||
| //如果是多人,每加入一个人都要创建一个新的 PeerConnection | |||
| if (state === 'joined_unbind') { | |||
| createPeerConnection(); | |||
| bindTracks(); | |||
| } | |||
| //状态机变更为 joined_conn | |||
| state = 'joined_conn'; | |||
| //开始“呼叫”对方 | |||
| call1(); | |||
| console.log('receive other_join message, state = ', state); | |||
| }); | |||
| //full消息处理函数 | |||
| socket.on('full', (roomid, id) => { | |||
| console.log('receive full message', roomid, id); | |||
| //关闭gocket.io连接 | |||
| socket.disconnect(); | |||
| //挂断“呼叫” | |||
| hangup(); | |||
| //关闭本地媒体 | |||
| closeLocalMedia(); | |||
| //状态机变更为 leaved | |||
| state = 'leaved'; | |||
| console.log('receive full message, state = ', state); | |||
| alert('the room is full!'); | |||
| }); | |||
| //leaved消息处理函数 | |||
| socket.on('left', (roomid, id) => { | |||
| console.log('receive leaved message', roomid, id); | |||
| //状态机变更为leaved | |||
| state = 'leaved' | |||
| //关闭socket.io连接 | |||
| socket.disconnect(); | |||
| console.log('receive leaved message, state = ', state); | |||
| //改变button状态 | |||
| btnConn.disabled = false; | |||
| btnLeave.disabled = true; | |||
| }); | |||
| //bye消息处理函数 | |||
| socket.on('bye', (room, id) => { | |||
| console.log('receive bye message', roomid, id); | |||
| /** | |||
| * 当是Mesh方案时,应该带上当前房间的用户数, | |||
| * 如果当前房间用户数不小于 2,则不用修改状态, | |||
| * 并且关闭的应该是对应用户的PeerConnection。 | |||
| * 在客户端应该维护一张PeerConnection表,它是 | |||
| * key:value的格式,key=userid,value=peerconnection | |||
| */ | |||
| //状态机变更为 joined_unbind | |||
| state = 'joined_unbind'; | |||
| //挂断“呼叫” | |||
| hangup(); | |||
| offer.value = ''; | |||
| answer.value = ''; | |||
| console.log('receive bye message, state=', state); | |||
| }); | |||
| //socket.io连接断开处理函数 | |||
| socket.on('disconnect', (socket) => { | |||
| console.log('receive disconnect message!', roomid); | |||
| if (!(state === 'leaved')) { | |||
| //挂断“呼叫” | |||
| hangup(); | |||
| //关闭本地媒体 | |||
| closeLocalMedia(); | |||
| } | |||
| //状态机变更为 leaved | |||
| state = 'leaved'; | |||
| }); | |||
| //收到对端消息处理函数 | |||
| socket.on('message', (roomid, data) => { | |||
| console.log('receive message!', roomid, data); | |||
| if( data === null || data === undefined) { | |||
| console.error('the message is invalid!'); | |||
| return; | |||
| } | |||
| //如果收到的SDP是offer | |||
| if (data.hasOwnProperty('type') && data.type === 'offer') { | |||
| offer.value = data.sdp; | |||
| //进行媒体协商 | |||
| pc.setRemoteDescription(new RTCSessionDescription(data)); | |||
| //创建answer | |||
| pc.createAnswer() | |||
| .then(getAnswer) | |||
| .catch(handleAnswerError); | |||
| //如果收到的SDP是answer | |||
| } else if (data.hasOwnProperty('type') && data.type == 'answer') { | |||
| answer.value = data.sdp; | |||
| //进行媒体协商 | |||
| pc.setRemoteDescription(new RTCSessionDescription(data)); | |||
| //如果收到的是Candidate消息 | |||
| } else if (data.hasOwnProperty('type') && data.type === 'candidate') { | |||
| var candidate = new RTCIceCandidate({ | |||
| sdpMLineIndex: data.label, | |||
| candidate: data.candidate | |||
| }); | |||
| //将远端Candidate消息添加到PeerConnection中 | |||
| pc.addIceCandidate(candidate); | |||
| } else { | |||
| console.log('the message is invalid!', data); | |||
| } | |||
| }); | |||
| //从url中获取roomid | |||
| roomid = getQueryVariable('room'); | |||
| //发送'join'消息 | |||
| socket.emit('join', roomid); | |||
| return true; | |||
| } | |||
| /** | |||
| * 功能: 打开音视频设备成功时的回调函数 | |||
| * 返回值: 永远为true | |||
| */ | |||
| function getMediaStream(stream) { | |||
| //将从设备上获取到的音视频track添加到localStream中 | |||
| if (localStream) { | |||
| stream.getAudioTracks().forEach((track) => { | |||
| localStream.addTrack(track); | |||
| stream.removeTrack(track); | |||
| }); | |||
| } else { | |||
| localStream = stream; | |||
| } | |||
| //本地视频标签与本地流绑定 | |||
| localVideo.srcObject = localStream; | |||
| //创建PeerConnection 并绑定音视频轨 | |||
| createPeerConnection(); | |||
| bindTracks(); | |||
| } | |||
| /** | |||
| * 功能: 错误处理函数 | |||
| * | |||
| * 返回值: 无 | |||
| */ | |||
| function handleError(err) { | |||
| console.error('Failed to get Media Stream!', err); | |||
| } | |||
| /** | |||
| *功能: 打开音视频设备 | |||
| *返回值: 无 | |||
| */ | |||
| function startLocalMedia() { | |||
| if (!navigator.mediaDevices || | |||
| !navigator.mediaDevices.getUserMedia) { | |||
| console.error('the getUserMedia is not supported!'); | |||
| return; | |||
| } else { | |||
| var constraints; | |||
| constraints = { | |||
| video: true, | |||
| audio: { | |||
| echoCancellation: true, | |||
| noiseSuppression: true, | |||
| autoGainControl: true | |||
| } | |||
| }; | |||
| navigator.mediaDevices.getUserMedia(constraints) | |||
| .then(getMediaStream) | |||
| .catch(handleError); | |||
| } | |||
| } | |||
| /** | |||
| * 功能: 获得远端媒体流 | |||
| * 返回值: 无 | |||
| */ | |||
| function getRemoteStream(e) { | |||
| //存放远端视频流 | |||
| remoteStream = e.streams[0]; | |||
| //远端视频标签与远端视频流绑定 | |||
| remoteVideo.srcObject = e.streams[0]; | |||
| } | |||
| /** | |||
| * 功能: 处理Offer错误 | |||
| * 返回值: 无 | |||
| */ | |||
| function handleOfferError(err) { | |||
| console.error('Failed to create offer:', err); | |||
| } | |||
| /** | |||
| *功能:处理Answer错误 | |||
| *返回值:无 | |||
| */ | |||
| function handleAnswerError(err) { | |||
| console.error('Failed to create answer;', err); | |||
| } | |||
| /** | |||
| *功能:获取Answer SDP 描述符的回调函数 | |||
| * | |||
| *返回值:无 | |||
| */ | |||
| function getAnswer(desc) { | |||
| //设置Answer | |||
| pc.setLocalDescription(desc); | |||
| //将Answer显示出来 | |||
| answer.value = desc.sdp; | |||
| //将AnswerSDP发送给对端 | |||
| let req = new Object; | |||
| req.cmd = 'CMD_WEBRTC'; | |||
| req.data = desc; | |||
| let str = JSON.stringify(req); | |||
| console.log(str); | |||
| conn.send(str); | |||
| } | |||
| /** | |||
| *功能:获取Offer SDP 描述符的回调函数 | |||
| *返回值:无 | |||
| */ | |||
| function getOffer(desc) { | |||
| //设置Offer | |||
| pc.setLocalDescription(desc); | |||
| //将Offer显示出来 | |||
| offer.value = desc.sdp; | |||
| offerdesc = desc; | |||
| console.log(offerdesc) | |||
| //将 OfferSDP发送给对端 | |||
| let req = new Object; | |||
| req.cmd = 'CMD_WEBRTC'; | |||
| req.data = desc; | |||
| let str = JSON.stringify(req); | |||
| console.log(str); | |||
| conn.send(str); | |||
| } | |||
| /** | |||
| * 功能: 创建PeerConnection对象 | |||
| * 返回值: 无 | |||
| */ | |||
| function createPeerConnection() { | |||
| /* | |||
| * 如果是多人的话,在这里要创建一个新的连接 | |||
| * 新创建好的要放到一个映射表中 | |||
| */ | |||
| //key=userid, value=peerconnection | |||
| console.log('create RTCPeerConnection!'); | |||
| if (!pc) { | |||
| //创建PeerConnection对象 | |||
| pc = new RTCPeerConnection(pcConfig); | |||
| //当收集到Candidate后 | |||
| pc.onicecandidate = (event) => { | |||
| if (event.candidate) { | |||
| console.log("candidate" + JSON.stringify(event.candidate.toJSON())); | |||
| //将Candidate发送给对端 | |||
| let req = new Object; | |||
| req.cmd = 'CMD_WEBRTC'; | |||
| req.data = { | |||
| type: 'candidate', | |||
| label: event.candidate.sdpMLineIndex, | |||
| id: event.candidate.sdpMid, | |||
| candidate: event.candidate.candidate | |||
| }; | |||
| let str = JSON.stringify(req); | |||
| console.log(str); | |||
| conn.send(str); | |||
| } else { | |||
| console.log('this is the end candidate'); | |||
| } | |||
| } | |||
| /** | |||
| * 当PeerConnection对象收到远端音视频流时 | |||
| * 触发ontrack事件,并回调getRemoteStream函数 | |||
| */ | |||
| pc.ontrack = getRemoteStream; | |||
| } else { | |||
| console.log('the pc have be created!'); | |||
| } | |||
| return; | |||
| } | |||
| /** | |||
| * 功能: 将音视频track绑定到PeerConnection对象中 | |||
| * 返回值: 无 | |||
| */ | |||
| function bindTracks() { | |||
| console.log('bind tracks into RTCPeerConnection!'); | |||
| if (pc === null && localStream === undefined) { | |||
| console.error('pc is null or undefined!'); | |||
| return; | |||
| } | |||
| if (localStream === null && localStream === undefined) { | |||
| console.error('localstream is null or undefined!'); | |||
| return; | |||
| } | |||
| //将本地音视频流中所有的track添加到PeerConnection对象中 | |||
| localStream.getTracks().forEach((track)=>{ | |||
| pc.addTrack(track, localStream); | |||
| }); | |||
| } | |||
| /** | |||
| * 功能: 开启“呼叫” | |||
| * 返回值: 无 | |||
| */ | |||
| function videoCall() { | |||
| var offerOptions = { | |||
| offerToReceiveAudio: 1, | |||
| offerToReceiveVideo: 1 | |||
| }; | |||
| /** | |||
| * 创建Offer, | |||
| * 如果成功,则回调getoffer()方法 | |||
| * 如果失败,则回调handleofferError()方法 | |||
| */ | |||
| pc.createOffer(offerOptions) | |||
| .then(getOffer) | |||
| .catch(handleOfferError); | |||
| } | |||
| /** | |||
| * 功能: 挂断“呼叫” | |||
| * 返回值: 无 | |||
| */ | |||
| function hangup() { | |||
| if (!pc) { | |||
| return; | |||
| } | |||
| //发送“挂断”消息 | |||
| let r = new Object; | |||
| r.cmd = "CMD_HANGUP"; | |||
| let str = JSON.stringify(r); | |||
| conn.send(str); | |||
| /* | |||
| offerdesc = null; | |||
| //将PeerConnection连接关掉 | |||
| pc.close(); | |||
| pc = null; | |||
| */ | |||
| } | |||
| /** | |||
| * 功能:关闭本地媒体 | |||
| * 返回值:无 | |||
| */ | |||
| function closeLocalMedia() { | |||
| if (!(localStream === null || localStream === undefined)) { | |||
| //遍历每个track,并将其关闭 | |||
| localStream.getTracks().forEach((track) => { | |||
| track.stop(); | |||
| }); | |||
| } | |||
| localStream = null; | |||
| } | |||
| /** | |||
| *功能:离开房间 | |||
| *返回值:无 | |||
| */ | |||
| function leave() { | |||
| //向信令服务器发送leave消息 | |||
| socket.emit('leave', roomid); | |||
| //挂断“呼叫” | |||
| hangup(); | |||
| //关闭本地媒体 | |||
| closeLocalMedia(); | |||
| offer.value = ''; | |||
| answer.value = ''; | |||
| } | |||
| @@ -0,0 +1,196 @@ | |||
| <!DOCTYPE html> | |||
| <html> | |||
| <head> | |||
| <title>用户页面</title> | |||
| <link href="./css/client.css" rel="stylesheet"/> | |||
| <script type="text/javascript"> | |||
| var username; | |||
| var msg; | |||
| var log; | |||
| window.onload = function () { | |||
| username = document.getElementById("username"); | |||
| msg = document.getElementById("msg"); | |||
| log = document.getElementById("log"); | |||
| if (window["WebSocket"]) { | |||
| // 连接服务器 | |||
| conn = new WebSocket("wss://" + document.location.host + "/webrtc"); | |||
| conn.onclose = function (evt) { | |||
| appendLog("连接已断开."); | |||
| }; | |||
| conn.onmessage = function (e) { | |||
| res = JSON.parse(e.data) | |||
| if (res.cmd == 'CMD_USER_LOGIN_ACK') { | |||
| if (res.error === 0) { | |||
| appendLog("登录成功。"); | |||
| // 启动本地音视频 | |||
| startLocalMedia(); | |||
| } else { | |||
| appendLog("登录失败,error: " + res.error); | |||
| } | |||
| } else if (res.cmd === 'CMD_CALL_ACK') { | |||
| if (res.error === 0) | |||
| appendLog("呼叫成功。"); | |||
| else | |||
| appendLog("呼叫失败,error: " + res.error); | |||
| } else if (res.cmd === 'CMD_SESSION_BEGIN') { | |||
| session = new Object; | |||
| session.staffer = res.staffer; | |||
| session.user = res.user; | |||
| session.iceServers = res.iceServers | |||
| appendLog("开始由 " + res.staffer + " 为您服务"); | |||
| console.log(session); | |||
| if (!pc) { | |||
| createPeerConnection(); | |||
| bindTracks(); | |||
| } | |||
| videoCall(); | |||
| } else if (res.cmd === 'CMD_SESSION_END') { | |||
| if (res.error === 0) | |||
| appendLog("会话已结束。"); | |||
| else | |||
| appendLog("会话异常结束,error: " + res.error); | |||
| if (pc != null) { | |||
| pc.close(); | |||
| pc = null; | |||
| offerdesc = null; | |||
| offer.value = ""; | |||
| answer.value = ""; | |||
| } | |||
| } else if (res.cmd === 'CMD_CHAT') { | |||
| appendLog(session.staffer + " 说:" + res.text); | |||
| } else if (res.cmd === 'CMD_WEBRTC') { | |||
| //如果收到的SDP是offer | |||
| if (res.data.hasOwnProperty('type') && res.data.type === 'offer') { | |||
| offer.value = res.data.sdp; | |||
| //进行媒体协商 | |||
| pc.setRemoteDescription(new RTCSessionDescription(res.data)); | |||
| //创建answer | |||
| pc.createAnswer() | |||
| .then(getAnswer) | |||
| .catch(handleAnswerError); | |||
| //如果收到的SDP是answer | |||
| } else if (res.data.hasOwnProperty('type') && res.data.type == 'answer') { | |||
| answer.value = res.data.sdp; | |||
| //进行媒体协商 | |||
| pc.setRemoteDescription(new RTCSessionDescription(res.data)); | |||
| //如果收到的是Candidate消息 | |||
| } else if (res.data.hasOwnProperty('type') && res.data.type === 'candidate') { | |||
| var candidate = new RTCIceCandidate({ | |||
| sdpMLineIndex: res.data.label, | |||
| candidate: res.data.candidate | |||
| }); | |||
| //将远端Candidate消息添加到PeerConnection中 | |||
| pc.addIceCandidate(candidate); | |||
| } | |||
| } | |||
| }; | |||
| } else { | |||
| appendLog("Your browser does not support WebSockets."); | |||
| } | |||
| }; | |||
| function appendLog(text) { | |||
| let item = document.createElement("div"); | |||
| item.innerText = text; | |||
| let doScroll = log.scrollTop > log.scrollHeight - log.clientHeight - 1; | |||
| log.appendChild(item); | |||
| if (doScroll) { | |||
| log.scrollTop = log.scrollHeight - log.clientHeight; | |||
| } | |||
| } | |||
| function login() { | |||
| let req = new Object; | |||
| req.cmd = "CMD_USER_LOGIN"; | |||
| req.username = username.value; | |||
| req.password = "123456"; | |||
| req.agent = "browser"; | |||
| let str = JSON.stringify(req); | |||
| console.log(str); | |||
| conn.send(str); | |||
| } | |||
| function chat() { | |||
| if (!conn) { | |||
| return false; | |||
| } | |||
| if (!msg.value) { | |||
| return false; | |||
| } | |||
| appendLog("我说:" + msg.value); | |||
| let req = new Object; | |||
| req.cmd = "CMD_CHAT"; | |||
| req.text = msg.value; | |||
| let str = JSON.stringify(req); | |||
| console.log(str); | |||
| conn.send(str); | |||
| msg.value = ""; | |||
| return false; | |||
| } | |||
| function call() { | |||
| let req = new Object; | |||
| req.cmd = "CMD_CALL"; | |||
| req.caller = username.value; | |||
| req.callee = ""; | |||
| let str = JSON.stringify(req); | |||
| console.log(str); | |||
| conn.send(str); | |||
| } | |||
| </script> | |||
| </head> | |||
| <body> | |||
| <div class="container"> | |||
| <div class="top"> | |||
| <label>用户账号</label> | |||
| <input id="username"></input> | |||
| <button id="login" onclick="login()">登录</button> | |||
| <button id="call" onclick="call()">呼叫</button> | |||
| <button id="call" onclick="hangup()">挂断</button> | |||
| </div> | |||
| <div class="middle"> | |||
| <div class="log" id="log"></div> | |||
| <div class="preview"> | |||
| <div class="local"> | |||
| <p>Local:</p> | |||
| <video id="localvideo" autoplay playsinline muted></video> | |||
| <p>Offer SDP:</p> | |||
| <textarea class="sdp" id="offer"></textarea> | |||
| </div> | |||
| <div class="remote"> | |||
| <p>Remote:</p> | |||
| <video id="remotevideo" autoplay playsinline></video> | |||
| <p>Answer SDP:</p> | |||
| <textarea class="sdp" id="answer"></textarea> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="bottom"> | |||
| <textarea id="msg" cols="70" rows="6" style="overflow: visible; resize: none"></textarea> | |||
| <button id="send" style="vertical-align: top;" onclick="chat()">发送</button> | |||
| </div> | |||
| </div> | |||
| <!-- | |||
| 引用的JavaScript脚本库: | |||
| socket.io.js:用于连接信令服务器 | |||
| adapter-latest.js:用于浏览器适配Chrome,Firefox... | |||
| client.js:WebRTC客户端代码 | |||
| --> | |||
| <script src="js/adapter-latest.js"></script> | |||
| <script src="js/client.js"></script> | |||
| </body> | |||
| </html> | |||
| @@ -5,15 +5,20 @@ | |||
| <div class="userInfo"> | |||
| <div class="nickName" @click="go('/myPage')">用户名</div> | |||
| <div class="userModel"> | |||
| <img | |||
| <!-- <img | |||
| class="userImg" | |||
| src="../../assets/img/user_black.png" | |||
| @click="go('/myPage')" | |||
| /> | |||
| <img | |||
| /> --> | |||
| <!-- <img | |||
| class="userImg" | |||
| src="../../assets/img/loginOut.png" | |||
| @click="loginOut()" | |||
| /> --> | |||
| <img | |||
| class="userImg" | |||
| src="../../assets/icon/icon-loginout.png" | |||
| @click="loginOut()" | |||
| /> | |||
| </div> | |||
| </div> | |||
| @@ -100,14 +105,15 @@ export default { | |||
| font-weight: 600; | |||
| font-size: 13px; | |||
| margin-right: 15px; | |||
| margin-top: 3px; | |||
| } | |||
| .userModel { | |||
| position: relative; | |||
| top: 3px; | |||
| margin-right: 10px; | |||
| .userImg { | |||
| width: 30px; | |||
| height: 30px; | |||
| width: 24px; | |||
| height: 24px; | |||
| margin-right: 2px; | |||
| } | |||
| } | |||
| @@ -44,19 +44,24 @@ const routes = [ | |||
| name: 'chooseModel', | |||
| component: () => import("../views/model/chooseModel"), | |||
| }, | |||
| // 模板预览 | |||
| // 模板预览new | |||
| { | |||
| path: '/previewModel', | |||
| name: 'previewModel', | |||
| component: () => import("../views/model/previewModel"), | |||
| }, | |||
| // 编辑模板 | |||
| // 编辑模板new | |||
| { | |||
| path: '/editModel', | |||
| name: 'editModel', | |||
| component: () => import("../views/model/editModel"), | |||
| }, | |||
| // 聊天首页new | |||
| { | |||
| path: '/chat', | |||
| name: 'chat', | |||
| component: () => import("@/views/chat/chat"), | |||
| }, | |||
| // 录入文案 | |||
| { | |||
| path: '/getPaper', | |||
| @@ -0,0 +1,61 @@ | |||
| <template> | |||
| <div :class="inPage ? 'page active' : 'page'"> | |||
| <div id="top"></div> | |||
| <!-- 主体内容 --> | |||
| <div class="contnet"> | |||
| <!-- 头部 --> | |||
| <div class="head"> | |||
| <img src="" alt=""> | |||
| <div>退出聊天</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </template> | |||
| <script> | |||
| // 组件 | |||
| import Vue from "vue"; | |||
| import { Toast } from "vant"; | |||
| import { mapState, mapActions } from "vuex"; | |||
| // 工具 | |||
| import { scrollToID } from "../../utils"; | |||
| Vue.use(Toast); | |||
| export default { | |||
| data() { | |||
| return { | |||
| inPage: false, | |||
| }; | |||
| }, | |||
| computed: { | |||
| ...mapState({ | |||
| characters: (state) => state.publicObj.characters, | |||
| }), | |||
| }, | |||
| methods: { | |||
| ...mapActions(["setCharacters"]), | |||
| go(url) { | |||
| this.$router.push(url); | |||
| }, | |||
| }, | |||
| created() {}, | |||
| mounted() { | |||
| this.inPage = true; | |||
| scrollToID("top"); | |||
| }, | |||
| }; | |||
| </script> | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| transform: translateX(30%); | |||
| opacity: 0; | |||
| transition: all 0.5s; // global-transition | |||
| &.active { | |||
| opacity: 1; | |||
| transform: translateX(0); | |||
| } | |||
| } | |||
| </style> | |||
| @@ -3,13 +3,14 @@ | |||
| <div id="top"></div> | |||
| <HeadTop /> | |||
| <!-- 顶部tab栏 --> | |||
| <van-tabs animated color="#7264F5" line-width=46 line-height=6 title-inactive-color="#666" title-active-color="#333"> | |||
| <van-tab v-for="item in tabList" :key="item.id" :title="item.name"> | |||
| <van-tabs @click="chooseType1" class="typeOutSide" animated color="#7264F5" line-width=46 line-height=6 title-inactive-color="#666" title-active-color="#333"> | |||
| <van-tab v-for="item in tabList" :key="item" :title="item"> | |||
| </van-tab> | |||
| </van-tabs> | |||
| <div class="title">选择模板</div> | |||
| <div class="typeOutSide"> | |||
| <!-- <div class="title">选择模板</div> --> | |||
| <!-- 老版吸顶tab栏 --> | |||
| <!-- <div class="typeOutSide"> | |||
| <div class="type"> | |||
| <div :class="type == 0 ? 'active' : ''" @click="chooseType(0)"> | |||
| 全部模板 | |||
| @@ -21,7 +22,7 @@ | |||
| 男性模板 | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> --> | |||
| <!-- 模板列表 --> | |||
| <div class="choose"> | |||
| <div class="modeList"> | |||
| @@ -67,15 +68,7 @@ export default { | |||
| type: 0, | |||
| currentId: "", | |||
| modeList: [], // 模板列表 | |||
| tabList: [ | |||
| { id: 1, name: '全部' }, | |||
| { id: 2, name: '男性' }, | |||
| { id: 3, name: '女性' }, | |||
| { id: 4, name: '原P' }, | |||
| { id: 5, name: '武装直升机' }, | |||
| { id: 6, name: '粥P' }, | |||
| { id: 7, name: '二刺猿' }, | |||
| ] | |||
| tabList: ['全部','男性','女性','原P','武装直升机','粥P','二刺猿'] | |||
| }; | |||
| }, | |||
| computed: { | |||
| @@ -86,7 +79,11 @@ export default { | |||
| methods: { | |||
| ...mapActions(["setCharacters"]), | |||
| //点击tab栏选择模板列表 | |||
| chooseType1(name, title) { | |||
| this.type = this.tabList.findIndex(item => item==title) | |||
| this.loadModeList(this.type); | |||
| }, | |||
| globalWatcher(e) { | |||
| console.log(e, "e"); | |||
| }, | |||
| @@ -99,10 +96,10 @@ export default { | |||
| this.phase = num; | |||
| }, | |||
| chooseType(num) { | |||
| this.type = num; | |||
| this.loadModeList(num); | |||
| }, | |||
| // chooseType(num) { | |||
| // this.type = num; | |||
| // this.loadModeList(num); | |||
| // }, | |||
| selectModel(id) { | |||
| if (this.currentId != id) { | |||
| @@ -235,9 +232,9 @@ export default { | |||
| } | |||
| .typeOutSide { | |||
| position: sticky; | |||
| top: 60px; | |||
| top: 0px; | |||
| z-index: 99; | |||
| .type { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| @@ -265,6 +262,7 @@ export default { | |||
| } | |||
| .choose { | |||
| padding-top: 20px; | |||
| margin-bottom: 40px; | |||
| .modeList { | |||
| @@ -7,15 +7,14 @@ | |||
| <!-- 展示图片 --> | |||
| <div v-if="imgUrl" class="shouModelImg"> | |||
| <img :src="imgUrl" alt=""> | |||
| <div v-show="showSubtitle" class="shouModelImg-subtitle"> | |||
| 中午吃啥? | |||
| </div> | |||
| <!-- 字幕 --> | |||
| <div v-html="textareaContent" v-show="showSubtitle" class="shouModelImg-subtitle"></div> | |||
| </div> | |||
| <!-- 文字 --> | |||
| <div class="addText"> | |||
| <div class="addText-title">添加文案</div> | |||
| <div class="desalinate"> | |||
| <span>已录入65字</span> | |||
| <span>已录入{{textareaContent.length}}字</span> | |||
| <span>视频时长: 00:16</span> | |||
| </div> | |||
| </div> | |||
| @@ -23,7 +22,11 @@ | |||
| <div class="textInput"> | |||
| <div class="textInput-in"> | |||
| <!-- 标题 --> | |||
| <el-input v-model="inputTitle" placeholder="请输入标题"></el-input> | |||
| <el-input v-model="inputTitle" placeholder="请输入标题"> | |||
| <template slot="append"> | |||
| <img src="../../assets/icon/icon-pencil.png" alt=""> | |||
| </template> | |||
| </el-input> | |||
| <div class="divider" /> | |||
| <!-- 语音内容 --> | |||
| <el-input rows="4" type="textarea" v-model="textareaContent"></el-input> | |||
| @@ -54,7 +57,7 @@ | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <!-- 底部导航栏 --> | |||
| <div class="bottom"> | |||
| <van-tabbar class="bottom-tab"> | |||
| @@ -101,7 +104,7 @@ | |||
| <!-- 顶部文字 --> | |||
| <div class="tabSheet-title"> | |||
| <div><img class="img" src="../../assets/icon/icon-microphone.png" alt=""> 更改声音</div> | |||
| <div><img src="../../assets/icon/icon-yes.png" alt=""></div> | |||
| <div><img class="yesImg" src="../../assets/icon/icon-yes.png" alt=""></div> | |||
| </div> | |||
| <!-- 主要内容 --> | |||
| <div class="tabSheet-content"> | |||
| @@ -124,7 +127,7 @@ | |||
| <!-- 顶部文字 --> | |||
| <div class="tabSheet-title"> | |||
| <div><img class="img" src="../../assets/icon/icon-background.png" alt=""> 更换背景</div> | |||
| <div><img src="../../assets/icon/icon-yes.png" alt=""></div> | |||
| <div><img class="yesImg" src="../../assets/icon/icon-yes.png" alt=""></div> | |||
| </div> | |||
| <!-- 主要内容 --> | |||
| <div class="tabSheet-content"> | |||
| @@ -143,7 +146,18 @@ | |||
| <input type="file"> | |||
| </div> | |||
| <!-- 图片列表 --> | |||
| <div @click="bjImgAction=index" class="myTab-show-list-item" :class="{'myTab-show-list-active':bjImgAction==index}" v-for="(item,index) in 7" :key="index" ></div> | |||
| <div @click="bjImgAction=index" class="myTab-show-list-item" v-for="(item,index) in 7" :key="index" > | |||
| <div :class=" {'myTab-show-list-active':bjImgAction==index}"> | |||
| <div class="whiteBox"> | |||
| </div> | |||
| </div> | |||
| <!-- 删除上传素材 --> | |||
| <div class="icon-error" v-if="bjAction==1&&bjImgAction==index"> | |||
| × | |||
| </div> | |||
| <!-- 中间图片 --> | |||
| <img src="" alt=""> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -153,7 +167,7 @@ | |||
| <!-- 顶部文字 --> | |||
| <div class="tabSheet-title"> | |||
| <div><img class="img" src="../../assets/icon/icon-material.png" alt=""> 更换素材</div> | |||
| <div><img src="../../assets/icon/icon-yes.png" alt=""></div> | |||
| <div><img class="yesImg" src="../../assets/icon/icon-yes.png" alt=""></div> | |||
| </div> | |||
| <!-- 主要内容 --> | |||
| <div class="tabSheet-content"> | |||
| @@ -167,12 +181,23 @@ | |||
| <div class="myTab-show-title">图片<i></i></div> | |||
| <div class="myTab-show-list"> | |||
| <!-- 上传 --> | |||
| <div v-if="bjAction==1" class="myTab-show-list-upload"> | |||
| <div v-if="scAction==1" class="myTab-show-list-upload"> | |||
| <img src="../../assets/icon/icon-upload@2x.png" alt=""> | |||
| <input type="file"> | |||
| </div> | |||
| <!-- 图片列表 --> | |||
| <div @click="scImgAction=index" class="myTab-show-list-item" :class="{'myTab-show-list-active':scImgAction==index}" v-for="(item,index) in 7" :key="index" ></div> | |||
| <div @click="scImgAction=index" class="myTab-show-list-item" v-for="(item,index) in 7" :key="index" > | |||
| <div :class=" {'myTab-show-list-active':scImgAction==index}"> | |||
| <div class="whiteBox"> | |||
| </div> | |||
| </div> | |||
| <!-- 删除上传素材 --> | |||
| <div class="icon-error" v-if="scAction==1&&scImgAction==index"> | |||
| × | |||
| </div> | |||
| <!-- 中间图片 --> | |||
| <img src="" alt=""> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -201,19 +226,19 @@ export default { | |||
| imgUrl:null, | |||
| modelId: null, | |||
| inputTitle: '', | |||
| textareaContent: '', | |||
| textareaContent: '', // 输入框内容 | |||
| bottomTabActive: 2,// 底部导航高亮 | |||
| showSubtitle: true, // 是否展示字幕 | |||
| showSynthesisDialog: false,//合成进度条窗口显示隐藏 | |||
| showTabSheet: true, //tab栏弹出框显隐 | |||
| showTabSheet: false, //tab栏弹出框显隐 | |||
| soundTabList: ['男生', '女生', '御姐', '萝莉'], // 声音tab栏标题 | |||
| soundListAction:0,//声音tab栏标题高亮 | |||
| soundListAction:-1,//声音tab栏标题高亮 | |||
| soundItemList: ['普通话', '英语', '日语', '日语', '日语', '日语', '日语'],// 声音展示列表 | |||
| soundItemListAction:0,// 声音展示列表高亮 | |||
| bjAction:0,// 背景:素材库和上传高亮 | |||
| bjImgAction:0,// 选中背景图高亮 | |||
| scAction:0,// 素材:素材库和上传高亮 | |||
| scImgAction:0,// 选中素材图高亮 | |||
| soundItemListAction:-1,// 声音展示列表高亮 | |||
| bjAction:-1,// 背景:素材库和上传高亮 | |||
| bjImgAction:-1,// 选中背景图高亮 | |||
| scAction:-1,// 素材:素材库和上传高亮 | |||
| scImgAction:-1,// 选中素材图高亮 | |||
| }; | |||
| }, | |||
| computed: { | |||
| @@ -252,6 +277,7 @@ export default { | |||
| <style lang="scss" scoped> | |||
| .page { | |||
| // transform会导致其子元素固定定位失效 | |||
| // transform: translateX(30%); | |||
| opacity: 0; | |||
| @@ -265,7 +291,7 @@ export default { | |||
| position: relative; | |||
| padding: 0 10px; | |||
| padding-bottom: 50px; | |||
| width: 375px; | |||
| .shouModelImg { | |||
| display: flex; | |||
| @@ -275,6 +301,7 @@ export default { | |||
| img { | |||
| width: 200px; | |||
| height: 351px; | |||
| border-radius: 12px; | |||
| }; | |||
| &-subtitle { | |||
| position: absolute; | |||
| @@ -312,6 +339,15 @@ export default { | |||
| height: 100%; | |||
| background: #fff; | |||
| border-radius: 6px; | |||
| ::v-deep .el-input-group__append { | |||
| padding: 0; | |||
| } | |||
| img { | |||
| float: right; | |||
| width: 16px; | |||
| height: 16px; | |||
| margin: 10px; | |||
| } | |||
| } | |||
| .divider { | |||
| transform: scaleY (0.25); | |||
| @@ -331,7 +367,7 @@ export default { | |||
| } | |||
| .makeVideo { | |||
| margin: 15px 20px 0; | |||
| width: 315px; | |||
| height: 50px; | |||
| text-align: center; | |||
| font-size: 20px; | |||
| @@ -373,6 +409,7 @@ export default { | |||
| } | |||
| } | |||
| .bottom { | |||
| ::v-deep .van-tabbar-item { | |||
| color: rgba(51, 51, 51, 1)!important; | |||
| font-size: 10px !important; | |||
| @@ -428,6 +465,11 @@ export default { | |||
| width: 16px; | |||
| height: 16px; | |||
| } | |||
| .yesImg { | |||
| margin-top: -13px; | |||
| width: 24px; | |||
| height: 16px; | |||
| } | |||
| } | |||
| &-content { | |||
| height: 220px; | |||
| @@ -503,7 +545,6 @@ export default { | |||
| &-show { | |||
| width: 100%; | |||
| height: 190px; | |||
| background-color: green; | |||
| &-title { | |||
| font-size: 16px; | |||
| i { | |||
| @@ -518,6 +559,7 @@ export default { | |||
| } | |||
| &-list { | |||
| height: 145px; | |||
| padding-left: 4px; | |||
| overflow-y: scroll; | |||
| display: flex; | |||
| justify-content: start; | |||
| @@ -526,24 +568,58 @@ export default { | |||
| width:0; | |||
| } | |||
| &-item { | |||
| margin-right: 10px; | |||
| position: relative; | |||
| margin-right: 9px; | |||
| margin-top: 10px; | |||
| width: 80px; | |||
| height: 120px; | |||
| background: red; | |||
| z-index: 9; | |||
| img { | |||
| background: red; | |||
| width: 80px; | |||
| height: 120px; | |||
| border-radius: 12px; | |||
| } | |||
| border-radius: 10px; | |||
| .icon-error { | |||
| float: left; | |||
| position: absolute; | |||
| top: 5px; | |||
| right: 5px; | |||
| z-index: 99999; | |||
| display: block!important; | |||
| float: right; | |||
| width: 12px; | |||
| height: 12px; | |||
| line-height: 12px; | |||
| text-align: center; | |||
| color: #fff; | |||
| background: #666666; | |||
| border-radius: 6px; | |||
| } | |||
| &:nth-child(4n) { | |||
| margin-right: 0px; | |||
| margin-right: 0px!important; | |||
| } | |||
| } | |||
| &-active { | |||
| margin-right: 8px; | |||
| margin-top: 8px; | |||
| padding: 2px; | |||
| width: 84px; | |||
| height: 124px; | |||
| border: 1px solid; | |||
| border-image: linear-gradient(90deg, rgba(114, 100, 245, 1), rgba(81, 217, 202, 1)) 1 1; | |||
| position: absolute; | |||
| top: -3px; | |||
| left: -3px; | |||
| padding: 1px; | |||
| z-index: -1; | |||
| width: 86px; | |||
| height: 126px; | |||
| border-radius: 12px; | |||
| background: linear-gradient(225deg, rgba(81, 217, 202, 1), rgba(114, 100, 245, 1)); | |||
| .whiteBox { | |||
| transition: all 0.5s; | |||
| width: 100%; | |||
| height: 100%; | |||
| background-color: #fff; | |||
| border-radius: 12px; | |||
| } | |||
| } | |||
| &-upload { | |||
| position: relative; | |||
| @@ -14,6 +14,7 @@ | |||
| <!-- 具体内容 --> | |||
| <van-swipe-cell v-for="(item, index) in videoList" :key="item.id?item.id:index"> | |||
| <div class="itemList"> | |||
| <!-- 每一项 --> | |||
| <div | |||
| v-if="videoList.length > 0" | |||
| class="videoModel" | |||
| @@ -45,18 +46,24 @@ | |||
| </div> | |||
| </div> | |||
| <!-- 右侧更多 --> | |||
| <div class="showMore" @click.stop="showDialog(item)"> | |||
| <div class="showMore" @click.stop="showDialog(item.id)"> | |||
| <div></div> | |||
| <div></div> | |||
| <div></div> | |||
| </div> | |||
| <!-- 弹出层 --> | |||
| <van-dialog class="dialog" | |||
| <van-popup v-if="clickItem==item.id" v-model="showPopup"> | |||
| <div @click="closeDialog"><i class="el-icon-edit" /> 编辑</div> | |||
| <div @click="goDelete(item.id)"><i class="el-icon-delete" /> 删除</div> | |||
| </van-popup> | |||
| <!-- <van-dialog class="dialog" | |||
| v-model="clickItem==item?showTrue:showFalse"> | |||
| <div @click="closeDialog"><i class="el-icon-edit" /> 编辑</div> | |||
| <div><i class="el-icon-delete" /> 删除</div> | |||
| </van-dialog> | |||
| </van-dialog> --> | |||
| </div> | |||
| <!-- 无数据处理 --> | |||
| <div v-if="videoList.length <= 0" class="noWork"> | |||
| 您还没有视频作品 | |||
| </div> | |||
| @@ -131,6 +138,7 @@ export default { | |||
| }, | |||
| data() { | |||
| return { | |||
| showPopup:false,// 编辑和删除显示隐藏 | |||
| clickItem: -1,// 弹出层显示隐藏 | |||
| showTrue: true,// 弹出层显示隐藏 | |||
| showFalse: false,// 弹出层显示隐藏 | |||
| @@ -155,8 +163,9 @@ export default { | |||
| Dialog.close | |||
| }, | |||
| // 点击显示编辑删除 | |||
| showDialog(item) { | |||
| this.clickItem=item | |||
| showDialog(id) { | |||
| this.showPopup=true | |||
| this.clickItem=id | |||
| }, | |||
| go(url) { | |||
| this.$router.push(url); | |||
| @@ -226,14 +235,14 @@ export default { | |||
| }); | |||
| } | |||
| }, | |||
| // 删除作品 | |||
| goDelete(id) { | |||
| Dialog.confirm({ | |||
| title: "删除作品", | |||
| message: "您确定要删除该作品吗?", | |||
| }) | |||
| .then(() => { | |||
| this.deleteVideoById(id); | |||
| .then(async() => { | |||
| await this.deleteVideoById(id); | |||
| this.loadVideoList(this.currentPage, this.pageSize); | |||
| }) | |||
| .catch(() => { | |||
| @@ -417,7 +426,7 @@ export default { | |||
| .createTime { | |||
| font-size: 11px; | |||
| color: #999999; | |||
| margin-top: 10px; | |||
| margin-top: 5px; | |||
| } | |||
| } | |||
| .showMore { | |||
| @@ -438,8 +447,9 @@ export default { | |||
| } | |||
| } | |||
| // 弹出层样式 | |||
| ::v-deep .van-dialog { | |||
| ::v-deep .van-popup { | |||
| display: flex; | |||
| flex-wrap: wrap; | |||
| align-items: center; | |||
| justify-content: center; | |||
| top: 70%; | |||