feat: 支持发送录音

This commit is contained in:
kuaifan 2022-05-27 14:48:17 +08:00
parent 4dd8658aff
commit 44ff21ffcd
30 changed files with 1766 additions and 304 deletions

View File

@ -263,6 +263,50 @@ class DialogController extends AbstractController
return Base::retSuccess('发送成功', $list);
}
/**
* @api {post} api/dialog/msg/sendrecord 07. 发送语音
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName msg__sendrecord
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {String} base64 语音base64
* @apiParam {Number} duration 语音时长(毫秒)
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function msg__sendrecord()
{
$user = User::auth();
//
$dialog_id = Base::getPostInt('dialog_id');
//
WebSocketDialog::checkDialog($dialog_id);
//
$path = "uploads/chat/" . date("Ym") . "/" . $dialog_id . "/";
$base64 = Base::getPostValue('base64');
$duration = Base::getPostInt('duration');
if ($duration < 600) {
return Base::retError('说话时间太短');
}
$data = Base::record64save([
"base64" => $base64,
"path" => $path,
]);
if (Base::isError($data)) {
return Base::retError($data['msg']);
} else {
$recordData = $data['data'];
$recordData['size'] *= 1024;
$recordData['duration'] = $duration;
return WebSocketDialogMsg::sendMsg($dialog_id, 'record', $recordData, $user->userid);
}
}
/**
* @api {post} api/dialog/msg/sendfile 07. 文件上传
*

View File

@ -36,6 +36,9 @@ class VerifyCsrfToken extends Middleware
// 聊天发文本
'api/dialog/msg/sendtext/',
// 聊天发语音
'api/dialog/msg/sendrecord/',
// 聊天发文件
'api/dialog/msg/sendfile/',

View File

@ -92,6 +92,8 @@ class WebSocketDialogMsg extends AbstractModel
$value['type'] = in_array($value['ext'], ['jpg', 'jpeg', 'png', 'gif']) ? 'img' : 'file';
$value['path'] = Base::fillUrl($value['path']);
$value['thumb'] = Base::fillUrl($value['thumb'] ?: Base::extIcon($value['ext']));
} else if ($this->type === 'record') {
$value['path'] = Base::fillUrl($value['path']);
}
return $value;
}
@ -210,6 +212,8 @@ class WebSocketDialogMsg extends AbstractModel
switch ($this->type) {
case 'text':
return $this->previewTextMsg($this->msg['text'], $preserveHtml);
case 'record':
return "[语音]";
case 'file':
if ($this->msg['type'] == 'img') {
return "[图片]";

View File

@ -2174,6 +2174,39 @@ class Base
return Min(Max(Base::nullShow(Request::input($inputName), $default), 1), $max);
}
/**
* base64语音保存
* @param array $param [ base64=带前缀的base64, path=>文件路径 ]
* @return array [name=>文件名, size=>文件大小(单位KB),file=>绝对地址, path=>相对地址, url=>全路径地址, ext=>文件后缀名]
*/
public static function record64save($param)
{
$base64 = $param['base64'];
if (preg_match('/^(data:\s*audio\/(\w+);base64,)/', $base64, $res)) {
$extension = $res[2];
if (!in_array($extension, ['mp3', 'wav'])) {
return Base::retError('语音格式错误');
}
$fileName = 'record_' . md5($base64) . '.' . $extension;
$fileDir = $param['path'];
$filePath = public_path($fileDir);
Base::makeDir($filePath);
if (file_put_contents($filePath . $fileName, base64_decode(str_replace($res[1], '', $base64)))) {
$fileSize = filesize($filePath . $fileName);
$array = [
"name" => $fileName, //原文件名
"size" => Base::twoFloat($fileSize / 1024, true), //大小KB
"file" => $filePath . $fileName, //文件的完整路径 "D:\www....KzZ.jpg"
"path" => $fileDir . $fileName, //相对路径 "uploads/pic....KzZ.jpg"
"url" => Base::fillUrl($fileDir . $fileName), //完整的URL "https://.....hhsKzZ.jpg"
"ext" => $extension, //文件后缀名
];
return Base::retSuccess('success', $array);
}
}
return Base::retError('语音保存失败');
}
/**
* image64图片保存
* @param array $param [ image64=带前缀的base64, path=>文件路径, fileName=>文件名称, scale=>[压缩原图宽,, 压缩方式] ]

Binary file not shown.

Binary file not shown.

Binary file not shown.

6
public/js/recorder.mp3.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,338 @@
/*
录音 Recorder扩展频率直方图显示
使用本扩展需要引入lib.fft.js支持直方图特意优化主要显示0-5khz语音部分其他高频显示区域较小不适合用来展示音乐频谱
https://github.com/xiangyuecn/Recorder
本扩展核心算法主要参考了Java开源库jmp123 版本0.3 的代码
https://www.iteye.com/topic/851459
https://sourceforge.net/projects/jmp123/files/
*/
(function(){
"use strict";
var FrequencyHistogramView=function(set){
return new fn(set);
};
var fn=function(set){
var This=this;
var o={
/*
elem:"css selector" //自动显示到dom并以此dom大小为显示大小
//或者配置显示大小手动把frequencyObj.elem显示到别的地方
,width:0 //显示宽度
,height:0 //显示高度
以上配置二选一
*/
scale:2 //缩放系数应为正整数使用2(3? no!)倍宽高进行绘制,避免移动端绘制模糊
,fps:20 //绘制帧率,不可过高
,lineCount:30 //直方图柱子数量数量的多少对性能影响不大密集运算集中在FFT算法中
,widthRatio:0.6 //柱子线条宽度占比为所有柱子占用整个视图宽度的比例剩下的空白区域均匀插入柱子中间默认值也基本相当于一根柱子占0.6一根空白占0.4设为1不留空白当视图不足容下所有柱子时也不留空白
,spaceWidth:0 //柱子间空白固定基础宽度柱子宽度自适应当不为0时widthRatio无效当视图不足容下所有柱子时将不会留空白允许为负数让柱子发生重叠
,minHeight:0 //柱子保留基础高度position不为±1时应该保留点高度
,position:-1 //绘制位置,取值-1到1-1为最底下0为中间1为最顶上小数为百分比
,mirrorEnable:false //是否启用镜像,如果启用,视图宽度会分成左右两块,右边这块进行绘制,左边这块进行镜像(以中间这根柱子的中心进行镜像)
,stripeEnable:true //是否启用柱子顶上的峰值小横条position不是-1时应当关闭否则会很丑
,stripeHeight:3 //峰值小横条基础高度
,stripeMargin:6 //峰值小横条和柱子保持的基础距离
,fallDuration:1000 //柱子从最顶上下降到最底部最长时间ms
,stripeFallDuration:3500 //峰值小横条从最顶上下降到底部最长时间ms
//柱子颜色配置:[位置css颜色...] 位置: 取值0.0-1.0之间
,linear:[0,"rgba(0,187,17,1)",0.5,"rgba(255,215,0,1)",1,"rgba(255,102,0,1)"]
//峰值小横条渐变颜色配置取值格式和linear一致留空为柱子的渐变颜色
,stripeLinear:null
,shadowBlur:0 //柱子阴影基础大小设为0不显示阴影如果柱子数量太多时请勿开启非常影响性能
,shadowColor:"#bbb" //柱子阴影颜色
,stripeShadowBlur:-1 //峰值小横条阴影基础大小设为0不显示阴影-1为柱子的大小如果柱子数量太多时请勿开启非常影响性能
,stripeShadowColor:"" //峰值小横条阴影颜色,留空为柱子的阴影颜色
//当发生绘制时会回调此方法参数为当前绘制的频率数据和采样率可实现多个直方图同时绘制只消耗一个input输入和计算时间
,onDraw:function(frequencyData,sampleRate){}
};
for(var k in set){
o[k]=set[k];
};
This.set=set=o;
var elem=set.elem;
if(elem){
if(typeof(elem)=="string"){
elem=document.querySelector(elem);
}else if(elem.length){
elem=elem[0];
};
};
if(elem){
set.width=elem.offsetWidth;
set.height=elem.offsetHeight;
};
var scale=set.scale;
var width=set.width*scale;
var height=set.height*scale;
var thisElem=This.elem=document.createElement("div");
var lowerCss=["","transform-origin:0 0;","transform:scale("+(1/scale)+");"];
thisElem.innerHTML='<div style="width:'+set.width+'px;height:'+set.height+'px;overflow:hidden"><div style="width:'+width+'px;height:'+height+'px;'+lowerCss.join("-webkit-")+lowerCss.join("-ms-")+lowerCss.join("-moz-")+lowerCss.join("")+'"><canvas/></div></div>';
var canvas=This.canvas=thisElem.querySelector("canvas");
var ctx=This.ctx=canvas.getContext("2d");
canvas.width=width;
canvas.height=height;
if(elem){
elem.innerHTML="";
elem.appendChild(thisElem);
};
if(!Recorder.LibFFT){
throw new Error("需要lib.fft.js支持");
};
This.fft=Recorder.LibFFT(1024);
//柱子所在高度
This.lastH=[];
//峰值小横条所在高度
This.stripesH=[];
};
fn.prototype=FrequencyHistogramView.prototype={
genLinear:function(ctx,colors,from,to){
var rtv=ctx.createLinearGradient(0,from,0,to);
for(var i=0;i<colors.length;){
rtv.addColorStop(colors[i++],colors[i++]);
};
return rtv;
}
,input:function(pcmData,powerLevel,sampleRate){
var This=this;
This.sampleRate=sampleRate;
This.pcmData=pcmData;
This.pcmPos=0;
This.inputTime=Date.now();
This.schedule();
}
,schedule:function(){
var This=this,set=This.set;
var interval=Math.floor(1000/set.fps);
if(!This.timer){
This.timer=setInterval(function(){
This.schedule();
},interval);
};
var now=Date.now();
var drawTime=This.drawTime||0;
if(now-This.inputTime>set.stripeFallDuration*1.3){
//超时没有输入,顶部横条已全部落下,干掉定时器
clearInterval(This.timer);
This.timer=0;
return;
};
if(now-drawTime<interval){
//没到间隔时间,不绘制
return;
};
This.drawTime=now;
//调用FFT计算频率数据
var bufferSize=This.fft.bufferSize;
var pcm=This.pcmData;
var pos=This.pcmPos;
var arr=new Int16Array(bufferSize);
for(var i=0;i<bufferSize&&pos<pcm.length;i++,pos++){
arr[i]=pcm[pos];
};
This.pcmPos=pos;
var frequencyData=This.fft.transform(arr);
//推入绘制
This.draw(frequencyData,This.sampleRate);
}
,draw:function(frequencyData,sampleRate){
var This=this,set=This.set;
var ctx=This.ctx;
var scale=set.scale;
var width=set.width*scale;
var height=set.height*scale;
var lineCount=set.lineCount;
var bufferSize=This.fft.bufferSize;
//计算高度位置
var position=set.position;
var posAbs=Math.abs(set.position);
var originY=position==1?0:height;//y轴原点
var heightY=height;//最高的一边高度
if(posAbs<1){
heightY=heightY/2;
originY=heightY;
heightY=Math.floor(heightY*(1+posAbs));
originY=Math.floor(position>0?originY*(1-posAbs):originY*(1+posAbs));
};
var lastH=This.lastH;
var stripesH=This.stripesH;
var speed=Math.ceil(heightY/(set.fallDuration/(1000/set.fps)));
var stripeSpeed=Math.ceil(heightY/(set.stripeFallDuration/(1000/set.fps)));
var stripeMargin=set.stripeMargin*scale;
var Y0=1 << (Math.round(Math.log(bufferSize)/Math.log(2) + 3) << 1);
var logY0 = Math.log(Y0)/Math.log(10);
var dBmax=20*Math.log(0x7fff)/Math.log(10);
var fftSize=bufferSize/2;
var fftSize5k=Math.min(fftSize,Math.floor(fftSize*5000/(sampleRate/2)));//5khz所在位置8000采样率及以下最高只有4khz
var fftSize5kIsAll=fftSize5k==fftSize;
var line80=fftSize5kIsAll?lineCount:Math.round(lineCount*0.8);//80%的柱子位置
var fftSizeStep1=fftSize5k/line80;
var fftSizeStep2=fftSize5kIsAll?0:(fftSize-fftSize5k)/(lineCount-line80);
var fftIdx=0;
for(var i=0;i<lineCount;i++){
//不采用jmp123的非线性划分频段录音语音并不适用于音乐的频率应当弱化高频部分
//80%关注0-5khz主要人声部分 20%关注剩下的高频,这样不管什么采样率都能做到大部分频率显示一致。
var start=Math.ceil(fftIdx);
if(i<line80){
//5khz以下
fftIdx+=fftSizeStep1;
}else{
//5khz以上
fftIdx+=fftSizeStep2;
};
var end=Math.min(Math.ceil(fftIdx),fftSize);
//参考AudioGUI.java .drawHistogram方法
//查找当前频段的最大"幅值"
var maxAmp=0;
for (var j=start; j<end; j++) {
maxAmp=Math.max(maxAmp,Math.abs(frequencyData[j]));
};
//计算音量
var dB= (maxAmp > Y0) ? Math.floor((Math.log(maxAmp)/Math.log(10) - logY0) * 17) : 0;
var h=heightY*Math.min(dB/dBmax,1);
//使柱子匀速下降
lastH[i]=(lastH[i]||0)-speed;
if(h<lastH[i]){h=lastH[i];};
if(h<0){h=0;};
lastH[i]=h;
var shi=stripesH[i]||0;
if(h&&h+stripeMargin>shi) {
stripesH[i]=h+stripeMargin;
}else{
//使峰值小横条匀速度下落
var sh =shi-stripeSpeed;
if(sh < 0){sh = 0;};
stripesH[i] = sh;
};
};
//开始绘制图形
ctx.clearRect(0,0,width,height);
var linear1=This.genLinear(ctx,set.linear,originY,originY-heightY);//上半部分的填充
var stripeLinear1=set.stripeLinear&&This.genLinear(ctx,set.stripeLinear,originY,originY-heightY)||linear1;//上半部分的峰值小横条填充
var linear2=This.genLinear(ctx,set.linear,originY,originY+heightY);//下半部分的填充
var stripeLinear2=set.stripeLinear&&This.genLinear(ctx,set.stripeLinear,originY,originY+heightY)||linear2;//上半部分的峰值小横条填充
//计算柱子间距
ctx.shadowBlur=set.shadowBlur*scale;
ctx.shadowColor=set.shadowColor;
var mirrorEnable=set.mirrorEnable;
var mirrorCount=mirrorEnable?lineCount*2-1:lineCount;//镜像柱子数量翻一倍-1根
var widthRatio=set.widthRatio;
var spaceWidth=set.spaceWidth*scale;
if(spaceWidth!=0){
widthRatio=(width-spaceWidth*(mirrorCount+1))/width;
};
var lineWidth=Math.max(1*scale,Math.floor((width*widthRatio)/mirrorCount));//柱子宽度至少1个单位
var spaceFloat=(width-mirrorCount*lineWidth)/(mirrorCount+1);//均匀间隔,首尾都留空,可能为负数,柱子将发生重叠
//绘制柱子
var minHeight=set.minHeight*scale;
var mirrorSubX=spaceFloat+lineWidth/2;
var XFloat=mirrorEnable?width/2-mirrorSubX:0;//镜像时,中间柱子位于正中心
for(var i=0,xFloat=XFloat,x,y,h;i<lineCount;i++){
xFloat+=spaceFloat;
x=Math.floor(xFloat);
h=Math.max(lastH[i],minHeight);
//绘制上半部分
if(originY!=0){
y=originY-h;
ctx.fillStyle=linear1;
ctx.fillRect(x, y, lineWidth, h);
};
//绘制下半部分
if(originY!=height){
ctx.fillStyle=linear2;
ctx.fillRect(x, originY, lineWidth, h);
};
xFloat+=lineWidth;
};
//绘制柱子顶上峰值小横条
if(set.stripeEnable){
var stripeShadowBlur=set.stripeShadowBlur;
ctx.shadowBlur=(stripeShadowBlur==-1?set.shadowBlur:stripeShadowBlur)*scale;
ctx.shadowColor=set.stripeShadowColor||set.shadowColor;
var stripeHeight=set.stripeHeight*scale;
for(var i=0,xFloat=XFloat,x,y,h;i<lineCount;i++){
xFloat+=spaceFloat;
x=Math.floor(xFloat);
h=stripesH[i];
//绘制上半部分
if(originY!=0){
y=originY-h-stripeHeight;
if(y<0){y=0;};
ctx.fillStyle=stripeLinear1;
ctx.fillRect(x, y, lineWidth, stripeHeight);
};
//绘制下半部分
if(originY!=height){
y=originY+h;
if(y+stripeHeight>height){
y=height-stripeHeight;
};
ctx.fillStyle=stripeLinear2;
ctx.fillRect(x, y, lineWidth, stripeHeight);
};
xFloat+=lineWidth;
};
};
//镜像,从中间直接镜像即可
if(mirrorEnable){
var srcW=Math.floor(width/2);
ctx.save();
ctx.scale(-1,1);
ctx.drawImage(This.canvas,Math.ceil(width/2),0,srcW,height,-srcW,0,srcW,height);
ctx.restore();
};
set.onDraw(frequencyData,sampleRate);
}
};
Recorder.FrequencyHistogramView=FrequencyHistogramView;
})();

