feat: 新增自定义创建群聊功能

This commit is contained in:
kuaifan 2022-04-14 22:00:45 +08:00
parent ac1e7bc186
commit fcd6b1ddec
16 changed files with 688 additions and 52 deletions

View File

@ -550,4 +550,156 @@ class DialogController extends AbstractController
'mark_unread' => $dialogUser->mark_unread,
]);
}
/**
* @api {get} api/dialog/group/add 15. 新增群聊
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName group__add
*
* @apiParam {String} chat_name 群名
* @apiParam {Array} userids 群成员,格式: [userid1, userid2, userid3]
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function group__add()
{
$user = User::auth();
//
$chatName = trim(Request::input('chat_name'));
$userids = Request::input('userids');
//
if (!is_array($userids)) {
return Base::retError('请选择群成员');
}
$userids = array_merge([$user->userid], $userids);
$userids = array_values(array_filter(array_unique($userids)));
if (count($userids) < 2) {
return Base::retError('群成员至少2人');
}
//
if (empty($chatName)) {
$array = [];
foreach ($userids as $userid) {
$array[] = User::userid2nickname($userid);
if (count($array) >= 8 || strlen(implode(", ", $array)) > 200) {
$array[] = "...";
break;
}
}
$chatName = implode(", ", $array);
}
$dialog = WebSocketDialog::createGroup($chatName, $userids, 'user', $user->userid);
if (empty($dialog)) {
return Base::retError('创建群聊失败');
}
return Base::retSuccess('创建成功', $dialog);
}
/**
* @api {get} api/dialog/group/user 16. 获取群成员
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName group__user
*
* @apiParam {Number} dialog_id 会话ID
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function group__user()
{
User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
//
return Base::retSuccess('success', $dialog->dialogUser);
}
/**
* @api {get} api/dialog/group/adduser 16. 添加群成员
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName group__adduser
*
* @apiParam {Number} dialog_id 会话ID
* @apiParam {Array} userids 新增的群成员,格式: [userid1, userid2, userid3]
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function group__adduser()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$userids = Request::input('userids');
//
if (!is_array($userids)) {
return Base::retError('请选择群成员');
}
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
if ($dialog->owner_id != $user->userid) {
return Base::retError('仅限群主操作');
}
//
$dialog->joinGroup($userids);
return Base::retSuccess('添加成功');
}
/**
* @api {get} api/dialog/group/deluser 16. 移出(退出)群成员
*
* @apiDescription 需要token身份
* @apiVersion 1.0.0
* @apiGroup dialog
* @apiName group__adduser
*
* @apiParam {Number} dialog_id 会话ID
* @apiParam {Array} userids 移出的群成员,格式: [userid1, userid2, userid3]
* - 留空表示自己退出
* - 有值表示移出,仅限群主操作
*
* @apiSuccess {Number} ret 返回状态码1正确、0错误
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据
*/
public function group__deluser()
{
$user = User::auth();
//
$dialog_id = intval(Request::input('dialog_id'));
$userids = Request::input('userids');
//
$type = 'remove';
if (empty($userids)) {
$type = 'exit';
$userids = [$user->userid];
}
//
if (!is_array($userids)) {
return Base::retError('请选择群成员');
}
//
$dialog = WebSocketDialog::checkDialog($dialog_id);
if ($type === 'remove' && $dialog->owner_id != $user->userid) {
return Base::retError('仅限群主操作');
}
//
$dialog->exitGroup($userids);
return Base::retSuccess($type === 'remove' ? '移出成功' : '退出成功');
}
}

View File

