no message

This commit is contained in:
kuaifan 2021-06-05 23:14:40 +08:00
parent 8cd0f1521d
commit 11a86c0cdd
48 changed files with 1159 additions and 210 deletions

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Models\User;
use App\Models\WebSocketDialog;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgRead;
use App\Models\WebSocketDialogUser;
use App\Module\Base;
use Request;
@ -44,6 +45,10 @@ class DialogController extends AbstractController
}
//
$list = WebSocketDialogMsg::whereDialogId($dialog_id)->orderByDesc('id')->paginate(Base::getPaginate(100, 50));
$list->transform(function (WebSocketDialogMsg $item) use ($user) {
$item->r = $item->userid === $user->userid ? null : WebSocketDialogMsgRead::whereMsgId($item->id)->whereUserid($user->userid)->first();
return $item;
});
//
return Base::retSuccess('success', $list);
}
@ -94,4 +99,128 @@ class DialogController extends AbstractController
return WebSocketDialogMsg::addUserMsg($dialog_id, 'text', $msg, $user->userid, $extra_int, $extra_str);
}
}
/**
* {post}文件上传
*
* @apiParam {Number} dialog_id 对话ID
* @apiParam {Number} [extra_int] 额外参数(数字)
* @apiParam {String} [extra_str] 额外参数(字符)
* @apiParam {String} [filename] post-文件名称
* @apiParam {String} [image64] post-base64图片二选一
* @apiParam {File} [files] post-文件对象(二选一)
*/
public function msg__sendfile()
{
$user = User::authE();
if (Base::isError($user)) {
return $user;
} else {
$user = User::IDE($user['data']);
}
//
$dialog_id = Base::getPostInt('dialog_id');
$extra_int = Base::getPostInt('extra_int');
$extra_str = Base::getPostValue('extra_str');
//
if (!WebSocketDialogUser::whereDialogId($dialog_id)->whereUserid($user->userid)->exists()) {
return Base::retError('不在成员列表内');
}
$dialog = WebSocketDialog::whereId($dialog_id)->first();
if (empty($dialog)) {
return Base::retError('对话不存在或已被删除');
}
//
$path = "uploads/chat/" . $user->userid . "/";
$image64 = Base::getPostValue('image64');
$fileName = Base::getPostValue('filename');
if ($image64) {
$data = Base::image64save([
"image64" => $image64,
"path" => $path,
"fileName" => $fileName,
]);
} else {
$data = Base::upload([
"file" => Request::file('files'),
"type" => 'file',
"path" => $path,
"fileName" => $fileName,
]);
}
//
if (Base::isError($data)) {
return Base::retError($data['msg']);
} else {
$fileData = $data['data'];
$fileData['thumb'] = $fileData['thumb'] ?: 'images/ext/file.png';
switch ($fileData['ext']) {
case "docx":
$fileData['thumb'] = 'images/ext/doc.png';
break;
case "xlsx":
$fileData['thumb'] = 'images/ext/xls.png';
break;
case "pptx":
$fileData['thumb'] = 'images/ext/ppt.png';
break;
case "ai":
case "avi":
case "bmp":
case "cdr":
case "doc":
case "eps":
case "gif":
case "mov":
case "mp3":
case "mp4":
case "pdf":
case "ppt":
case "pr":
case "psd":
case "rar":
case "svg":
case "tif":
case "txt":
case "xls":
case "zip":
$fileData['thumb'] = 'images/ext/' . $fileData['ext'] . '.png';
break;
}
//
$msg = $fileData;
$msg['size'] *= 1024;
//
if ($dialog->type == 'group') {
return WebSocketDialogMsg::addGroupMsg($dialog_id, 'file', $msg, $user->userid, $extra_int, $extra_str);
} else {
return WebSocketDialogMsg::addUserMsg($dialog_id, 'file', $msg, $user->userid, $extra_int, $extra_str);
}
}
}
/**
* 获取消息阅读情况
*
* @apiParam {Number} msg_id 消息ID需要是消息的发送人
*/
public function msg__readlist()
{
$user = User::authE();
if (Base::isError($user)) {
return $user;
} else {
$user = User::IDE($user['data']);
}
//
$msg_id = intval(Request::input('msg_id'));
//
$msg = WebSocketDialogMsg::whereId($msg_id)->whereUserid($user->userid)->first();
if (empty($msg)) {
return Base::retError('不是发送人');
}
//
$read = WebSocketDialogMsgRead::whereMsgId($msg_id)->get();
return Base::retSuccess('success', $read ?: []);
}
}

View File

@ -23,5 +23,8 @@ class VerifyCsrfToken extends Middleware
// 添加任务
'api/project/task/add/',
// 聊天发文件
'api/dialog/msg/sendfile/',
];
}

View File