111
public/js/recorder/lib.fft.js vendored Normal file
View File

@ -0,0 +1,111 @@
/*
时域转频域快速傅里叶变换(FFT)
https://github.com/xiangyuecn/Recorder
var fft=Recorder.LibFFT(bufferSize)
bufferSize取值2的n次方
fft.bufferSize 实际采用的bufferSize
fft.transform(inBuffer)
inBuffer:[Int16,...] 数组长度必须是bufferSize
返回[Float64(Long),...]长度为bufferSize/2
*/
/*
从FFT.java 移植Java开源库jmp123 版本0.3
https://www.iteye.com/topic/851459
https://sourceforge.net/projects/jmp123/files/
*/
Recorder.LibFFT=function(bufferSize){
"use strict";
var FFT_N_LOG,FFT_N,MINY;
var real, imag, sintable, costable;
var bitReverse;
var FFT_Fn=function(bufferSize) {//bufferSize只能取值2的n次方
FFT_N_LOG=Math.round(Math.log(bufferSize)/Math.log(2));
FFT_N = 1 << FFT_N_LOG;
MINY = ((FFT_N << 2) * Math.sqrt(2));
real = [];
imag = [];
sintable = [0];
costable = [0];
bitReverse = [];
var i, j, k, reve;
for (i = 0; i < FFT_N; i++) {
k = i;
for (j = 0, reve = 0; j != FFT_N_LOG; j++) {
reve <<= 1;
reve |= (k & 1);
k >>>= 1;
}
bitReverse[i] = reve;
}
var theta, dt = 2 * Math.PI / FFT_N;
for (i = (FFT_N >> 1) - 1; i > 0; i--) {
theta = i * dt;
costable[i] = Math.cos(theta);
sintable[i] = Math.sin(theta);
}
}
/*
用于频谱显示的快速傅里叶变换
inBuffer 输入FFT_N个实数返回 FFT_N/2个输出值(复数模的平方)
*/
var getModulus=function(inBuffer) {
var i, j, k, ir, j0 = 1, idx = FFT_N_LOG - 1;
var cosv, sinv, tmpr, tmpi;
for (i = 0; i != FFT_N; i++) {
real[i] = inBuffer[bitReverse[i]];
imag[i] = 0;
}
for (i = FFT_N_LOG; i != 0; i--) {
for (j = 0; j != j0; j++) {
cosv = costable[j << idx];
sinv = sintable[j << idx];
for (k = j; k < FFT_N; k += j0 << 1) {
ir = k + j0;
tmpr = cosv * real[ir] - sinv * imag[ir];
tmpi = cosv * imag[ir] + sinv * real[ir];
real[ir] = real[k] - tmpr;
imag[ir] = imag[k] - tmpi;
real[k] += tmpr;
imag[k] += tmpi;
}
}
j0 <<= 1;
idx--;
}
j = FFT_N >> 1;
var outBuffer=new Float64Array(j);
/*
* 输出模的平方:
* for(i = 1; i <= j; i++)
* inBuffer[i-1] = real[i] * real[i] + imag[i] * imag[i];
*
* 如果FFT只用于频谱显示,可以"淘汰"幅值较小的而减少浮点乘法运算. MINY的值
* 和Spectrum.Y0,Spectrum.logY0对应.
*/
sinv = MINY;
cosv = -MINY;
for (i = j; i != 0; i--) {
tmpr = real[i];
tmpi = imag[i];
if (tmpr > cosv && tmpr < sinv && tmpi > cosv && tmpi < sinv)
outBuffer[i - 1] = 0;
else
outBuffer[i - 1] = Math.round(tmpr * tmpr + tmpi * tmpi);
}
return outBuffer;
}
FFT_Fn(bufferSize);
return {transform:getModulus,bufferSize:FFT_N};
};

