From 11a86c0cdd381412ab8ade755c20a036a1d6b8f7 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Sat, 5 Jun 2021 23:14:40 +0800 Subject: [PATCH] no message --- app/Http/Controllers/Api/DialogController.php | 129 ++++++++++ app/Http/Middleware/VerifyCsrfToken.php | 3 + app/Models/WebSocketDialogMsg.php | 110 ++++++--- app/Models/WebSocketDialogMsgRead.php | 29 +++ app/Module/Base.php | 33 ++- app/Services/WebSocketService.php | 12 +- app/Tasks/WebSocketDialogMsgTask.php | 71 ++++++ resources/assets/js/components/UserAvatar.vue | 15 +- resources/assets/js/components/WCircle.vue | 125 ++++++++++ resources/assets/js/functions/common.js | 29 ++- resources/assets/js/functions/web.js | 1 + resources/assets/js/pages/manage.vue | 8 +- .../manage/components/message-upload.vue | 99 ++++++++ .../pages/manage/components/message-view.vue | 128 ++++++++-- .../pages/manage/components/project-list.vue | 29 ++- .../manage/components/project-message.vue | 226 ++++++++++++++---- .../{project-detail.vue => project.vue} | 2 +- resources/assets/js/routes.js | 4 +- resources/assets/js/store/mutations.js | 125 ++++++---- resources/assets/sass/main.scss | 191 ++++++++++++--- .../assets/statics/public/images/ext/ai.png | Bin 0 -> 575 bytes .../assets/statics/public/images/ext/avi.png | Bin 0 -> 633 bytes .../assets/statics/public/images/ext/bmp.png | Bin 0 -> 644 bytes .../assets/statics/public/images/ext/cdr.png | Bin 0 -> 642 bytes .../assets/statics/public/images/ext/doc.png | Bin 0 -> 621 bytes .../statics/public/images/ext/document.png | Bin 0 -> 505 bytes .../assets/statics/public/images/ext/eps.png | Bin 0 -> 610 bytes .../assets/statics/public/images/ext/exe.png | Bin 0 -> 589 bytes .../assets/statics/public/images/ext/file.png | Bin 0 -> 481 bytes .../assets/statics/public/images/ext/flow.png | Bin 0 -> 526 bytes .../statics/public/images/ext/folder.png | Bin 0 -> 682 bytes .../assets/statics/public/images/ext/gif.png | Bin 0 -> 553 bytes .../assets/statics/public/images/ext/html.png | Bin 0 -> 576 bytes .../assets/statics/public/images/ext/mind.png | Bin 0 -> 2432 bytes .../assets/statics/public/images/ext/mov.png | Bin 0 -> 701 bytes .../assets/statics/public/images/ext/mp3.png | Bin 0 -> 655 bytes .../assets/statics/public/images/ext/mp4.png | Bin 0 -> 619 bytes .../assets/statics/public/images/ext/pdf.png | Bin 0 -> 585 bytes .../assets/statics/public/images/ext/ppt.png | Bin 0 -> 538 bytes .../assets/statics/public/images/ext/pr.png | Bin 0 -> 558 bytes .../assets/statics/public/images/ext/psd.png | Bin 0 -> 637 bytes .../assets/statics/public/images/ext/rar.png | Bin 0 -> 644 bytes .../statics/public/images/ext/sheet.png | Bin 0 -> 587 bytes .../assets/statics/public/images/ext/svg.png | Bin 0 -> 678 bytes .../assets/statics/public/images/ext/tif.png | Bin 0 -> 450 bytes .../assets/statics/public/images/ext/txt.png | Bin 0 -> 574 bytes .../assets/statics/public/images/ext/xls.png | Bin 0 -> 637 bytes .../assets/statics/public/images/ext/zip.png | Bin 0 -> 553 bytes 48 files changed, 1159 insertions(+), 210 deletions(-) create mode 100644 app/Models/WebSocketDialogMsgRead.php create mode 100644 app/Tasks/WebSocketDialogMsgTask.php create mode 100644 resources/assets/js/components/WCircle.vue create mode 100644 resources/assets/js/pages/manage/components/message-upload.vue rename resources/assets/js/pages/manage/{project-detail.vue => project.vue} (98%) create mode 100644 resources/assets/statics/public/images/ext/ai.png create mode 100644 resources/assets/statics/public/images/ext/avi.png create mode 100644 resources/assets/statics/public/images/ext/bmp.png create mode 100644 resources/assets/statics/public/images/ext/cdr.png create mode 100644 resources/assets/statics/public/images/ext/doc.png create mode 100644 resources/assets/statics/public/images/ext/document.png create mode 100644 resources/assets/statics/public/images/ext/eps.png create mode 100644 resources/assets/statics/public/images/ext/exe.png create mode 100644 resources/assets/statics/public/images/ext/file.png create mode 100644 resources/assets/statics/public/images/ext/flow.png create mode 100644 resources/assets/statics/public/images/ext/folder.png create mode 100644 resources/assets/statics/public/images/ext/gif.png create mode 100644 resources/assets/statics/public/images/ext/html.png create mode 100644 resources/assets/statics/public/images/ext/mind.png create mode 100644 resources/assets/statics/public/images/ext/mov.png create mode 100644 resources/assets/statics/public/images/ext/mp3.png create mode 100644 resources/assets/statics/public/images/ext/mp4.png create mode 100644 resources/assets/statics/public/images/ext/pdf.png create mode 100644 resources/assets/statics/public/images/ext/ppt.png create mode 100644 resources/assets/statics/public/images/ext/pr.png create mode 100644 resources/assets/statics/public/images/ext/psd.png create mode 100644 resources/assets/statics/public/images/ext/rar.png create mode 100644 resources/assets/statics/public/images/ext/sheet.png create mode 100644 resources/assets/statics/public/images/ext/svg.png create mode 100644 resources/assets/statics/public/images/ext/tif.png create mode 100644 resources/assets/statics/public/images/ext/txt.png create mode 100644 resources/assets/statics/public/images/ext/xls.png create mode 100644 resources/assets/statics/public/images/ext/zip.png diff --git a/app/Http/Controllers/Api/DialogController.php b/app/Http/Controllers/Api/DialogController.php index 1129aee4..e2c8ce05 100755 --- a/app/Http/Controllers/Api/DialogController.php +++ b/app/Http/Controllers/Api/DialogController.php @@ -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 ?: []); + } } diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index cd3a95c8..5e0bb090 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -23,5 +23,8 @@ class VerifyCsrfToken extends Middleware // 添加任务 'api/project/task/add/', + + // 聊天发文件 + 'api/dialog/msg/sendfile/', ]; } diff --git a/app/Models/WebSocketDialogMsg.php b/app/Models/WebSocketDialogMsg.php index 5b82717a..78a235d1 100644 --- a/app/Models/WebSocketDialogMsg.php +++ b/app/Models/WebSocketDialogMsg.php @@ -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); }); } diff --git a/app/Models/WebSocketDialogMsgRead.php b/app/Models/WebSocketDialogMsgRead.php new file mode 100644 index 00000000..caa4ff95 --- /dev/null +++ b/app/Models/WebSocketDialogMsgRead.php @@ -0,0 +1,29 @@ +timestamps = false; + } +} diff --git a/app/Module/Base.php b/app/Module/Base.php index e60e6a1b..cb5ba5b3 100755 --- a/app/Module/Base.php +++ b/app/Module/Base.php @@ -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"; } } diff --git a/app/Services/WebSocketService.php b/app/Services/WebSocketService.php index f6e5daa8..8abcd4fd 100644 --- a/app/Services/WebSocketService.php +++ b/app/Services/WebSocketService.php @@ -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')); + } } diff --git a/app/Tasks/WebSocketDialogMsgTask.php b/app/Tasks/WebSocketDialogMsgTask.php new file mode 100644 index 00000000..43763185 --- /dev/null +++ b/app/Tasks/WebSocketDialogMsgTask.php @@ -0,0 +1,71 @@ +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, + ] + ]); + } + } +} diff --git a/resources/assets/js/components/UserAvatar.vue b/resources/assets/js/components/UserAvatar.vue index 07fb1d6b..f9d0c689 100755 --- a/resources/assets/js/components/UserAvatar.vue +++ b/resources/assets/js/components/UserAvatar.vue @@ -1,14 +1,19 @@ @@ -24,6 +29,10 @@ type: [String, Number], default: 'default' }, + showName: { + type: Boolean, + default: false + }, transfer: { type: Boolean, default: true diff --git a/resources/assets/js/components/WCircle.vue b/resources/assets/js/components/WCircle.vue new file mode 100644 index 00000000..5547ca1c --- /dev/null +++ b/resources/assets/js/components/WCircle.vue @@ -0,0 +1,125 @@ + + + + diff --git a/resources/assets/js/functions/common.js b/resources/assets/js/functions/common.js index 38fc36cc..c5134b97 100755 --- a/resources/assets/js/functions/common.js +++ b/resources/assets/js/functions/common.js @@ -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; }, /** diff --git a/resources/assets/js/functions/web.js b/resources/assets/js/functions/web.js index 03f953a1..c76a7ba8 100755 --- a/resources/assets/js/functions/web.js +++ b/resources/assets/js/functions/web.js @@ -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; diff --git a/resources/assets/js/pages/manage.vue b/resources/assets/js/pages/manage.vue index 69982c8a..4870ee8b 100644 --- a/resources/assets/js/pages/manage.vue +++ b/resources/assets/js/pages/manage.vue @@ -8,15 +8,15 @@