@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @property string|null $group_type 聊天室类型
* @property string|null $name 对话名称
* @property string|null $last_at 最后消息时间
* @property int|null $owner_id 群主用户ID
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
* @property \Illuminate\Support\Carbon|null $deleted_at
@ -29,6 +30,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereLastAt($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereName($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereOwnerId($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereType($value)
* @method static \Illuminate\Database\Eloquent\Builder|WebSocketDialog whereUpdatedAt($value)
* @method static \Illuminate\Database\Query\Builder|WebSocketDialog withTrashed()
@ -74,6 +76,55 @@ class WebSocketDialog extends AbstractModel
return true;
}
/**
* 加入聊天室
* @param int|array $userid 加入的会员ID或会员ID组
* @return bool
*/
public function joinGroup($userid)
{
if ($this->type !== 'group') {
return false;
}
AbstractModel::transaction(function () use ($userid) {
foreach (is_array($userid) ? $userid : [$userid] as $value) {
if ($value > 0) {
WebSocketDialogUser::updateInsert([
'dialog_id' => $this->id,
'userid' => $value,
]);
}
}
});
return true;
}
/**
* 退出聊天室
* @param int|array $userid 加入的会员ID或会员ID组
* @return bool
*/
public function exitGroup($userid)
{
$builder = WebSocketDialogUser::whereDialogId($this->id);
if (is_array($userid)) {
$builder->whereIn('userid', $userid);
} else {
$builder->whereUserid($userid);
}
$builder->chunkById(100, function($list) {
/** @var WebSocketDialogUser $item */
foreach ($list as $item) {
if ($item->userid == $this->owner_id) {
// 群主不可退出
continue;
}
$item->delete();
}
});
return true;
}
/**
* 获取对话(同时检验对话身份)
* @param $dialog_id
@ -148,17 +199,20 @@ class WebSocketDialog extends AbstractModel
/**
* 创建聊天室
* @param string $name 聊天室名称
* @param int|array $userid 加入的会员ID或会员ID组
* @param int|array $userid 加入的会员ID()
* @param string $group_type 聊天室类型
* @param int $owner_id 群主会员ID
* @return self|null
*/
public static function createGroup($name, $userid, $group_type = '')
public static function createGroup($name, $userid, $group_type = '', $owner_id = 0)
{
return AbstractModel::transaction(function () use ($userid, $group_type, $name) {
return AbstractModel::transaction(function () use ($owner_id, $userid, $group_type, $name) {
$dialog = self::createInstance([
'type' => 'group',
'name' => $name ?: '',
'group_type' => $group_type,
'owner_id' => $owner_id,
'last_at' => $group_type === 'user' ? Carbon::now() : null,
]);
$dialog->save();
foreach (is_array($userid) ? $userid : [$userid] as $value) {
@ -173,47 +227,6 @@ class WebSocketDialog extends AbstractModel
});
}
/**
* 加入聊天室
* @param int $dialog_id 会话ID 聊天室ID
* @param int|array $userid 加入的会员ID或会员ID组
* @return bool
*/
public static function joinGroup($dialog_id, $userid)
{
$dialog = self::whereId($dialog_id)->whereType('group')->first();
if (empty($dialog)) {
return false;
}
AbstractModel::transaction(function () use ($dialog, $userid) {
foreach (is_array($userid) ? $userid : [$userid] as $value) {
if ($value > 0) {
WebSocketDialogUser::createInstance([
'dialog_id' => $dialog->id,
'userid' => $value,
])->save();
}
}
});
return true;
}
/**
* 退出聊天室
* @param int $dialog_id 会话ID 聊天室ID
* @param int|array $userid 加入的会员ID或会员ID组
* @return bool
*/
public static function exitGroup($dialog_id, $userid)
{
if (is_array($userid)) {
WebSocketDialogUser::whereDialogId($dialog_id)->whereIn('userid', $userid)->delete();
} else {
WebSocketDialogUser::whereDialogId($dialog_id)->whereUserid($userid)->delete();
}
return true;
}
/**
* 获取会员对话(没有自动创建)
* @param int $userid 会员ID

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddWebSocketDialogsOwnerId extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('web_socket_dialogs', function (Blueprint $table) {
if (!Schema::hasColumn('web_socket_dialogs', 'owner_id')) {
$table->bigInteger('owner_id')->nullable()->default(0)->after('last_at')->comment('群主用户ID');
}
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('web_socket_dialogs', function (Blueprint $table) {
$table->dropColumn("owner_id");
});
}
}

View File

@ -19,7 +19,7 @@
</div>
<template v-else>
<div class="quick-text"><slot></slot></div>
<Icon class="quick-icon" type="ios-create-outline" @click.stop="onEdit"/>
<Icon v-if="!disabled" class="quick-icon" type="ios-create-outline" @click.stop="onEdit"/>
</template>
</div>
</template>
@ -49,6 +49,10 @@ export default {
type: Boolean,
default: true
},
disabled: {
type: Boolean,
default: false
},
},
data() {

View File

@ -75,6 +75,11 @@
type: Number,
default: 600
},
userResult: {
type: Function,
default: () => {
}
}
},
data() {
return {
@ -205,6 +210,7 @@
//
}
this.user = info;
this.userResult(info);
},
onError() {

View File

@ -786,7 +786,7 @@ export default {
let path = 'project/' + item.id;
let openMenu = this.openMenu[item.id];
return {
"active": $A.leftExists(this.routePath, '/manage/' + path),
"active": this.routePath === '/manage/' + path,
"open-menu": openMenu === true,
"operate": item.id == this.topOperateItem.id && this.topOperateVisible
};

View File

@ -0,0 +1,230 @@
<template>
<div class="dialog-group-info">
<div class="group-info-title">{{$L('群名')}}</div>
<div class="group-info-value">
<QuickEdit :value="dialogData.name" :disabled="dialogData.owner_id != userId" @on-update="updateName">{{dialogData.name}}</QuickEdit>
</div>
<div class="group-info-title">{{$L('群类型')}}</div>
<div class="group-info-value">{{ $L(groupType) }}</div>
<div class="group-info-search">
<Input
prefix="ios-search"
v-model="searchKey"
:placeholder="$L('搜索')"
clearable/>
</div>
<div v-if="dialogData.owner_id == userId" @click="openAdd" class="group-info-button">
<Icon type="md-add" />
<span>{{ $L("添加成员") }}</span>
</div>
<div v-else @click="onExit" class="group-info-button">
<Icon type="md-exit" />
<span>{{ $L("退出群聊") }}</span>
</div>
<div class="group-info-user">
<ul>
<li v-for="(item, index) in userList" :key="index">
<UserAvatar :userid="item.userid" :size="32" :user-result="userResult" showName tooltipDisabled/>
<Tag v-if="item.userid === dialogData.owner_id" color="primary">{{ $L("群主") }}</Tag>
<Icon v-else-if="dialogData.owner_id == userId" class="user-exit" type="md-exit" @click="onExit(item)"/>
</li>
<li v-if="userList.length === 0" class="no">
<Loading v-if="loadIng > 0"/>
<span v-else>{{$L('没有符合条件的数据')}}</span>
</li>
</ul>
</div>
<!--添加成员-->
<Modal
v-model="addShow"
:title="$L('添加群成员')"
:mask-closable="false">
<Form :model="addData" label-width="auto" @submit.native.prevent>
<FormItem prop="userids" :label="$L('新增成员')">
<UserInput v-model="addData.userids" :disabledChoice="addData.disabledChoice" :multiple-max="100" :placeholder="$L('选择项目成员')"/>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="addShow=false">{{$L('取消')}}</Button>
<Button type="primary" :loading="addLoad > 0" @click="onAdd">{{$L('确定添加')}}</Button>
</div>
</Modal>
</div>
</template>
<script>
import {mapState} from "vuex";
import UserInput from "../../../components/UserInput";
export default {
name: "DialogGroupInfo",
components: {UserInput},
props: {
dialogId: {
type: Number,
default: 0
},
},
data() {
return {
searchKey: '',
loadIng: 0,
dialogUser: [],
addShow: false,
addData: {},
addLoad: 0,
}
},
computed: {
...mapState(['cacheDialogs', 'userId']),
dialogData() {
return this.cacheDialogs.find(({id}) => id == this.dialogId) || {};
},
groupType() {
const {group_type} = this.dialogData
if (group_type === 'project') return '项目群组'
if (group_type === 'task') return '任务群组'
if (group_type === 'user') return '个人群组'
return '未知'
},
userList() {
const {dialogUser, searchKey, dialogData} = this;
const list = dialogUser.filter(item => {
if (searchKey && item.nickname) {
if (!$A.strExists(item.nickname, searchKey) && !$A.strExists(item.email, searchKey)) {
return false;
}
}
return true;
})
return list.sort((a, b) => {
if (a.userid === dialogData.owner_id || b.userid === dialogData.owner_id) {
return (a.userid === dialogData.owner_id ? 0 : 1) - (b.userid === dialogData.owner_id ? 0 : 1);
}
return $A.Date(a.created_at) - $A.Date(b.created_at);
})
}
},
watch: {
dialogId: {
handler() {
this.getDialogUser();
},
immediate: true
}
},
methods: {
updateName() {
},
getDialogUser() {
if (this.dialogId <= 0) {
return
}
this.loadIng++;
this.$store.dispatch("call", {
url: 'dialog/group/user',
data: {
dialog_id: this.dialogId
}
}).then(({data}) => {
this.dialogUser = data;
this.$store.dispatch("saveDialog", {
id: this.dialogId,
people: data.length
});
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.loadIng--;
});
},
userResult(user) {
let index = this.dialogUser.findIndex(({userid}) => userid === user.userid);
if (index > -1) {
this.dialogUser.splice(index, 1, Object.assign(user, {
id: this.dialogUser[index].id,
created_at: this.dialogUser[index].created_at
}))
}
},
openAdd() {
this.addData = {
dialog_id: this.dialogId,
userids: [],
disabledChoice: this.dialogUser.map(item => item.userid)
};
this.addShow = true;
},
onAdd() {
this.addLoad++;
this.$store.dispatch("call", {
url: 'dialog/group/adduser',
data: this.addData
}).then(({msg}) => {
$A.messageSuccess(msg);
this.addShow = false;
this.addData = {};
this.getDialogUser();
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.addLoad--;
});
},
onExit(item) {
let content = "你确定要退出群聊吗?"
let userids = [];
if ($A.isJson(item)) {
content = `你确定要将【${item.nickname}】移出群聊吗?`
userids = [item.userid];
}
$A.modalConfirm({
content,
loading: true,
onOk: () => {
this.$store.dispatch("call", {
url: 'dialog/group/deluser',
data: {
dialog_id: this.dialogId,
userids,
}
}).then(({msg}) => {
this.$Modal.remove();
$A.messageSuccess(msg);
if (userids === "my") {
this.getDialogUser();
} else {
this.$store.dispatch("forgetDialog", this.dialogId);
this.goForward({name: 'manage-messenger'});
}
}).catch(({msg}) => {
$A.modalError(msg, 301);
this.$Modal.remove();
});
},
});
}
}
}
</script>

View File

@ -34,6 +34,14 @@
</div>
</template>
</div>
<template v-if="dialogData.type === 'group'">
<ETooltip v-if="dialogData.group_type === 'user'" placement="top" :content="$L('群设置')">
<i class="taskfont dialog-create" @click="groupInfoShow = true">&#xe6e9;</i>
</ETooltip>
</template>
<ETooltip v-else-if="dialogData.type === 'user'" placement="top" :content="$L('创建群组')">
<i class="taskfont dialog-create" @click="openCreateGroup">&#xe646;</i>
</ETooltip>
</div>
</slot>
<ScrollerY
@ -118,6 +126,33 @@
</template>
</div>
</Modal>
<!--创建群聊-->
<Modal
v-model="createGroupShow"
:title="$L('创建群聊')"
:mask-closable="false">
<Form :model="createGroupData" label-width="auto" @submit.native.prevent>
<FormItem prop="userids" :label="$L('群成员')">
<UserInput v-model="createGroupData.userids" :uncancelable="createGroupData.uncancelable" :multiple-max="100" :placeholder="$L('选择项目成员')"/>
</FormItem>
<FormItem prop="chat_name" :label="$L('群名称')">
<Input v-model="createGroupData.chat_name" :placeholder="$L('输入群名称(选填)')"/>
</FormItem>
</Form>
<div slot="footer" class="adaption">
<Button type="default" @click="createGroupShow=false">{{$L('取消')}}</Button>
<Button type="primary" :loading="createGroupLoad > 0" @click="onCreateGroup">{{$L('创建')}}</Button>
</div>
</Modal>
<!--群设置-->
<DrawerOverlay
v-model="groupInfoShow"
placement="right"
:size="380">
<DialogGroupInfo v-if="groupInfoShow" :dialogId="dialogId"/>
</DrawerOverlay>
</div>
</template>
@ -128,10 +163,13 @@ import {mapState} from "vuex";
import DialogView from "./DialogView";
import DialogUpload from "./DialogUpload";
import {Store} from "le5le-store";
import UserInput from "../../../components/UserInput";
import DrawerOverlay from "../../../components/DrawerOverlay";
import DialogGroupInfo from "./DialogGroupInfo";
export default {
name: "DialogWrapper",
components: {DialogUpload, DialogView, ScrollerY, DragInput},
components: {DialogGroupInfo, DrawerOverlay, UserInput, DialogUpload, DialogView, ScrollerY, DragInput},
props: {
dialogId: {
type: Number,
@ -159,6 +197,12 @@ export default {
pasteShow: false,
pasteFile: [],
pasteItem: [],
createGroupShow: false,
createGroupData: {},
createGroupLoad: 0,
groupInfoShow: false,
}
},
@ -500,6 +544,31 @@ export default {
})
}
},
openCreateGroup() {
this.createGroupData = {
userids: this.dialogData.dialog_user ? [this.userId, this.dialogData.dialog_user.userid] : [this.userId],
uncancelable: [this.userId]
};
this.createGroupShow = true;
},
onCreateGroup() {
this.createGroupLoad++;
this.$store.dispatch("call", {
url: 'dialog/group/add',
data: this.createGroupData
}).then(({data, msg}) => {
$A.messageSuccess(msg);
this.createGroupShow = false;
this.createGroupData = {};
this.goForward({name: 'manage-messenger', params: {dialogId: data.id}});
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.createGroupLoad--;
});
},
}
}
</script>

View File

@ -159,14 +159,14 @@
<Radio label="replace">{{$L('流转模式')}}</Radio>
<Radio label="merge">{{$L('剔除模式')}}</Radio>
</RadioGroup>
<div v-if="userData.usertype=='replace'" class="form-tip">{{$L('流转到此状态时改变任务负责人为状态负责人原本的任务负责人移至协助人员')}}</div>
<div v-else-if="userData.usertype=='merge'" class="form-tip">{{$L('流转到此状态时改变任务负责人为状态负责人(并保留操作状态的人员),原本的任务负责人移至协助人员。')}}</div>
<div v-else class="form-tip">{{$L('流转到此状态时添加状态负责人至任务负责人。')}}</div>
<div v-if="userData.usertype=='replace'" class="form-tip">{{$L(`流转到${userData.name}时改变任务负责人为状态负责人原本的任务负责人移至协助人员`)}}</div>
<div v-else-if="userData.usertype=='merge'" class="form-tip">{{$L(`流转到【${userData.name}时改变任务负责人为状态负责人(并保留操作状态的人员),原本的任务负责人移至协助人员。`)}}</div>
<div v-else class="form-tip">{{$L(`流转到【${userData.name}】时添加状态负责人至任务负责人。`)}}</div>
</FormItem>
<FormItem prop="userlimit" :label="$L('限制负责人')">
<iSwitch v-model="userData.userlimit" :true-value="1" :false-value="0"/>
<div v-if="userData.userlimit===1" class="form-tip">{{$L('在此状态的任务状态负责人项目管理员可以修改状态')}}</div>
<div v-else class="form-tip">{{$L('在此状态的任务任务负责人、项目管理员可以修改状态。')}}</div>
<div v-if="userData.userlimit===1" class="form-tip">{{$L(`流转到${userData.name}"状态负责人""项目管理员"可以修改状态`)}}</div>
<div v-else class="form-tip">{{$L(`流转到【${userData.name}】时,"任务负责人"和"项目管理员"可以修改状态。`)}}</div>
</FormItem>
</Form>
<div slot="footer" class="adaption">

View File

@ -1,3 +1,4 @@
@import "dialog-group-info";
@import "dialog-wrapper";
@import "file-content";
@import "project-archived";

View File

@ -0,0 +1,118 @@
.dialog-group-info {
display: flex;
flex-direction: column;
position: absolute;
top: 10px;
left: 0;
right: 0;
bottom: 0;
.group-info-title {
color: #b7b1b1;
margin: 18px 24px 0;
}
.group-info-value {
margin: 4px 24px 0;
line-height: 34px;
.quick-text {
padding: 6px 0;
height: auto;
line-height: 20px;
box-sizing: content-box;
overflow: visible;
white-space: normal;
}
}
.group-info-search {
margin: 18px 24px 0;
}
.group-info-button {
display: flex;
align-items: center;
margin: 18px 24px 0;
cursor: pointer;
> i {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
font-size: 18px;
margin-right: 8px;
border-radius: 50%;
color: #777;
border: 1px solid #ddd;
}
}
.group-info-user {
flex: 1;
overflow: auto;
margin-top: 16px;
padding: 0 24px;
> ul {
> li {
display: flex;
align-items: center;
list-style: none;
padding-bottom: 16px;
&:hover {
.user-exit {
opacity: 1;
transform: translateX(0);
}
}
&.no {
justify-content: center;
color: #999;
.common-loading {
width: 16px;
height: 16px;
}
}
.common-avatar {
width: 0;
flex: 1;
.avatar-name {
padding-left: 8px;
}
}
.user-exit {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
margin-left: 4px;
width: 20px;
height: 20px;
font-size: 12px;
color: #999999;
border: 1px solid #dddddd;
border-radius: 50%;
opacity: 0;
transform: translateX(50%);
transition: all 0.2s;
}
.ivu-tag {
margin-left: 4px;
height: 20px;
line-height: 20px;
padding: 0 5px;
transform: scale(0.9);
transform-origin: right center;
}
}
}
}
}

View File

@ -132,6 +132,7 @@
background-color: #8BCF70;
color: #FFFFFF;
text-align: center;
white-space: nowrap;
}
}
@ -151,6 +152,13 @@
}
}
}
.dialog-create {
cursor: pointer;
margin-left: 24px;
font-size: 20px;
color: $primary-text-color;
}
}
.dialog-scroller {

View File

@ -39,6 +39,7 @@
background-color: #8BCF70;
color:#FFFFFF;
text-align: center;
white-space: nowrap;
}
}
.project-icons {