File diff suppressed because one or more lines are too long

View File

@ -9,6 +9,7 @@
<RightBottom/>
<NetworkException/>
<PreviewImageState/>
<AudioManager/>
<iframe v-if="manifestUrl" v-show="false" :src="manifestUrl"></iframe>
</div>
</template>
@ -19,9 +20,10 @@ import RightBottom from "./components/RightBottom";
import PreviewImageState from "./components/PreviewImage/state";
import {mapState} from "vuex";
import NetworkException from "./components/NetworkException";
import AudioManager from "./components/AudioManager";
export default {
components: {NetworkException, PreviewImageState, RightBottom, Spinner},
components: {AudioManager, NetworkException, PreviewImageState, RightBottom, Spinner},
data() {
return {

View File

@ -0,0 +1,93 @@
<template>
<audio
ref="audio"
class="common-audio"
@ended="overAudio"
@pause="onPause"
@play="onPlay"></audio>
</template>
<style lang="scss" scoped>
.common-audio {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
opacity: 0;
z-index: 0;
}
</style>
<script>
import {Store} from "le5le-store";
export default {
name: 'AudioManager',
data() {
return {
audioSubscribe: null,
audioPlay: false,
audioSrc: null,
callback: null,
}
},
mounted() {
this.audioSubscribe = Store.subscribe('audioSubscribe', this.setAudioPlay);
},
beforeDestroy() {
if (this.audioSubscribe) {
this.audioSubscribe.unsubscribe();
this.audioSubscribe = null;
}
},
watch: {
audioPlay(play) {
if (typeof this.callback === "function") {
this.callback(play)
}
}
},
methods: {
setAudioPlay(info) {
const audio = this.$refs.audio;
const ended = audio.ended || audio.paused;
audio.controls = false;
audio.loop = false;
if (info === false) {
if (!ended) {
audio.pause()
}
return
}
const {src, callback} = info
this.callback = callback || null;
if (src === this.audioSrc) {
if (ended) {
audio.play()
} else {
audio.pause();
}
} else {
this.audioSrc = src;
if (!ended) {
audio.pause()
}
audio.src = src
audio.play()
}
},
overAudio() {
this.audioPlay = false;
},
onPause() {
this.audioPlay = false;
},
onPlay() {
this.audioPlay = true;
}
}
}
</script>

View File

@ -1,46 +0,0 @@
const isSupportTouch = "ontouchend" in document;
export default {
bind (el, binding) {
if (!isSupportTouch) {
return
}
let timer = 0;
el.__touchLongpressDown__ = e => {
timer = setTimeout(_ => {
timer = 0
if (binding.expression) {
binding.value(e, el)
}
}, 600)
};
el.__touchLongpressMove__ = _ => {
if (timer) {
clearTimeout(timer)
timer = 0
}
};
el.__touchLongpressUp__ = _ => {
if (timer) {
clearTimeout(timer)
timer = 0
}
};
el.addEventListener('touchstart', el.__touchLongpressDown__);
el.addEventListener('touchmove', el.__touchLongpressMove__);
el.addEventListener('touchend', el.__touchLongpressUp__);
},
update () {
},
unbind (el) {
if (!isSupportTouch) {
return
}
el.removeEventListener('touchstart', el.__touchLongpressDown__);
el.removeEventListener('touchmove', el.__touchLongpressMove__);
el.removeEventListener('touchend', el.__touchLongpressUp__);
delete el.__touchLongpressDown__;
delete el.__touchLongpressMove__;
delete el.__touchLongpressUp__;
}
};

View File

@ -1,33 +1,34 @@
const isSupportTouch = "ontouchend" in document;
export default {
bind (el, binding) {
let isMove = false;
let isTouch = false;
el.__touchMouseDown__ = e => {
isMove = false;
e.preventDefault();
isTouch = true;
binding.value("down", e);
};
el.__touchMouseMove__ = _ => {
isMove = true;
};
el.__touchMouseUp__ = e => {
if (isMove) {
return;
el.__touchMouseMove__ = e => {
if (isTouch) {
binding.value("move", e);
}
if (binding.expression) {
binding.value(e);
};
el.__touchMouseUp__ = _ => {
if (isTouch) {
isTouch = false;
binding.value("up");
}
};
el.addEventListener(isSupportTouch ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
el.addEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
el.addEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
document.addEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
document.addEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
},
update () {
},
unbind (el) {
el.removeEventListener(isSupportTouch ? 'touchstart' : 'mousedown', el.__touchMouseDown__);
el.removeEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
el.removeEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
document.removeEventListener(isSupportTouch ? 'touchmove' : 'mousemove', el.__touchMouseMove__);
document.removeEventListener(isSupportTouch ? 'touchend' : 'mouseup', el.__touchMouseUp__);
delete el.__touchMouseDown__;
delete el.__touchMouseMove__;
delete el.__touchMouseUp__;

View File

@ -1,63 +1,104 @@
<template>
<div class="chat-input-box">
<div class="chat-input-wrapper" :class="modeClass" @click.stop="focus">
<div ref="editor" class="no-dark-content" :style="editorStyle" @click.stop="" @paste="handlePaste"></div>
<div class="chat-input-toolbar" @click.stop="">
<slot name="toolbarBefore"/>
<div class="chat-input-box" :class="boxClass">
<div class="chat-input-wrapper" @click.stop="focus">
<!-- 输入框 -->
<div
ref="editor"
class="no-dark-content"
:style="editorStyle"
@click.stop=""
@paste="handlePaste"></div>
<EPopover
v-if="!emojiBottom"
v-model="showEmoji"
:visibleArrow="false"
placement="top"
popperClass="chat-input-emoji-popover">
<ETooltip slot="reference" ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
<i class="taskfont">&#xe7ad;</i>
<!-- 工具栏 -->
<ul class="chat-toolbar" @click.stop="">
<!-- 桌面端表情漂浮 -->
<li>
<EPopover
v-if="!emojiBottom"
v-model="showEmoji"
:visibleArrow="false"
placement="top"
popperClass="chat-input-emoji-popover">
<ETooltip slot="reference" ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
<i class="taskfont">&#xe7ad;</i>
</ETooltip>
<ChatEmoji @on-select="onSelectEmoji"/>
</EPopover>
<ETooltip v-else ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
<i class="taskfont" @click="showEmoji=!showEmoji">&#xe7ad;</i>
</ETooltip>
<ChatEmoji @on-select="onSelectEmoji"/>
</EPopover>
<ETooltip v-else ref="emojiTip" :disabled="!$isDesktop || showEmoji" placement="top" :content="$L('表情')">
<i class="taskfont" @click="showEmoji=!showEmoji">&#xe7ad;</i>
</ETooltip>
</li>
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择会员')">
<i class="taskfont" @click="onToolbar('user')">&#xe78f;</i>
</ETooltip>
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择任务')">
<i class="taskfont" @click="onToolbar('task')">&#xe7d6;</i>
</ETooltip>
<EPopover
v-model="showMore"
:visibleArrow="false"
placement="top"
popperClass="chat-input-more-popover">
<ETooltip slot="reference" ref="moreTip" :disabled="!$isDesktop || showMore" placement="top" :content="$L('展开')">
<i class="taskfont">&#xe790;</i>
<!-- @ # -->
<li>
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择会员')">
<i class="taskfont" @click="onToolbar('user')">&#xe78f;</i>
</ETooltip>
<div class="chat-input-popover-item" @click="onToolbar('image')">
<i class="taskfont">&#xe64a;</i>
{{$L('图片')}}
</div>
<div class="chat-input-popover-item" @click="onToolbar('file')">
<i class="taskfont">&#xe786;</i>
{{$L('文件')}}
</div>
</EPopover>
<div class="chat-send" :class="[value ? '' : 'disabled']" v-touchmouse="send">
<Loading v-if="loading"/>
<ETooltip v-else placement="top" :disabled="!$isDesktop" :content="$L('发送')">
<Icon type="md-send"/>
</li>
<li>
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('选择任务')">
<i class="taskfont" @click="onToolbar('task')">&#xe7d6;</i>
</ETooltip>
</div>
</li>
<slot name="toolbarAfter"/>
</div>
<!-- 图片文件 -->
<li>
<EPopover
v-model="showMore"
:visibleArrow="false"
placement="top"
popperClass="chat-input-more-popover">
<ETooltip slot="reference" ref="moreTip" :disabled="!$isDesktop || showMore" placement="top" :content="$L('展开')">
<i class="taskfont">&#xe790;</i>
</ETooltip>
<div class="chat-input-popover-item" @click="onToolbar('image')">
<i class="taskfont">&#xe64a;</i>
{{$L('图片')}}
</div>
<div class="chat-input-popover-item" @click="onToolbar('file')">
<i class="taskfont">&#xe786;</i>
{{$L('文件')}}
</div>
</EPopover>
</li>
<!-- 发送按钮 -->
<li class="chat-send" :class="sendClass" v-touchmouse="clickSend">
<ETooltip placement="top" :disabled="!$isDesktop" :content="$L('发送')">
<div>
<transition name="mobile-send">
<i v-if="sendClass === 'recorder'" class="taskfont">&#xe609;</i>
</transition>
<transition name="mobile-send">
<i v-if="sendClass !== 'recorder'" class="taskfont">&#xe606;</i>
</transition>
</div>
</ETooltip>
</li>
<!-- 录音效果 -->
<li v-if="recordReady" class="chat-record-recwave">
<div ref="recwave"></div>
</li>
</ul>
</div>
<template v-if="emojiBottom">
<ChatEmoji v-if="showEmoji" @on-select="onSelectEmoji"/>
</template>
<!-- 移动端表情底部 -->
<ChatEmoji v-if="emojiBottom && showEmoji" @on-select="onSelectEmoji"/>
<!-- 录音取消 -->
<transition name="fade">
<div
v-if="recordState === 'ing'"
v-transfer-dom
:data-transfer="true"
class="chat-input-record-transfer"
:class="{cancel: touchLimitY}"
@click="stopRecord">
<div class="record-duration">{{recordFormatDuration}}</div>
<div class="record-cancel" @click.stop="stopRecord(true)">{{$L(touchLimitY ? '松开取消' : '向上滑动取消')}}</div>
</div>
</transition>
</div>
</template>
@ -67,11 +108,12 @@ import Quill from 'quill';
import "quill-mention";
import ChatEmoji from "./emoji";
import touchmouse from "../../../../directives/touchmouse";
import TransferDom from "../../../../directives/transfer-dom";
export default {
name: 'ChatInput',
components: {ChatEmoji},
directives: {touchmouse},
directives: {touchmouse, TransferDom},
props: {
dialogId: {
type: Number,
@ -126,7 +168,7 @@ export default {
_content: '',
_options: {},
modeClass: '',
mentionMode: '',
userList: null,
taskList: null,
@ -138,6 +180,17 @@ export default {
wrapperWidth: 0,
editorHeight: 0,
recordReady: false,
recordRec: null,
recordState: "stop",
recordBlob: null,
recordWave: null,
recordDuration: 0,
touchStart: {},
touchLimitX: false,
touchLimitY: false,
isSpecVersion: this.checkIOSVersion(),
};
},
@ -179,6 +232,37 @@ export default {
} else {
return {};
}
},
boxClass() {
const array = [];
if (this.recordState === 'ing') {
array.push('record-ing');
}
if (this.mentionMode) {
array.push(this.mentionMode);
}
return array
},
sendClass() {
if (this.value) {
return 'sender';
}
if (this.recordReady) {
return 'recorder'
}
return ''
},
recordFormatDuration() {
const {recordDuration} = this;
let minute = Math.floor(recordDuration / 60000),
seconds = Math.floor(recordDuration / 1000) % 60,
millisecond = ("00" + recordDuration % 1000).substr(-2)
if (minute < 10) minute = `0${minute}`
if (seconds < 10) seconds = `0${seconds}`
return `${minute}:${seconds}${millisecond}`
}
},
watch: {
@ -257,7 +341,7 @@ export default {
shortKey: true,
handler: _ => {
if (!this.enterSend) {
this.send();
this.onSend();
return false;
}
return true;
@ -268,7 +352,7 @@ export default {
shiftKey: false,
handler: _ => {
if (this.enterSend) {
this.send();
this.onSend();
return false;
}
return true;
@ -390,6 +474,27 @@ export default {
return delta
})
// Load recorder
$A.loadScriptS([
'js/recorder/recorder.mp3.min.js',
'js/recorder/lib.fft.js',
'js/recorder/frequency.histogram.view.js',
], (e) => {
if (e !== null || typeof window.Recorder !== 'function') {
return;
}
this.recordRec = window.Recorder({
type: "mp3",
bitRate: 16,
sampleRate: 16000,
onProcess: (buffers, powerLevel, duration, sampleRate, newBufferIdx, asyncEnd) => {
this.recordWave.input(buffers[buffers.length - 1], powerLevel, sampleRate);
this.recordDuration = duration;
}
})
this.recordReady = true;
});
// Ready event
this.$emit('on-ready', this.quill)
},
@ -439,18 +544,122 @@ export default {
})
},
send() {
clickSend(action, event) {
if (this.loading) {
return;
}
switch (action) {
case 'down':
this.touchLimitX = false;
this.touchLimitY = false;
this.touchStart = event.type === "touchstart" ? event.touches[0] : event;
if (this.startRecord()) {
return;
}
break;
case 'move':
const touchMove = event.type === "touchmove" ? event.touches[0] : event;
this.touchLimitX = (this.touchStart.clientX - touchMove.clientX) / window.innerWidth > 0.1
this.touchLimitY = (this.touchStart.clientY - touchMove.clientY) / window.innerHeight > 0.1
break;
case 'up':
if (this.stopRecord(this.touchLimitY)) {
return;
}
if (this.touchLimitY || this.touchLimitX) {
return; // XY
}
this.onSend()
break;
}
},
onSend() {
this.rangeIndex = 0
this.$emit('on-send')
},
startRecord() {
if (this.sendClass === 'recorder') {
this.recordState = "ready";
this.recordRec.open(_ => {
if (this.recordState === "ready") {
this.recordState = "ing"
this.recordBlob = null
this.$nextTick(_ => {
this.$refs.recwave.innerHTML = "";
this.recordWave = window.Recorder.FrequencyHistogramView({
elem: this.$refs.recwave,
lineCount: 90,
position: 0,
minHeight: 1,
stripeEnable: false
})
this.recordRec.start()
})
} else {
this.recordRec.close();
}
}, (msg) => {
$A.modalError(msg || '打开录音失败')
});
return true;
} else {
return false;
}
},
stopRecord(isCancel) {
if (this.recordState === "ing") {
this.recordState = "stop";
this.recordRec.stop((blob, duration) => {
this.recordRec.close();
if (isCancel === true) {
return;
}
if (duration < 600) {
// 600ms
$A.messageWarning("说话时间太短")
} else {
this.recordBlob = blob;
this.uploadRecord(duration);
}
}, (msg) => {
this.recordRec.close();
$A.modalError("录音失败: " + msg);
});
return true;
} else {
this.recordState = "stop";
return false;
}
},
hidePopover() {
this.showEmoji = false;
this.showMore = false;
},
uploadRecord(duration) {
if (this.recordBlob === null) {
return;
}
const reader = new FileReader();
reader.onloadend = () => {
this.$emit('on-send', {
type: 'record',
data: {
type: this.recordBlob.type,
base64: reader.result,
duration,
}
})
};
reader.readAsDataURL(this.recordBlob);
},
onSelectEmoji(item) {
if (!this.quill) {
return;
@ -533,7 +742,7 @@ export default {
return new Promise(resolve => {
switch (mentionChar) {
case "@": // @
this.modeClass = "user-mention";
this.mentionMode = "user-mention";
if (this.userList !== null) {
resolve(this.userList)
return;
@ -603,7 +812,7 @@ export default {
break;
case "#": // #
this.modeClass = "task-mention";
this.mentionMode = "task-mention";
if (this.taskList !== null) {
resolve(this.taskList)
return;

View File

@ -25,6 +25,13 @@
</div>
</div>
</div>
<!--录音-->
<div v-else-if="msgData.type === 'record'" class="content-record no-dark-content">
<div class="dialog-record" :class="{playing: recordPlay}" :style="recordStyle(msgData.msg)" @click="playRecord">
<div class="record-time">{{recordDuration(msgData.msg.duration)}}</div>
<div class="record-icon taskfont"></div>
</div>
</div>
<!--等待-->
<div v-else-if="msgData.type === 'loading'" class="content-loading">
<Loading/>
@ -88,6 +95,7 @@
<script>
import WCircle from "../../../components/WCircle";
import {mapState} from "vuex";
import {Store} from "le5le-store";
export default {
name: "DialogView",
@ -114,6 +122,7 @@ export default {
popperLoad: 0,
popperShow: false,
timeShow: false,
recordPlay: false,
allList: [],
}
},
@ -122,6 +131,12 @@ export default {
this.msgRead()
},
beforeDestroy() {
if (this.recordPlay) {
Store.set('audioSubscribe', false);
}
},
computed: {
...mapState(['userToken', 'userId', 'dialogMsgs']),
@ -246,6 +261,23 @@ export default {
return text;
},
recordStyle(info) {
const {duration} = info;
let width = 50 + Math.min(180, Math.floor(duration / 150));
return {
width: width + 'px',
};
},
recordDuration(duration) {
let minute = Math.floor(duration / 60000),
seconds = Math.floor(duration / 1000) % 60;
if (minute > 0) {
return `${minute}:${seconds}`
}
return `${Math.max(1, seconds)}`
},
imageStyle(info) {
const {width, height} = info;
if (width && height) {
@ -270,6 +302,15 @@ export default {
return {};
},
playRecord() {
Store.set('audioSubscribe', {
src: this.msgData.msg.path,
callback: (play) => {
this.recordPlay = play;
}
});
},
withdraw() {
$A.modalConfirm({
content: `确定撤回此信息吗?`,

View File

@ -96,24 +96,8 @@
</DynamicScrollerItem>
</template>
</DynamicScroller>
<div :class="['dialog-footer', msgNew > 0 && allMsgs.length > 0 ? 'newmsg' : '']" @click="onActive">
<div class="dialog-footer" :class="{newmsg: msgNew > 0 && allMsgs.length > 0}" @click="onActive">
<div class="dialog-newmsg" @click="onToBottom">{{$L('' + msgNew + '条新消息')}}</div>
<div class="dialog-input">
<slot name="inputBefore"/>
<ChatInput
ref="input"
v-model="msgText"
:dialog-id="dialogId"
:emoji-bottom="!$isDesktop"
:maxlength="20000"
@on-focus="onEventFocus"
@on-blur="onEventBlur"
@on-more="onEventMore"
@on-file="sendFileMsg"
@on-send="sendMsg"
:placeholder="$L('输入消息...')"/>
<slot name="inputAfter"/>
</div>
<DialogUpload
ref="chatUpload"
class="chat-upload"
@ -121,6 +105,18 @@
@on-progress="chatFile('progress', $event)"
@on-success="chatFile('success', $event)"
@on-error="chatFile('error', $event)"/>
<ChatInput
ref="input"
v-model="msgText"
:dialog-id="dialogId"
:emoji-bottom="!$isDesktop"
:maxlength="20000"
@on-focus="onEventFocus"
@on-blur="onEventBlur"
@on-more="onEventMore"
@on-file="sendFileMsg"
@on-send="sendMsg"
:placeholder="$L('输入消息...')"/>
</div>
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
@ -386,7 +382,18 @@ export default {
},
methods: {
/**
* 发送消息
* @param text
*/
sendMsg(text) {
if ($A.isJson(text)) {
if (text.type === 'record') {
// text.record
this.sendRecordMsg(text.data);
}
return;
}
let msgText;
if (typeof text === "string" && text) {
msgText = text;
@ -430,6 +437,42 @@ export default {
});
},
/**
* 发送录音
* @param msg {base64, duration}
*/
sendRecordMsg(msg) {
this.onToBottom();
this.onActive();
//
let tempId = $A.randomString(16);
this.tempMsgs.push({
id: tempId,
dialog_id: this.dialogData.id,
type: 'loading',
userid: this.userId,
msg,
});
//
this.$store.dispatch("call", {
url: 'dialog/msg/sendrecord',
data: Object.assign(msg, {
dialog_id: this.dialogId,
}),
method: 'post'
}).then(({data}) => {
this.tempMsgs = this.tempMsgs.filter(({id}) => id != tempId)
this.sendSuccess(data);
}).catch(({msg}) => {
$A.modalError(msg);
this.tempMsgs = this.tempMsgs.filter(({id}) => id != tempId)
});
},
/**
* 发送文件
* @param row
*/
sendFileMsg(row) {
const files = $A.isArray(row) ? row : [row];
if (files.length > 0) {

View File

@ -409,9 +409,8 @@
:placeholder="$L('输入消息...')"
@on-more="onEventMore"
@on-file="onSelectFile"
@on-send="onSend">
<Badge slot="toolbarAfter" :count="taskDetail.msg_num"/>
</ChatInput>
@on-send="onSend"/>
<Badge class="input-badge" :count="taskDetail.msg_num"/>
</div>
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
<div class="drag-text">{{$L('拖动到这里发送')}}</div>

View File

@ -478,6 +478,8 @@ export default {
switch (data.type) {
case 'text':
return $A.getMsgTextPreview(data.msg.text)
case 'record':
return `[${this.$L('语音')}]`
case 'file':
if (data.msg.type == 'img') {
return `[${this.$L('图片')}]`

View File

@ -240,6 +240,21 @@
opacity: 0;
}
// 录音发送缩放
.mobile-send-enter-active {
transition: all 0.3s ease;
}
.mobile-send-leave-active {
transition: all 0.3s ease;
}
.mobile-send-enter,
.mobile-send-leave-to {
transform: translate(-50%, -50%) scale(0) !important;
opacity: 0;
}
@media (max-width: 768px) {
.mobile-tabbar {
display: flex;

View File

@ -2,15 +2,28 @@
@import "~quill-mention/dist/quill.mention.min.css";
.chat-input-box {
display: flex;
flex-direction: column;
display: inline-block;
width: 100%;
.chat-input-wrapper {
display: inline-block;
width: 100%;
&.record-ing {
.chat-input-wrapper {
.chat-toolbar {
> li {
&.chat-send {
&:before {
transform: translate(-50%, -50%) scale(1.5);
}
}
&.chat-record-recwave {
display: flex;
}
}
}
}
}
&.task-mention {
&.task-mention {
.chat-input-wrapper {
.ql-container {
.ql-mention-list-container {
.ql-mention-list {
@ -29,6 +42,13 @@
}
}
}
}
.chat-input-wrapper {
position: relative;
display: inline-block;
vertical-align: middle;
width: 100%;
.ql-container {
display: block;
@ -43,6 +63,7 @@
max-height: 200px;
padding: 0;
margin: 4px 7px;
line-height: 22px;
img {
max-width: 150px;
@ -87,6 +108,7 @@
min-width: 220px;
max-width: 280px;
max-height: 360px;
//noinspection CssInvalidPropertyValue
overflow-y: overlay;
&::-webkit-scrollbar {
@ -218,101 +240,76 @@
}
}
.chat-input-toolbar {
.chat-toolbar {
float: right;
display: flex;
align-items: center;
justify-content: flex-end;
float: right;
width: 170px;
height: 28px;
padding: 0 2px;
> i {
display: inline-block;
cursor: pointer;
padding: 0 5px;
font-size: 20px;
line-height: 28px;
&.disabled {
opacity: 0.5;
}
}
.chat-send {
> li {
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: center;
cursor: pointer;
font-size: 20px;
padding-right: 16px;
margin: 0 -16px 0 7px;
height: 48px;
width: 48px;
position: relative;
.common-loading {
width: 18px;
height: 18px;
padding: 0;
margin: 0 1px;
}
> i {
position: static;
z-index: 2;
display: flex;
align-items: center;
color: #ffffff;
line-height: 28px;
transition: all 0.3s;
}
&:after {
position: absolute;
content: "";
left: 0;
z-index: 1;
width: 1px;
height: 14px;
background-color: #cccccc;
transition: all 0.3s ease;
transform: scale(0);
opacity: 0;
}
&:before {
position: absolute;
content: "";
left: 2px;
z-index: 1;
border-radius: 50%;
background-color: $primary-color;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
transition: all 0.3s ease;
transform: scale(1);
opacity: 1;
}
&.disabled {
> i {
color: rgba($primary-text-color, 0.5);
.taskfont {
display: inline-block;
font-size: 20px;
line-height: 30px;
&.disabled {
opacity: 0.5;
}
&:after {
transform: scale(1);
}
&.chat-send {
width: 48px;
position: relative;
&:before {
content: "";
position: absolute;
top: 50%;
left: 50%;
z-index: 2;
transform: translate(-50%, -50%) scale(1);
border-radius: 50%;
background-color: $primary-color;
width: 36px;
height: 36px;
transition: all 0.3s ease;
opacity: 1;
}
&:before {
transform: scale(0);
opacity: 0;
.el-tooltip {
height: 30px;
}
.taskfont {
position: absolute;
top: 50%;
left: 50%;
z-index: 3;
transform: translate(-50%, -50%) scale(1);
color: #ffffff;
}
}
&.chat-record-recwave {
position: absolute;
top: 0;
left: 0;
z-index: 1;
height: 100%;
width: 100%;
background-color: #ffffff;
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
> div {
flex: 1;
width: 100%;
}
}
}
.el-tooltip.taskfont {
display: inline-block;
cursor: pointer;
padding: 0 5px;
font-size: 20px;
line-height: 28px;
}
}
}
.chat-emoji-wrapper {
.chat-emoji-box {
width: auto;
@ -364,34 +361,6 @@
}
}
.chat-input-emoji-popover {
padding: 0;
overflow: hidden;
}
.chat-input-more-popover {
min-width: 100px;
padding: 8px;
.chat-input-popover-item {
display: flex;
align-items: center;
font-size: 14px;
cursor: pointer;
line-height: 36px;
padding: 0 8px;
border-radius: 4px;
&:hover {
background-color: #ecf5ff;
}
> i {
font-size: 20px;
margin-right: 8px;
}
}
}
.chat-emoji-wrapper {
display: flex;
flex-direction: column;
@ -474,18 +443,77 @@
}
}
.chat-input-emoji-popover {
padding: 0;
overflow: hidden;
}
.chat-input-more-popover {
min-width: 100px;
padding: 8px;
.chat-input-popover-item {
display: flex;
align-items: center;
font-size: 14px;
cursor: pointer;
line-height: 36px;
padding: 0 8px;
border-radius: 4px;
&:hover {
background-color: #ecf5ff;
}
.taskfont {
font-size: 20px;
margin-right: 8px;
}
}
}
.chat-input-record-transfer {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
padding: 14px 26px;
color: $primary-title-color;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 0 12px 0 #c8c8c8;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
.record-duration {
font-size: 20px;
padding-bottom: 6px;
}
.record-cancel {
opacity: 0.6;
}
&.cancel {
background-color: #ff6565;
color: #ffffff;
}
}
@media (max-width: 768px) {
.chat-input-box {
.chat-input-wrapper {
padding-left: 6px;
padding-right: 6px;
background-color: #ffffff;
.ql-container {
.ql-editor {
font-size: 16px;
color: $primary-title-color;
}
}
}
.chat-emoji-wrapper {
margin-top: 8px;
margin-left: -10px;
margin-bottom: -8px;
width: calc(100% + 20px);
background-color: #ffffff;
.chat-emoji-box {
height: 246px;

View File

@ -443,6 +443,55 @@
}
}
.content-record {
display: flex;
.dialog-record {
display: flex;
flex-direction: row-reverse;
justify-content: flex-end;
align-content: center;
color: #ffffff;
line-height: 20px;
cursor: pointer;
.record-time {
padding: 0 0 0 4px;
}
.record-icon {
transform: rotate(180deg) scale(0.9);
&:before {
content: "\E793";
}
}
&.playing {
.record-icon {
&:before {
animation: record-playing 1s infinite;
}
}
}
@keyframes record-playing {
0% {
content: "\E793";
}
33% {
content: "\E791";
}
66% {
content: "\E792";
}
100% {
content: "\E793";
}
}
}
}
.content-loading {
display: flex;
@ -637,6 +686,21 @@
background-color: transparent;
}
}
.content-record {
.dialog-record {
flex-direction: row;
.record-time {
padding: 0 4px 0 0;
}
.record-icon {
transform: rotate(0) scale(0.9);
}
}
}
}
.dialog-menu {
@ -660,15 +724,14 @@
}
.dialog-footer {
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 0 28px;
margin-bottom: 20px;
position: relative;
padding: 0 24px;
margin-bottom: 16px;
.dialog-newmsg {
display: none;
position: absolute;
top: -38px;
height: 30px;
line-height: 30px;
color: #ffffff;
@ -679,25 +742,7 @@
margin-right: 10px;
border-radius: 16px;
cursor: pointer;
z-index: 2;;
}
.dialog-input {
width: 100%;
display: flex;
align-items: center;
.chat-input-box {
flex: 1;
width: 0;
display: flex;
flex-direction: column;
.chat-input-wrapper {
background-color: #F4F5F7;
padding: 10px 12px;
border-radius: 10px;
}
}
z-index: 2;
}
.chat-upload {
@ -707,9 +752,26 @@
overflow: hidden;
}
&.newmsg {
margin-top: -50px;
.chat-input-box {
.chat-input-wrapper {
background-color: #F4F5F7;
padding: 8px 2px;
border-radius: 10px;
.ql-container {
.ql-editor {
margin: 4px 12px;
&.ql-blank {
&::before {
left: 12px;
right: 12px;
}
}
}
}
}
}
&.newmsg {
.dialog-newmsg {
display: block;
}
@ -921,14 +983,24 @@
background-color: #f8f8f8;
padding: 8px 10px;
margin-bottom: 0;
.dialog-input {
.chat-input-box {
.chat-input-wrapper {
padding-left: 6px;
padding-right: 6px;
background-color: #ffffff;
.chat-input-box {
.chat-input-wrapper {
background-color: #ffffff;
.chat-toolbar {
> li {
&.chat-record-recwave {
margin-left: -10px;
width: calc(100% + 20px);
background-color: #f8f8f8;
}
}
}
}
.chat-emoji-wrapper {
margin-left: -10px;
margin-bottom: -8px;
width: calc(100% + 20px);
}
}
}
}

View File

@ -568,28 +568,31 @@
margin-left: 36px;
}
.no-input {
display: flex;
align-items: center;
position: relative;
margin: 22px 0 0 36px;
background-color: #F4F5F7;
padding: 10px 11px;
padding: 10px 8px;
border-radius: 10px;
.chat-input-box {
.chat-input-wrapper {
padding: 0;
background-color: #F4F5F7;
.chat-toolbar {
> li {
&.chat-record-recwave {
background-color: #F4F5F7;
}
}
}
}
}
.chat-input-toolbar {
position: relative;
.ivu-badge {
position: absolute;
transform: scale(0.6) translateX(100%);
transform-origin: right center;
top: -8px;
right: 10px;
z-index: 1;
}
.input-badge {
position: absolute;
transform: scale(0.6) translateX(100%);
transform-origin: right center;
top: 2px;
right: 25px;
z-index: 2;
}
}
.drag-over {
@ -651,7 +654,7 @@
padding-left: 32px;
}
.dialog-footer {
padding: 0 14px 0 28px;
padding: 0 0 0 24px;
}
}
}

View File

@ -0,0 +1,338 @@
/*
录音 Recorder扩展频率直方图显示
使用本扩展需要引入lib.fft.js支持直方图特意优化主要显示0-5khz语音部分其他高频显示区域较小不适合用来展示音乐频谱
https://github.com/xiangyuecn/Recorder
本扩展核心算法主要参考了Java开源库jmp123 版本0.3 的代码
https://www.iteye.com/topic/851459
https://sourceforge.net/projects/jmp123/files/
*/
(function(){
"use strict";
var FrequencyHistogramView=function(set){
return new fn(set);
};
var fn=function(set){
var This=this;
var o={
/*
elem:"css selector" //自动显示到dom并以此dom大小为显示大小
//或者配置显示大小手动把frequencyObj.elem显示到别的地方
,width:0 //显示宽度
,height:0 //显示高度
以上配置二选一
*/
scale:2 //缩放系数应为正整数使用2(3? no!)倍宽高进行绘制,避免移动端绘制模糊
,fps:20 //绘制帧率,不可过高
,lineCount:30 //直方图柱子数量数量的多少对性能影响不大密集运算集中在FFT算法中
,widthRatio:0.6 //柱子线条宽度占比为所有柱子占用整个视图宽度的比例剩下的空白区域均匀插入柱子中间默认值也基本相当于一根柱子占0.6一根空白占0.4设为1不留空白当视图不足容下所有柱子时也不留空白
,spaceWidth:0 //柱子间空白固定基础宽度柱子宽度自适应当不为0时widthRatio无效当视图不足容下所有柱子时将不会留空白允许为负数让柱子发生重叠
,minHeight:0 //柱子保留基础高度position不为±1时应该保留点高度
,position:-1 //绘制位置,取值-1到1-1为最底下0为中间1为最顶上小数为百分比
,mirrorEnable:false //是否启用镜像,如果启用,视图宽度会分成左右两块,右边这块进行绘制,左边这块进行镜像(以中间这根柱子的中心进行镜像)
,stripeEnable:true //是否启用柱子顶上的峰值小横条position不是-1时应当关闭否则会很丑
,stripeHeight:3 //峰值小横条基础高度
,stripeMargin:6 //峰值小横条和柱子保持的基础距离
,fallDuration:1000 //柱子从最顶上下降到最底部最长时间ms
,stripeFallDuration:3500 //峰值小横条从最顶上下降到底部最长时间ms
//柱子颜色配置:[位置css颜色...] 位置: 取值0.0-1.0之间
,linear:[0,"rgba(0,187,17,1)",0.5,"rgba(255,215,0,1)",1,"rgba(255,102,0,1)"]
//峰值小横条渐变颜色配置取值格式和linear一致留空为柱子的渐变颜色
,stripeLinear:null
,shadowBlur:0 //柱子阴影基础大小设为0不显示阴影如果柱子数量太多时请勿开启非常影响性能
,shadowColor:"#bbb" //柱子阴影颜色
,stripeShadowBlur:-1 //峰值小横条阴影基础大小设为0不显示阴影-1为柱子的大小如果柱子数量太多时请勿开启非常影响性能
,stripeShadowColor:"" //峰值小横条阴影颜色,留空为柱子的阴影颜色
//当发生绘制时会回调此方法参数为当前绘制的频率数据和采样率可实现多个直方图同时绘制只消耗一个input输入和计算时间
,onDraw:function(frequencyData,sampleRate){}
};
for(var k in set){
o[k]=set[k];
};
This.set=set=o;
var elem=set.elem;
if(elem){
if(typeof(elem)=="string"){
elem=document.querySelector(elem);
}else if(elem.length){
elem=elem[0];
};
};
if(elem){
set.width=elem.offsetWidth;
set.height=elem.offsetHeight;
};
var scale=set.scale;
var width=set.width*scale;
var height=set.height*scale;
var thisElem=This.elem=document.createElement("div");
var lowerCss=["","transform-origin:0 0;","transform:scale("+(1/scale)+");"];
thisElem.innerHTML='<div style="width:'+set.width+'px;height:'+set.height+'px;overflow:hidden"><div style="width:'+width+'px;height:'+height+'px;'+lowerCss.join("-webkit-")+lowerCss.join("-ms-")+lowerCss.join("-moz-")+lowerCss.join("")+'"><canvas/></div></div>';
var canvas=This.canvas=thisElem.querySelector("canvas");
var ctx=This.ctx=canvas.getContext("2d");
canvas.width=width;
canvas.height=height;
if(elem){
elem.innerHTML="";
elem.appendChild(thisElem);
};
if(!Recorder.LibFFT){
throw new Error("需要lib.fft.js支持");
};
This.fft=Recorder.LibFFT(1024);
//柱子所在高度
This.lastH=[];
//峰值小横条所在高度
This.stripesH=[];
};
fn.prototype=FrequencyHistogramView.prototype={
genLinear:function(ctx,colors,from,to){
var rtv=ctx.createLinearGradient(0,from,0,to);
for(var i=0;i<colors.length;){
rtv.addColorStop(colors[i++],colors[i++]);
};
return rtv;
}
,input:function(pcmData,powerLevel,sampleRate){
var This=this;
This.sampleRate=sampleRate;
This.pcmData=pcmData;
This.pcmPos=0;
This.inputTime=Date.now();
This.schedule();
}
,schedule:function(){
var This=this,set=This.set;
var interval=Math.floor(1000/set.fps);
if(!This.timer){
This.timer=setInterval(function(){
This.schedule();
},interval);
};
var now=Date.now();
var drawTime=This.drawTime||0;
if(now-This.inputTime>set.stripeFallDuration*1.3){
//超时没有输入,顶部横条已全部落下,干掉定时器
clearInterval(This.timer);
This.timer=0;
return;
};
if(now-drawTime<interval){
//没到间隔时间,不绘制
return;
};
This.drawTime=now;
//调用FFT计算频率数据
var bufferSize=This.fft.bufferSize;
var pcm=This.pcmData;
var pos=This.pcmPos;
var arr=new Int16Array(bufferSize);
for(var i=0;i<bufferSize&&pos<pcm.length;i++,pos++){
arr[i]=pcm[pos];
};
This.pcmPos=pos;
var frequencyData=This.fft.transform(arr);
//推入绘制
This.draw(frequencyData,This.sampleRate);
}
,draw:function(frequencyData,sampleRate){
var This=this,set=This.set;
var ctx=This.ctx;
var scale=set.scale;
var width=set.width*scale;
var height=set.height*scale;
var lineCount=set.lineCount;
var bufferSize=This.fft.bufferSize;
//计算高度位置
var position=set.position;
var posAbs=Math.abs(set.position);
var originY=position==1?0:height;//y轴原点
var heightY=height;//最高的一边高度
if(posAbs<1){
heightY=heightY/2;
originY=heightY;
heightY=Math.floor(heightY*(1+posAbs));
originY=Math.floor(position>0?originY*(1-posAbs):originY*(1+posAbs));
};
var lastH=This.lastH;
var stripesH=This.stripesH;
var speed=Math.ceil(heightY/(set.fallDuration/(1000/set.fps)));
var stripeSpeed=Math.ceil(heightY/(set.stripeFallDuration/(1000/set.fps)));
var stripeMargin=set.stripeMargin*scale;
var Y0=1 << (Math.round(Math.log(bufferSize)/Math.log(2) + 3) << 1);
var logY0 = Math.log(Y0)/Math.log(10);
var dBmax=20*Math.log(0x7fff)/Math.log(10);
var fftSize=bufferSize/2;
var fftSize5k=Math.min(fftSize,Math.floor(fftSize*5000/(sampleRate/2)));//5khz所在位置8000采样率及以下最高只有4khz
var fftSize5kIsAll=fftSize5k==fftSize;
var line80=fftSize5kIsAll?lineCount:Math.round(lineCount*0.8);//80%的柱子位置
var fftSizeStep1=fftSize5k/line80;
var fftSizeStep2=fftSize5kIsAll?0:(fftSize-fftSize5k)/(lineCount-line80);
var fftIdx=0;
for(var i=0;i<lineCount;i++){
//不采用jmp123的非线性划分频段录音语音并不适用于音乐的频率应当弱化高频部分
//80%关注0-5khz主要人声部分 20%关注剩下的高频,这样不管什么采样率都能做到大部分频率显示一致。
var start=Math.ceil(fftIdx);
if(i<line80){
//5khz以下
fftIdx+=fftSizeStep1;
}else{
//5khz以上
fftIdx+=fftSizeStep2;
};
var end=Math.min(Math.ceil(fftIdx),fftSize);
//参考AudioGUI.java .drawHistogram方法
//查找当前频段的最大"幅值"
var maxAmp=0;
for (var j=start; j<end; j++) {
maxAmp=Math.max(maxAmp,Math.abs(frequencyData[j]));
};
//计算音量
var dB= (maxAmp > Y0) ? Math.floor((Math.log(maxAmp)/Math.log(10) - logY0) * 17) : 0;
var h=heightY*Math.min(dB/dBmax,1);
//使柱子匀速下降
lastH[i]=(lastH[i]||0)-speed;
if(h<lastH[i]){h=lastH[i];};
if(h<0){h=0;};
lastH[i]=h;
var shi=stripesH[i]||0;
if(h&&h+stripeMargin>shi) {
stripesH[i]=h+stripeMargin;
}else{
//使峰值小横条匀速度下落
var sh =shi-stripeSpeed;
if(sh < 0){sh = 0;};
stripesH[i] = sh;
};
};
//开始绘制图形
ctx.clearRect(0,0,width,height);
var linear1=This.genLinear(ctx,set.linear,originY,originY-heightY);//上半部分的填充
var stripeLinear1=set.stripeLinear&&This.genLinear(ctx,set.stripeLinear,originY,originY-heightY)||linear1;//上半部分的峰值小横条填充
var linear2=This.genLinear(ctx,set.linear,originY,originY+heightY);//下半部分的填充
var stripeLinear2=set.stripeLinear&&This.genLinear(ctx,set.stripeLinear,originY,originY+heightY)||linear2;//上半部分的峰值小横条填充
//计算柱子间距
ctx.shadowBlur=set.shadowBlur*scale;
ctx.shadowColor=set.shadowColor;
var mirrorEnable=set.mirrorEnable;
var mirrorCount=mirrorEnable?lineCount*2-1:lineCount;//镜像柱子数量翻一倍-1根
var widthRatio=set.widthRatio;
var spaceWidth=set.spaceWidth*scale;
if(spaceWidth!=0){
widthRatio=(width-spaceWidth*(mirrorCount+1))/width;
};
var lineWidth=Math.max(1*scale,Math.floor((width*widthRatio)/mirrorCount));//柱子宽度至少1个单位
var spaceFloat=(width-mirrorCount*lineWidth)/(mirrorCount+1);//均匀间隔,首尾都留空,可能为负数,柱子将发生重叠
//绘制柱子
var minHeight=set.minHeight*scale;
var mirrorSubX=spaceFloat+lineWidth/2;
var XFloat=mirrorEnable?width/2-mirrorSubX:0;//镜像时,中间柱子位于正中心
for(var i=0,xFloat=XFloat,x,y,h;i<lineCount;i++){
xFloat+=spaceFloat;
x=Math.floor(xFloat);
h=Math.max(lastH[i],minHeight);
//绘制上半部分
if(originY!=0){
y=originY-h;
ctx.fillStyle=linear1;
ctx.fillRect(x, y, lineWidth, h);
};
//绘制下半部分
if(originY!=height){
ctx.fillStyle=linear2;
ctx.fillRect(x, originY, lineWidth, h);
};
xFloat+=lineWidth;
};
//绘制柱子顶上峰值小横条
if(set.stripeEnable){
var stripeShadowBlur=set.stripeShadowBlur;
ctx.shadowBlur=(stripeShadowBlur==-1?set.shadowBlur:stripeShadowBlur)*scale;
ctx.shadowColor=set.stripeShadowColor||set.shadowColor;
var stripeHeight=set.stripeHeight*scale;
for(var i=0,xFloat=XFloat,x,y,h;i<lineCount;i++){
xFloat+=spaceFloat;
x=Math.floor(xFloat);
h=stripesH[i];
//绘制上半部分
if(originY!=0){
y=originY-h-stripeHeight;
if(y<0){y=0;};
ctx.fillStyle=stripeLinear1;
ctx.fillRect(x, y, lineWidth, stripeHeight);
};
//绘制下半部分
if(originY!=height){
y=originY+h;
if(y+stripeHeight>height){
y=height-stripeHeight;
};
ctx.fillStyle=stripeLinear2;
ctx.fillRect(x, y, lineWidth, stripeHeight);
};
xFloat+=lineWidth;
};
};
//镜像,从中间直接镜像即可
if(mirrorEnable){
var srcW=Math.floor(width/2);
ctx.save();
ctx.scale(-1,1);
ctx.drawImage(This.canvas,Math.ceil(width/2),0,srcW,height,-srcW,0,srcW,height);
ctx.restore();
};
set.onDraw(frequencyData,sampleRate);
}
};
Recorder.FrequencyHistogramView=FrequencyHistogramView;
})();

View File

@ -0,0 +1,111 @@
/*
时域转频域快速傅里叶变换(FFT)
https://github.com/xiangyuecn/Recorder
var fft=Recorder.LibFFT(bufferSize)
bufferSize取值2的n次方
fft.bufferSize 实际采用的bufferSize
fft.transform(inBuffer)
inBuffer:[Int16,...] 数组长度必须是bufferSize
返回[Float64(Long),...]长度为bufferSize/2
*/
/*
从FFT.java 移植Java开源库jmp123 版本0.3
https://www.iteye.com/topic/851459
https://sourceforge.net/projects/jmp123/files/
*/
Recorder.LibFFT=function(bufferSize){
"use strict";
var FFT_N_LOG,FFT_N,MINY;
var real, imag, sintable, costable;
var bitReverse;
var FFT_Fn=function(bufferSize) {//bufferSize只能取值2的n次方
FFT_N_LOG=Math.round(Math.log(bufferSize)/Math.log(2));
FFT_N = 1 << FFT_N_LOG;
MINY = ((FFT_N << 2) * Math.sqrt(2));
real = [];
imag = [];
sintable = [0];
costable = [0];
bitReverse = [];
var i, j, k, reve;
for (i = 0; i < FFT_N; i++) {
k = i;
for (j = 0, reve = 0; j != FFT_N_LOG; j++) {
reve <<= 1;
reve |= (k & 1);
k >>>= 1;
}
bitReverse[i] = reve;
}
var theta, dt = 2 * Math.PI / FFT_N;
for (i = (FFT_N >> 1) - 1; i > 0; i--) {
theta = i * dt;
costable[i] = Math.cos(theta);
sintable[i] = Math.sin(theta);
}
}
/*
用于频谱显示的快速傅里叶变换
inBuffer 输入FFT_N个实数返回 FFT_N/2个输出值(复数模的平方)
*/
var getModulus=function(inBuffer) {
var i, j, k, ir, j0 = 1, idx = FFT_N_LOG - 1;
var cosv, sinv, tmpr, tmpi;
for (i = 0; i != FFT_N; i++) {
real[i] = inBuffer[bitReverse[i]];
imag[i] = 0;
}
for (i = FFT_N_LOG; i != 0; i--) {
for (j = 0; j != j0; j++) {
cosv = costable[j << idx];
sinv = sintable[j << idx];
for (k = j; k < FFT_N; k += j0 << 1) {
ir = k + j0;
tmpr = cosv * real[ir] - sinv * imag[ir];
tmpi = cosv * imag[ir] + sinv * real[ir];
real[ir] = real[k] - tmpr;
imag[ir] = imag[k] - tmpi;
real[k] += tmpr;
imag[k] += tmpi;
}
}
j0 <<= 1;
idx--;
}
j = FFT_N >> 1;
var outBuffer=new Float64Array(j);
/*
* 输出模的平方:
* for(i = 1; i <= j; i++)
* inBuffer[i-1] = real[i] * real[i] + imag[i] * imag[i];
*
* 如果FFT只用于频谱显示,可以"淘汰"幅值较小的而减少浮点乘法运算. MINY的值
* 和Spectrum.Y0,Spectrum.logY0对应.
*/
sinv = MINY;
cosv = -MINY;
for (i = j; i != 0; i--) {
tmpr = real[i];
tmpi = imag[i];
if (tmpr > cosv && tmpr < sinv && tmpi > cosv && tmpi < sinv)
outBuffer[i - 1] = 0;
else
outBuffer[i - 1] = Math.round(tmpr * tmpr + tmpi * tmpi);
}
return outBuffer;
}
FFT_Fn(bufferSize);
return {transform:getModulus,bufferSize:FFT_N};
};

File diff suppressed because one or more lines are too long