@ -4,7 +4,9 @@ namespace App\Models;
use App\Module\Base;
use App\Tasks\PushTask;
use App\Tasks\WebSocketDialogMsgTask;
use Carbon\Carbon;
use Hhxsv5\LaravelS\Swoole\Task\Task;
/**
* Class WebSocketDialogMsg
@ -15,11 +17,13 @@ use Carbon\Carbon;
* @property int|null $userid 发送会员ID
* @property string|null $type 消息类型
* @property array|mixed $msg 详细消息
* @property int|null $read 是否已读
* @property int|null $read 已阅数量
* @property int|null $send 发送数量
* @property int|null $extra_int 额外数字参数
* @property string|null $extra_str 额外字符参数
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property-read int|mixed $percentage
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg query()
@ -30,6 +34,7 @@ use Carbon\Carbon;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereMsg($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereRead($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereSend($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereType($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereUpdatedAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsg whereUserid($value)
@ -37,12 +42,32 @@ use Carbon\Carbon;
*/
class WebSocketDialogMsg extends AbstractModel
{
protected $appends = [
'percentage',
];
protected $hidden = [
'updated_at',
];
/**
* 消息
* 阅读占比
* @return int|mixed
*/
public function getPercentageAttribute()
{
if (!isset($this->attributes['percentage'])) {
if ($this->read > $this->send || empty($this->send)) {
$this->attributes['percentage'] = 100;
} else {
$this->attributes['percentage'] = intval($this->read / $this->send * 100);
}
}
return $this->attributes['percentage'];
}
/**
* 消息格式化
* @param $value
* @return array|mixed
*/
@ -51,27 +76,54 @@ class WebSocketDialogMsg extends AbstractModel
if (is_array($value)) {
return $value;
}
return Base::json2array($value);
$value = Base::json2array($value);
if ($this->type === 'file') {
$value['type'] = in_array($value['ext'], ['jpg', 'jpeg', 'png', 'gif']) ? 'img' : 'file';
$value['url'] = Base::fillUrl($value['path']);
$value['thumb'] = Base::fillUrl($value['thumb']);
}
return $value;
}
/**
* 标记已送达 同时 告诉发送人已送达
* @return $this
* @param $userid
* @return bool
*/
public function readSuccess()
public function readSuccess($userid)
{
if (empty($this->read)) {
$this->read = 1;
$this->save();
PushTask::push([
'userid' => $this->userid,
'msg' => [
'type' => 'dialog',
'data' => $this->toArray(),
]
]);
if (empty($userid)) {
return false;
}
return $this;
$result = self::transaction(function() use ($userid) {
$msgRead = WebSocketDialogMsgRead::whereMsgId($this->id)->whereUserid($userid)->lockForUpdate()->first();
if (empty($msgRead)) {
$msgRead = WebSocketDialogMsgRead::createInstance([
'msg_id' => $this->id,
'userid' => $userid,
]);
try {
$msgRead->saveOrFail();
$this->send = WebSocketDialogMsgRead::whereMsgId($this->id)->count();
$this->save();
} catch (\Throwable $e) {
$msgRead = $msgRead->first();
}
}
if ($msgRead && !$msgRead->read_at) {
$msgRead->read_at = Carbon::now();
$msgRead->save();
$this->increment('read');
PushTask::push([
'userid' => $this->userid,
'msg' => [
'type' => 'dialog',
'data' => $this->toArray(),
]
]);
}
});
return Base::isSuccess($result);
}
/**
@ -101,19 +153,14 @@ class WebSocketDialogMsg extends AbstractModel
}
$dialog->last_at = Carbon::now();
$dialog->save();
$userids = WebSocketDialogUser::whereDialogId($dialog->id)->where('userid', '!=', $dialogMsg->userid)->pluck('userid')->toArray();
$dialogMsg->send = count($userids);
$dialogMsg->dialog_id = $dialog->id;
$dialogMsg->save();
//
$userids = WebSocketDialogUser::whereDialogId($dialog->id)->where('userid', '!=', $dialogMsg->userid)->pluck('userid')->toArray();
if ($userids) {
PushTask::push([
'userid' => $userids,
'msg' => [
'type' => 'dialog',
'data' => $dialogMsg->toArray(),
]
]);
}
$task = new WebSocketDialogMsgTask($userids, $dialogMsg->toArray());
Task::deliver($task);
//
return Base::retSuccess('发送成功', $dialogMsg);
});
}
@ -146,16 +193,13 @@ class WebSocketDialogMsg extends AbstractModel
}
$dialog->last_at = Carbon::now();
$dialog->save();
$dialogMsg->send = 1;
$dialogMsg->dialog_id = $dialog->id;
$dialogMsg->save();
//
PushTask::push([
'userid' => $userid,
'msg' => [
'type' => 'dialog',
'data' => $dialogMsg->toArray(),
]
]);
$task = new WebSocketDialogMsgTask($userid, $dialogMsg->toArray());
Task::deliver($task);
//
return Base::retSuccess('发送成功', $dialogMsg);
});
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
/**
* Class WebSocketDialogMsgRead
*
* @package App\Models
* @property int $id
* @property int|null $msg_id 消息ID
* @property int|null $userid 发送会员ID
* @property string|null $read_at 阅读时间
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead newQuery()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead query()
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead whereMsgId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead whereReadAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialogMsgRead whereUserid($value)
* @mixin \Eloquent
*/
class WebSocketDialogMsgRead extends AbstractModel
{
function __construct(array $attributes = [])
{
parent::__construct($attributes);
$this->timestamps = false;
}
}

View File

@ -744,7 +744,11 @@ class Base
) {
return $str;
} else {
return url($str);
try {
return url($str);
} catch (\Throwable $e) {
return self::getSchemeAndHost() . "/" . $str;
}
}
}
@ -761,7 +765,22 @@ class Base
}
return $str;
}
return Base::leftDelete($str, url('') . '/');
try {
$find = url('');
} catch (\Throwable $e) {
$find = self::getSchemeAndHost();
}
return Base::leftDelete($str, $find . '/');
}
/**
* 获取主地址
* @return string http://127.0.0.1:8080
*/
public static function getSchemeAndHost()
{
$scheme = isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == '443' ? 'https://' : 'http://';
return $scheme.($_SERVER['HTTP_HOST'] ?? '');
}
/**
@ -1922,12 +1941,16 @@ class Base
public static function getPostValue($key, $default = null)
{
return self::newTrim(self::getContentValue($key, $default));
$value = self::newTrim(self::getContentValue($key, $default));
if (empty($value)) {
$value = self::newTrim(Request::post($key, $default));
}
return $value;
}
public static function getPostInt($key, $default = null)
{
return intval(self::getContentValue($key, $default));
return intval(self::getPostValue($key, $default));
}
/**
@ -2301,7 +2324,7 @@ class Base
$array['thumb'] = $array['path'];
if ($param['autoThumb'] === "false") $param['autoThumb'] = false;
if ($param['autoThumb'] !== false) {
if (Base::imgThumb($array['file'], $array['file'] . "_thumb.jpg", 180, 0)) {
if (Base::imgThumb($array['file'], $array['file'] . "_thumb.jpg", 320, 0)) {
$array['thumb'] .= "_thumb.jpg";
}
}

View File

@ -122,7 +122,7 @@ class WebSocketService implements WebSocketHandlerInterface
*/
case 'readMsg':
$dialogMsg = WebSocketDialogMsg::whereId(intval($data['id']))->first();
$dialogMsg && $dialogMsg->readSuccess();
$dialogMsg && $dialogMsg->readSuccess($this->getUserid($frame->fd));
return;
}
//
@ -178,4 +178,14 @@ class WebSocketService implements WebSocketHandlerInterface
{
WebSocket::whereFd($fd)->delete();
}
/**
* 根据fd获取会员ID
* @param $fd
* @return int
*/
private function getUserid($fd)
{
return intval(WebSocket::whereFd($fd)->value('userid'));
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Tasks;
use App\Models\WebSocketDialogMsg;
use App\Models\WebSocketDialogMsgRead;
@error_reporting(E_ALL & ~E_NOTICE);
/**
* 消息通知任务
* Class WebSocketDialogMsgTask
* @package App\Tasks
*/
class WebSocketDialogMsgTask extends AbstractTask
{
protected $userid;
protected $dialogMsgArray;
/**
* NoticeTask constructor.
*/
public function __construct($userid, array $dialogMsgArray)
{
$this->userid = $userid;
$this->dialogMsgArray = $dialogMsgArray;
}
/**
* @throws \Throwable
*/
public function start()
{
$userids = is_array($this->userid) ? $this->userid : [$this->userid];
$msgId = intval($this->dialogMsgArray['id']);
$send = intval($this->dialogMsgArray['send']);
if (empty($userids) || empty($msgId)) {
return;
}
$pushIds = [];
foreach ($userids AS $userid) {
$msgRead = WebSocketDialogMsgRead::createInstance([
'msg_id' => $msgId,
'userid' => $userid,
]);
try {
$msgRead->saveOrFail();
$pushIds[] = $userid;
} catch (\Throwable $e) {
//
}
}
// 更新已发送数量
if ($send != count($pushIds)) {
$send = WebSocketDialogMsgRead::whereMsgId($msgId)->count();
WebSocketDialogMsg::whereId($msgId)->update([ 'send' => $send ]);
$this->dialogMsgArray['send'] = $send;
}
// 开始推送消息
if ($pushIds) {
PushTask::push([
'userid' => $pushIds,
'msg' => [
'type' => 'dialog',
'data' => $this->dialogMsgArray,
]
]);
}
}
}

View File

@ -1,14 +1,19 @@
<template>
<Tooltip v-if="user"
:class="['common-avatar', user.online ? 'online' : '']"
class="common-avatar"
:delay="600"
:transfer="transfer">
<div slot="content">
<p>{{$L('昵称')}}: {{user.nickname}}</p>
<p>{{$L('职位/职称')}}: {{user.profession || '-'}}</p>
</div>
<Avatar v-if="showImg" :src="user.userimg" :size="size"/>
<Avatar v-else :size="size" class="common-avatar-text">{{nickname}}</Avatar>
<div class="avatar-wrapper">
<div :class="['avatar-box', user.online ? 'online' : '']">
<Avatar v-if="showImg" :src="user.userimg" :size="size"/>
<Avatar v-else :size="size" class="avatar-text">{{nickname}}</Avatar>
</div>
<div v-if="showName" class="avatar-name">{{user.nickname}}</div>
</div>
</Tooltip>
</template>
@ -24,6 +29,10 @@
type: [String, Number],
default: 'default'
},
showName: {
type: Boolean,
default: false
},
transfer: {
type: Boolean,
default: true

View File

@ -0,0 +1,125 @@
<template>
<div class="common-circle" :data-id="percent" :style="style">
<svg viewBox="0 0 28 28">
<g fill="none" fill-rule="evenodd">
<path class="common-circle-path" d="M-500-100h997V48h-997z"></path>
<g fill-rule="nonzero">
<path class="common-circle-g-path-ring" stroke-width="3" d="M14 25.5c6.351 0 11.5-5.149 11.5-11.5S20.351 2.5 14 2.5 2.5 7.649 2.5 14 7.649 25.5 14 25.5z"></path>
<path class="common-circle-g-path-core" :d="arc(args)"/>
</g>
</g>
</svg>
</div>
</template>
<style lang="scss" scoped>
:global {
.common-circle {
border-radius: 50%;
.common-circle-path {
fill: transparent;
}
.common-circle-g-path-ring {
stroke: #87d068;
}
.common-circle-g-path-core {
fill: #87d068;
transform: scale(0.56);
transform-origin: 50%;
}
}
}
</style>
<script>
export default {
name: 'WCircle',
props: {
percent: {
type: Number,
default: 0
},
size: {
type: Number,
default: 120
},
},
computed: {
style() {
let {size} = this;
if (this.isNumeric(size)) {
size += 'px';
}
return {
width: size,
height: size,
}
},
args() {
const {percent} = this;
let end = Math.min(360, 360 / 100 * percent);
if (end == 360) {
end = 0;
} else if (end == 0) {
end = 360;
}
return {
x: 14,
y: 14,
r: 14,
start: 360,
end: end,
}
}
},
methods: {
isNumeric(n) {
return n !== '' && !isNaN(parseFloat(n)) && isFinite(n);
},
point(x, y, r, angel) {
return [
(x + Math.sin(angel) * r).toFixed(2),
(y - Math.cos(angel) * r).toFixed(2),
]
},
full(x, y, R, r) {
if (r <= 0) {
return `M ${x - R} ${y} A ${R} ${R} 0 1 1 ${x + R} ${y} A ${R} ${R} 1 1 1 ${x - R} ${y} Z`;
}
return `M ${x - R} ${y} A ${R} ${R} 0 1 1 ${x + R} ${y} A ${R} ${R} 1 1 1 ${x - R} ${y} M ${x - r} ${y} A ${r} ${r} 0 1 1 ${x + r} ${y} A ${r} ${r} 1 1 1 ${x - r} ${y} Z`;
},
part(x, y, R, r, start, end) {
const [s, e] = [(start / 360) * 2 * Math.PI, (end / 360) * 2 * Math.PI];
const P = [
this.point(x, y, r, s),
this.point(x, y, R, s),
this.point(x, y, R, e),
this.point(x, y, r, e),
];
const flag = e - s > Math.PI ? '1' : '0';
return `M ${P[0][0]} ${P[0][1]} L ${P[1][0]} ${P[1][1]} A ${R} ${R} 0 ${flag} 1 ${P[2][0]} ${P[2][1]} L ${P[3][0]} ${P[3][1]} A ${r} ${r} 0 ${flag} 0 ${P[0][0]} ${P[0][1]} Z`;
},
arc(opts) {
const { x = 0, y = 0 } = opts;
let {R = 0, r = 0, start, end,} = opts;
[R, r] = [Math.max(R, r), Math.min(R, r)];
if (R <= 0) return '';
if (start !== +start || end !== +end) return this.full(x, y, R, r);
if (Math.abs(start - end) < 0.000001) return '';
if (Math.abs(start - end) % 360 < 0.000001) return this.full(x, y, R, r);
[start, end] = [start % 360, end % 360];
if (start > end) end += 360;
return this.part(x, y, R, r, start, end);
}
}
}
</script>

View File

@ -1306,27 +1306,30 @@
/**
* 对象中有Date格式的转成指定格式
* @param myObj
* @param format 默认格式Y-m-d
* @param params
* @param format 默认格式Y-m-d H:i:s
* @returns {*}
*/
date2string(myObj, format) {
if (myObj === null) {
return myObj;
date2string(params, format) {
if (params === null) {
return params;
}
if (typeof format === "undefined") {
format = "Y-m-d";
format = "Y-m-d H:i:s";
}
if (typeof myObj === "object") {
if (myObj instanceof Date) {
return $A.formatDate(format, myObj);
if (params instanceof Date) {
params = $A.formatDate(format, params);
} else if ($A.isJson(params)) {
for (let key in params) {
if (!params.hasOwnProperty(key)) continue;
params[key] = $A.date2string(params[key], format);
}
$A.each(myObj, (key, val)=>{
myObj[key] = $A.date2string(val, format);
} else if ($A.isArray(params)) {
params.forEach((val, index) => {
params[index] = $A.date2string(val, format);
});
return myObj;
}
return myObj;
return params;
},
/**

View File

@ -39,6 +39,7 @@
if (typeof params.success === 'undefined') params.success = () => { };
if (typeof params.header === 'undefined') params.header = {};
params.url = this.apiUrl(params.url);
params.data = this.date2string(params.data);
params.header['Content-Type'] = 'application/json';
params.header['language'] = $A.getLanguage();
params.header['token'] = $A.store.state.userToken;

View File

@ -8,15 +8,15 @@
<ul>
<li @click="toggleRoute('dashboard')" :class="classNameRoute('dashboard')">
<Icon type="md-speedometer" />
<div class="menu-title">Dashboard</div>
<div class="menu-title">{{$L('仪表板')}}</div>
</li>
<li @click="toggleRoute('setting/personal')" :class="classNameRoute('setting')">
<Icon type="md-cog" />
<div class="menu-title">Setting</div>
<div class="menu-title">{{$L('设置')}}</div>
</li>
<li @click="toggleRoute('calendar')" :class="classNameRoute('calendar')">
<Icon type="md-calendar" />
<div class="menu-title">Calendar</div>
<div class="menu-title">{{$L('日历')}}</div>
</li>
<li class="menu-project">
<ul>
@ -25,7 +25,7 @@
<Loading v-if="loadIng > 0"/>
</li>
</ul>
<Button class="manage-box-new" type="primary" icon="md-add" @click="addShow=true">New Project</Button>
<Button class="manage-box-new" type="primary" icon="md-add" @click="addShow=true">{{$L('新建项目')}}</Button>
</div>
<div class="manage-box-main">

View File

@ -0,0 +1,99 @@
<template>
<Upload
name="files"
ref="upload"
:action="actionUrl"
:data="params"
multiple
:format="uploadFormat"
:show-upload-list="false"
:max-size="maxSize"
:on-progress="handleProgress"
:on-success="handleSuccess"
:on-format-error="handleFormatError"
:on-exceeded-size="handleMaxSize">
</Upload>
</template>
<script>
import {mapState} from "vuex";
export default {
name: 'MessageUpload',
props: {
maxSize: {
type: Number,
default: 204800
}
},
data() {
return {
uploadFormat: ['jpg', 'jpeg', 'png', 'gif', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'esp', 'pdf', 'rar', 'zip', 'gz', 'ai', 'avi', 'bmp', 'cdr', 'eps', 'mov', 'mp3', 'mp4', 'pr', 'psd', 'svg', 'tif'],
actionUrl: $A.apiUrl('dialog/msg/sendfile'),
}
},
computed: {
...mapState(['userToken', 'dialogId']),
params() {
return {
dialog_id: this.dialogId,
token: this.userToken,
}
}
},
methods: {
handleProgress(event, file) {
//
if (typeof file.tempId === "undefined") {
file.tempId = $A.randomString(8);
this.$emit('on-progress', file);
}
},
handleSuccess(res, file) {
//
if (res.ret === 1) {
file.data = res.data;
this.$emit('on-success', file);
} else {
$A.modalWarning({
title: '发送失败',
content: '文件 ' + file.name + ' 发送失败,' + res.msg
});
this.$emit('on-error', file);
this.$refs.upload.fileList.pop();
}
},
handleFormatError(file) {
//
$A.modalWarning({
title: '文件格式不正确',
content: '文件 ' + file.name + ' 格式不正确,仅支持发送:' + this.uploadFormat.join(',')
});
},
handleMaxSize(file) {
//
$A.modalWarning({
title: '超出文件大小限制',
content: '文件 ' + file.name + ' 太大,不能发送超过' + $A.bytesToSize(this.maxSize * 1024) + '。'
});
},
handleClick() {
//
this.$refs.upload.handleClick()
},
upload(file) {
//file
this.$refs.upload.upload(file);
},
}
}
</script>

View File

@ -1,13 +1,51 @@
<template>
<div class="message-view">
<div class="message-view" :data-id="msgData.id">
<div v-if="msgData.type == 'text'" class="message-content" v-html="textMsg(msgData.msg.text)"></div>
<div v-else class="message-content message-unknown">{{$L("未知的消息类型")}}</div>
<!--文本-->
<div v-if="msgData.type === 'text'" class="message-content" v-html="textMsg(msgInfo.text)"></div>
<!--等待-->
<div v-else-if="msgData.type === 'loading'" class="message-content loading"><Loading/></div>
<!--文件-->
<div v-else-if="msgData.type === 'file'" :class="['message-content', msgInfo.type]">
<a :href="msgInfo.url" target="_blank">
<img v-if="msgInfo.type === 'img'" class="file-img" :style="imageStyle(msgInfo)" :src="msgInfo.thumb"/>
<div v-else class="file-box">
<img class="file-thumb" :src="msgInfo.thumb"/>
<div class="file-info">
<div class="file-name">{{msgInfo.name}}</div>
<div class="file-size">{{$A.bytesToSize(msgInfo.size)}}</div>
</div>
</div>
</a>
</div>
<!--未知-->
<div v-else class="message-content unknown">{{$L("未知的消息类型")}}</div>
<!--时间/阅读-->
<div v-if="msgData.created_at" class="message-foot">
<div class="time">{{formatTime(msgData.created_at)}}</div>
<Icon v-if="msgData.read" class="done-all" type="md-done-all" />
<Icon v-else class="done" type="md-checkmark" />
<Poptip
v-if="msgData.send > 1 || dialogType == 'group'"
class="percent"
placement="left-end"
transfer
:width="360"
:offset="8"
@on-popper-show="popperShow">
<div slot="content" class="message-readbox">
<ul class="read">
<li class="read-title"><em>{{readList.length}}</em>{{$L('已读')}}</li>
<li v-for="item in readList"><UserAvatar :userid="item.userid" :size="26" show-name/></li>
</ul>
<ul class="unread">
<li class="read-title"><em>{{unreadList.length}}</em>{{$L('未读')}}</li>
<li v-for="item in unreadList"><UserAvatar :userid="item.userid" :size="26" show-name/></li>
</ul>
</div>
<WCircle :percent="msgData.percentage" :size="14"/>
</Poptip>
<Icon v-else-if="msgData.percentage === 100" class="done" type="md-done-all"/>
<Icon v-else class="done" type="md-checkmark"/>
</div>
<div v-else class="message-foot"><Loading/></div>
@ -16,9 +54,11 @@
<script>
import {mapState} from "vuex";
import WCircle from "../../../components/WCircle";
export default {
name: "MessageView",
components: {WCircle},
props: {
msgData: {
type: Object,
@ -26,32 +66,66 @@ export default {
return {};
}
},
dialogType: {
type: String,
default: ''
},
},
data() {
return {
msgInfo: {},
read_list: []
}
},
mounted() {
this.readMarking()
this.parsingData()
},
computed: {
...mapState(['userId']),
readList() {
return this.read_list.filter(({read_at}) => read_at)
},
unreadList() {
return this.read_list.filter(({read_at}) => !read_at)
}
},
watch: {
msgData() {
this.readMarking()
this.parsingData()
}
},
methods: {
readMarking() {
if (this.msgData.read === 0 && this.msgData.userid != this.userId) {
this.$store.commit('wsSend', {
type: 'readMsg',
data: {
id: this.msgData.id
popperShow() {
$A.apiAjax({
url: 'dialog/msg/readlist',
data: {
msg_id: this.msgData.id,
},
success: ({ret, data, msg}) => {
if (ret === 1) {
this.read_list = data;
}
});
}
}
});
},
parsingData() {
this.msgInfo = this.msgData.msg;
//
const {userid, r, id} = this.msgData;
if (userid == this.userId) return;
if ($A.isJson(r) && r.read_at) return;
this.$store.commit('wsSend', {
type: 'readMsg',
data: {id}
});
},
formatTime(date) {
@ -74,6 +148,30 @@ export default {
text = text.trim().replace(/(\n\x20*){3,}/g, "<br/><br/>");
text = text.trim().replace(/\n/g, "<br/>");
return text;
},
imageStyle(info) {
const {width, height} = info;
if (width && height) {
let maxWidth = 220,
maxHeight = 220,
tempWidth = width,
tempHeight = height;
if (width > maxWidth || height > maxHeight) {
if (width > height) {
tempWidth = maxWidth;
tempHeight = height * (maxWidth / width);
} else {
tempWidth = width * (maxHeight / height);
tempHeight = maxHeight;
}
}
return {
width: tempWidth + 'px',
height: tempHeight + 'px',
};
}
return {};
}
}
}

View File

@ -20,7 +20,7 @@
<Tooltip theme="light" :always="searchText!=''" transfer>
<Icon type="ios-search" />
<div slot="content">
<Input v-model="searchText" placeholder="Search task..." clearable autofocus/>
<Input v-model="searchText" :placeholder="$L('名称、描述...')" clearable autofocus/>
</div>
</Tooltip>
</li>
@ -99,11 +99,11 @@
<div v-else class="project-table">
<div class="project-table-head">
<Row class="project-row">
<Col span="12"># Task name</Col>
<Col span="3">Task Column</Col>
<Col span="3">Priority</Col>
<Col span="3">Member</Col>
<Col span="3">Expiration</Col>
<Col span="12"># {{$L('任务名称')}}</Col>
<Col span="3">{{$L('列表')}}</Col>
<Col span="3">{{$L('优先级')}}</Col>
<Col span="3">{{$L('负责人')}}</Col>
<Col span="3">{{$L('到期时间')}}</Col>
</Row>
</div>
<!--我的任务-->
@ -112,7 +112,7 @@
<Row class="project-row">
<Col span="12" class="row-title">
<i class="iconfont">&#xe689;</i>
<div class="row-h1">My task</div>
<div class="row-h1">{{$L('我的任务')}}</div>
<div class="row-num">({{myList.length}})</div>
</Col>
<Col span="3"></Col>
@ -170,7 +170,7 @@
<Row class="project-row">
<Col span="12" class="row-title">
<i class="iconfont">&#xe689;</i>
<div class="row-h1">Undone</div>
<div class="row-h1">{{$L('未完成任务')}}</div>
<div class="row-num">({{undoneList.length}})</div>
</Col>
<Col span="3"></Col>
@ -217,7 +217,7 @@
<Row class="project-row">
<Col span="12" class="row-title">
<i class="iconfont">&#xe689;</i>
<div class="row-h1">Completed</div>
<div class="row-h1">{{$L('已完成任务')}}</div>
<div class="row-num">({{completedList.length}})</div>
</Col>
<Col span="3"></Col>
@ -975,6 +975,15 @@ export default {
$A.messageSuccess(msg);
this.$store.commit('getProjectDetail', this.addData.project_id);
this.addShow = false;
this.addData = {
owner: 0,
column_id: 0,
times: [],
subtasks: [],
p_level: 0,
p_name: '',
p_color: '',
};
} else {
$A.modalError(msg);
}
@ -1166,7 +1175,7 @@ export default {
let minutes = Math.floor(((second % 86400) % 3600) / 60);
let seconds = Math.floor(((second % 86400) % 3600) % 60);
if (days > 0) {
if (hours > 0) duration = days + "d," + this.formatBit(hours) + "h.";
if (hours > 0) duration = days + "d," + this.formatBit(hours) + "h";
else if (minutes > 0) duration = days + "d," + this.formatBit(minutes) + "min";
else if (seconds > 0) duration = days + "d," + this.formatBit(seconds) + "s";
else duration = days + "d";

View File

@ -1,5 +1,10 @@
<template>
<div v-if="$store.state.projectChatShow" class="project-message">
<div
v-if="$store.state.projectChatShow"
class="project-message"
@drop.prevent="chatPasteDrag($event, 'drag')"
@dragover.prevent="chatDragOver(true)"
@dragleave.prevent="chatDragOver(false)">
<div class="group-member">
<div class="member-head">
<div class="member-title">{{$L('项目成员')}}<span>({{projectDetail.project_user.length}})</span></div>
@ -12,7 +17,7 @@
</ul>
</div>
<div class="group-title">{{$L('群聊')}}</div>
<ScrollerY ref="groupChat" class="group-chat message-scroller" @on-scroll="groupChatScroll">
<ScrollerY ref="groupChat" class="group-chat message-scroller" @on-scroll="chatScroll">
<div ref="manageList" class="message-list">
<ul>
<li v-if="dialogLoad > 0" class="loading"><Loading/></li>
@ -21,13 +26,24 @@
<div class="message-avatar">
<UserAvatar :userid="item.userid" :size="30"/>
</div>
<MessageView :msg-data="item"/>
<MessageView :msg-data="item" dialog-type="group"/>
</li>
<li ref="bottom" class="bottom"></li>
</ul>
</div>
</ScrollerY>
<div class="group-footer">
<DragInput class="group-input" v-model="msgText" type="textarea" :rows="1" :autosize="{ minRows: 1, maxRows: 3 }" :maxlength="255" @on-keydown="groupKeydown" @on-input-paste="groupPasteDrag" :placeholder="$L('输入消息...')" />
<div :class="['group-footer', msgNew > 0 ? 'newmsg' : '']">
<div class="group-newmsg" @click="goNewBottom">{{$L('' + msgNew + '条新消息')}}</div>
<DragInput class="group-input" v-model="msgText" type="textarea" :rows="1" :autosize="{ minRows: 1, maxRows: 3 }" :maxlength="255" @on-keydown="chatKeydown" @on-input-paste="pasteDrag" :placeholder="$L('输入消息...')" />
<MessageUpload
ref="chatUpload"
class="chat-upload"
@on-progress="chatFile('progress', $event)"
@on-success="chatFile('success', $event)"
@on-error="chatFile('error', $event)"/>
</div>
<div v-if="dialogDrag" class="drag-over" @click="dialogDrag=false">
<div class="drag-text">{{$L('拖动到这里发送')}}</div>
</div>
</div>
</template>
@ -115,8 +131,64 @@
margin-top: 18px;
}
.group-footer {
display: flex;
flex-direction: column;
align-items: flex-end;
padding: 0 28px;
margin-bottom: 20px;
.group-newmsg {
display: none;
height: 30px;
line-height: 30px;
color: #ffffff;
font-size: 12px;
background-color: rgba(0, 0, 0, 0.6);
padding: 0 12px;
margin-bottom: 20px;
margin-right: 10px;
border-radius: 16px;
cursor: pointer;
z-index: 2;;
}
.chat-upload {
display: none;
width: 0;
height: 0;
overflow: hidden;
}
&.newmsg {
margin-top: -50px;
.group-newmsg {
display: block;
}
}
}
.drag-over {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 3;
background-color: rgba(255, 255, 255, 0.78);
display: flex;
align-items: center;
justify-content: center;
&:before {
content: "";
position: absolute;
top: 16px;
left: 16px;
right: 16px;
bottom: 16px;
border: 2px dashed #7b7b7b;
border-radius: 12px;
}
.drag-text {
padding: 12px;
font-size: 18px;
color: #666666;
}
}
}
}
@ -127,24 +199,34 @@ import DragInput from "../../../components/DragInput";
import ScrollerY from "../../../components/ScrollerY";
import {mapState} from "vuex";
import MessageView from "./message-view";
import MessageUpload from "./message-upload";
export default {
name: "ProjectMessage",
components: {MessageView, ScrollerY, DragInput},
components: {MessageUpload, MessageView, ScrollerY, DragInput},
data() {
return {
autoBottom: true,
autoInterval: null,
memberShowAll: false,
dialogId: 0,
dialogDrag: false,
msgText: '',
msgLength: 0,
msgNew: 0,
}
},
mounted() {
this.groupChatGoAuto();
this.groupChatGoBottom();
this.goBottom();
this.autoInterval = setInterval(this.goBottom, 200)
},
beforeDestroy() {
clearInterval(this.autoInterval)
},
computed: {
@ -158,21 +240,31 @@ export default {
dialogId(id) {
this.$store.commit('getDialogMsg', id);
},
dialogList(list) {
if (!this.autoBottom) {
let length = list.length - this.msgLength;
if (length > 0) {
this.msgNew+= length;
}
}
this.msgLength = list.length;
}
},
methods: {
sendMsg() {
let mid = $A.randomString(16);
let tempId = $A.randomString(16);
this.dialogList.push({
id: mid,
userid: this.userId,
id: tempId,
type: 'text',
userid: this.userId,
msg: {
text: this.msgText,
},
});
this.groupChatGoBottom(true);
this.goBottom();
//
$A.apiAjax({
url: 'dialog/msg/sendtext',
@ -181,24 +273,26 @@ export default {
text: this.msgText,
},
error:() => {
this.dialogList = this.dialogList.filter(({id}) => id != mid);
this.$store.commit('spliceDialogMsg', {id: tempId});
},
success: ({ret, data, msg}) => {
if (ret === 1) {
if (!this.dialogList.find(({id}) => id == data.id)) {
let index = this.dialogList.findIndex(({id}) => id == mid);
if (index > -1) this.dialogList.splice(index, 1, data);
return;
}
if (ret !== 1) {
$A.modalWarning({
title: '发送失败',
content: msg
});
}
this.dialogList = this.dialogList.filter(({id}) => id != mid);
this.$store.commit('spliceDialogMsg', {
id: tempId,
data: ret === 1 ? data : null
});
}
});
//
this.msgText = '';
},
groupKeydown(e) {
chatKeydown(e) {
if (e.keyCode === 13) {
if (e.shiftKey) {
return;
@ -208,46 +302,86 @@ export default {
}
},
groupPasteDrag(e, type) {
pasteDrag(e, type) {
const files = type === 'drag' ? e.dataTransfer.files : e.clipboardData.files;
const postFiles = Array.prototype.slice.call(files);
if (postFiles.length > 0) {
e.preventDefault();
postFiles.forEach((file) => {
//
this.$refs.chatUpload.upload(file);
});
}
},
groupChatScroll(res) {
if (res.directionreal === 'up') {
if (res.scrollE < 10) {
this.autoBottom = true;
}
} else if (res.directionreal === 'down') {
this.autoBottom = false;
chatDragOver(show) {
let random = (this.__dialogDrag = $A.randomString(8));
if (!show) {
setTimeout(() => {
if (random === this.__dialogDrag) {
this.dialogDrag = show;
}
}, 150);
} else {
this.dialogDrag = show;
}
},
groupChatGoAuto() {
clearTimeout(this.groupChatGoTimeout);
this.groupChatGoTimeout = setTimeout(() => {
if (this.autoBottom) {
this.groupChatGoBottom(true);
}
this.groupChatGoAuto();
}, 1000);
chatPasteDrag(e, type) {
this.dialogDrag = false;
const files = type === 'drag' ? e.dataTransfer.files : e.clipboardData.files;
const postFiles = Array.prototype.slice.call(files);
if (postFiles.length > 0) {
e.preventDefault();
postFiles.forEach((file) => {
this.$refs.chatUpload.upload(file);
});
}
},
groupChatGoBottom(animation = false) {
this.$nextTick(() => {
if (typeof this.$refs.groupChat !== "undefined") {
if (this.$refs.groupChat.getScrollInfo().scrollE > 0) {
this.$refs.groupChat.scrollTo(this.$refs.manageList.clientHeight, animation);
chatFile(type, file) {
switch (type) {
case 'progress':
this.dialogList.push({
id: file.tempId,
type: 'loading',
userid: this.userId,
msg: { },
});
break;
case 'error':
this.$store.commit('spliceDialogMsg', {id: file.tempId});
break;
case 'success':
this.$store.commit('spliceDialogMsg', {id: file.tempId, data: file.data});
break;
}
},
chatScroll(res) {
switch (res.directionreal) {
case 'up':
if (res.scrollE < 10) {
this.autoBottom = true;
}
this.autoBottom = true;
}
});
break;
case 'down':
this.autoBottom = false;
break;
}
},
goBottom() {
if (this.autoBottom && this.$refs.bottom) {
this.$refs.bottom.scrollIntoView(false);
}
},
goNewBottom() {
this.msgNew = 0;
this.autoBottom = true;
this.goBottom();
},
formatTime(date) {

View File

@ -20,7 +20,7 @@
.project-message {
position: relative;
height: 100%;
width: 30%;
width: 35%;
min-width: 320px;
max-width: 520px;
flex-shrink: 0;

View File

@ -54,9 +54,9 @@ export default [
]
},
{
name: 'manage-project-detail',
name: 'manage-project',
path: 'project/:id',
component: () => import('./pages/manage/project-detail.vue'),
component: () => import('./pages/manage/project.vue'),
},
]
},

View File

@ -192,6 +192,80 @@ export default {
});
},
/**
* 获取对话消息
* @param state
* @param dialog_id
*/
getDialogMsg(state, dialog_id) {
if (state.method.runNum(dialog_id) === 0) {
return;
}
if (state.method.isArray(state.cacheDialog[dialog_id])) {
state.dialogList = state.cacheDialog[dialog_id]
} else {
state.dialogList = [];
}
state.dialogId = dialog_id;
//
if (state.cacheDialog[dialog_id + "::load"]) {
return;
}
state.cacheDialog[dialog_id + "::load"] = true;
//
state.dialogLoad++;
$A.apiAjax({
url: 'dialog/msg/lists',
data: {
dialog_id: dialog_id,
},
complete: () => {
state.dialogLoad--;
state.cacheDialog[dialog_id + "::load"] = false;
},
success: ({ret, data, msg}) => {
if (ret === 1) {
state.cacheDialog[dialog_id] = data.data.reverse();
if (state.dialogId === dialog_id) {
state.cacheDialog[dialog_id].forEach((item) => {
let index = state.dialogList.findIndex(({id}) => id === item.id);
if (index === -1) {
state.dialogList.push(item);
} else {
state.dialogList.splice(index, 1, item);
}
})
}
}
}
});
},
/**
* 根据消息ID 删除 替换 对话数据
* @param state
* @param params {id, data}
*/
spliceDialogMsg(state, params) {
let {id, data} = params;
if (!id) {
return;
}
if (state.method.isJson(data)) {
if (data.id && state.dialogList.find(m => m.id == data.id)) {
data = null;
}
}
let index = state.dialogList.findIndex(m => m.id == id);
if (index > -1) {
if (data) {
state.dialogList.splice(index, 1, state.method.cloneJSON(data));
} else {
state.dialogList.splice(index, 1);
}
}
},
/**
* 初始化 websocket
* @param state
@ -291,7 +365,7 @@ export default {
return;
}
const {type, data, callback} = params;
let msgId = params.msgId || 0;
let msgId = params.msgId;
if (!state.ws) {
typeof callback === "function" && callback(null, false)
return;
@ -332,53 +406,4 @@ export default {
wsClose(state) {
state.ws && state.ws.close();
},
/**
* 获取对话消息
* @param state
* @param dialog_id
*/
getDialogMsg(state, dialog_id) {
if (state.method.runNum(dialog_id) === 0) {
return;
}
if (state.method.isArray(state.cacheDialog[dialog_id])) {
state.dialogList = state.cacheDialog[dialog_id]
} else {
state.dialogList = [];
}
state.dialogId = dialog_id;
//
if (state.cacheDialog[dialog_id + "::load"]) {
return;
}
state.cacheDialog[dialog_id + "::load"] = true;
//
state.dialogLoad++;
$A.apiAjax({
url: 'dialog/msg/lists',
data: {
dialog_id: dialog_id,
},
complete: () => {
state.dialogLoad--;
state.cacheDialog[dialog_id + "::load"] = false;
},
success: ({ret, data, msg}) => {
if (ret === 1) {
state.cacheDialog[dialog_id] = data.data.reverse();
if (state.dialogId === dialog_id) {
state.cacheDialog[dialog_id].forEach((item) => {
let index = state.dialogList.findIndex(({id}) => id === item.id);
if (index === -1) {
state.dialogList.push(item);
} else {
state.dialogList.splice(index, 1, item);
}
})
}
}
}
});
}
}

View File

@ -113,6 +113,13 @@ body {
}
}
}
.ivu-modal-confirm {
.ivu-modal-confirm-body {
> div {
word-break: break-all;
}
}
}
}
.modal-info-show {
@ -464,24 +471,37 @@ body {
.common-avatar {
position: relative;
.common-avatar-text {
background-color: #87d068;
}
&:before {
content: "";
position: absolute;
right: 0;
bottom: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background-color: #ff0000;
border: 1px solid #ffffff;
z-index: 1;
}
&.online {
&:before {
background-color: #87d068;
.avatar-wrapper {
display: flex;
align-items: center;
.avatar-box {
position: relative;
.avatar-text {
background-color: #87d068;
}
&:before {
content: "";
position: absolute;
right: 0;
bottom: 0;
width: 9px;
height: 9px;
border-radius: 50%;
background-color: #ff0000;
border: 1px solid #ffffff;
z-index: 1;
}
&.online {
&:before {
background-color: #87d068;
}
}
}
.avatar-name {
padding-left: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
@ -693,16 +713,72 @@ body {
background-color: #F4F5F7;
padding: 8px;
border-radius: 6px 6px 6px 0;
}
.message-unknown {
text-decoration: underline;
&.loading {
display: flex;
.common-loading {
width: 20px;
height: 20px;
margin: 4px;
}
}
&.file {
display: inline-block;
.file-box {
background-color: #ffffff;
display: flex;
align-items: center;
padding: 10px 14px;
border-radius: 3px;
width: 220px;
.file-thumb {
width: 36px;
}
.file-info {
margin-left: 12px;
display: flex;
flex-direction: column;
justify-content: center;
.file-name {
color: #333333;
font-size: 14px;
line-height: 18px;
word-break: break-all;
text-overflow: ellipsis;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.file-size {
padding-top: 4px;
color: #666666;
font-size: 14px;
}
}
}
}
&.img {
padding: 0;
display: flex;
max-width: 220px;
max-height: 220px;
border-radius: 6px;
background-color: transparent;
overflow: hidden;
.file-img {
display: flex;
}
}
&.unknown {
text-decoration: underline;
}
}
.message-foot {
display: flex;
align-items: center;
padding-top: 3px;
padding-top: 4px;
height: 21px;
line-height: 21px;
line-height: 1;
.common-loading {
margin: 0 2px;
width: 10px;
@ -712,14 +788,18 @@ body {
color: #bbbbbb;
font-size: 12px;
}
.done,
.done-all {
.done {
display: none;
margin-left: 4px;
transform: scale(0.9);
font-size: 12px;
color: #87d068;
}
.percent {
display: none;
margin-left: 4px;
cursor: pointer;
}
}
}
&.loading {
@ -738,6 +818,11 @@ body {
transform: translate(-50%, -50%);
color: #999999;
}
&.bottom {
height: 0;
margin: 0;
padding: 0;
}
&.self {
flex-direction: row-reverse;
.message-view {
@ -747,10 +832,19 @@ body {
color: #ffffff;
background-color: #2d8cf0;
border-radius: 6px 6px 0 6px;
&.file {
background-color: #F4F5F7;
}
&.img {
border-radius: 6px;
background-color: transparent;
}
}
.message-foot {
.done,
.done-all {
.done {
display: inline-block;
}
.percent {
display: inline-block;
}
}
@ -760,3 +854,46 @@ body {
}
}
}
.message-readbox {
display: flex;
position: relative;
.read,
.unread {
flex: 1;
max-height: 300px;
overflow: auto;
> li {
list-style: none;
margin-bottom: 12px;
.common-avatar {
width: 100%;
}
&:last-child {
margin-bottom: 6px;
}
&.read-title {
> em {
font-size: 18px;
font-weight: 600;
font-style: normal;
padding-right: 6px;
}
}
}
}
.unread {
> li {
padding-left: 16px;
}
}
&:before {
content: "";
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1px;
background-color: #F4F4F5;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 678 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B