QT聊天项目DAY16

1.聊天界面UI

1.1 创建ChatWidget

主要分为3部分

侧边栏,搜索框,以及聊天窗口

1.2 侧边栏

1. UI布局

UI布局为一个弹簧,外加一个窗口,窗口里是模仿微信的头像,聊天和通讯录,如下图所示

2.  整体布局参数

整体布局,限定宽度为56,其次垂直布局中的间距统统设置为0

3. 自定义UI控件

#ifndef STATELABEL_H
#define STATELABEL_H

#include <QLabel>
#include <QMouseEvent>
#include "Enum.h"


class StateLabel  : public QLabel
{
	Q_OBJECT

public:
	StateLabel(QWidget *parent = 0);

	virtual void mousePressEvent(QMouseEvent *event) override;
	virtual void mouseReleaseEvent(QMouseEvent *event) override;
	virtual void enterEvent(QEvent *event) override;
	virtual void leaveEvent(QEvent *event) override;

	/* 设置状态对应的文本,方便从样式表中切换UI */
	void SetState(QString normal = "", QString hover = "", QString press = "",
		QString select = "", QString select_hover = "", QString select_press = "");

	~StateLabel();

private:
	void UpdateStyleSheet(QString str);													// 刷新样式

private:
	QString _Normal;																	// 未选中
	QString _NormalHover;
	QString _NormalPress;

	QString _Selected;																	// 选中
	QString _SelectedHover;
	QString _SelectedPress;

	ClickLabelState _CurrState;															// 当前标签状态
};
#endif // STATELABEL_H
#include "StateLabel.h"
#include "Global.h"
#include <QDebug>


StateLabel::StateLabel(QWidget *parent)
	: QLabel(parent), _CurrState(ClickLabelState::Normal)
{
	setCursor(Qt::PointingHandCursor);
}

void StateLabel::mousePressEvent(QMouseEvent * event)
{
	if (event->button() == Qt::LeftButton)
	{
		if (_CurrState == ClickLabelState::Selected)
		{
			_CurrState = ClickLabelState::Normal;
			UpdateStyleSheet(_NormalPress);
		}
		if (_CurrState == ClickLabelState::Normal)
		{
			_CurrState = ClickLabelState::Selected;
			UpdateStyleSheet(_SelectedPress);
		}
	}
	QLabel::mousePressEvent(event);
}

void StateLabel::mouseReleaseEvent(QMouseEvent* event)
{
	if (event->button() == Qt::LeftButton)
	{
		if (_CurrState == ClickLabelState::Normal)
		{
			UpdateStyleSheet(_NormalHover);
		}
		else
		{
			UpdateStyleSheet(_SelectedHover);
		}
	}
	QLabel::mouseReleaseEvent(event);
}

void StateLabel::enterEvent(QEvent* event)
{
	if (_CurrState == ClickLabelState::Normal)
	{
		UpdateStyleSheet(_NormalHover);
	}
	else
	{
		UpdateStyleSheet(_SelectedHover);
	}
	QLabel::enterEvent(event);
}

void StateLabel::leaveEvent(QEvent* event)
{
	if (_CurrState == ClickLabelState::Normal)
	{
		UpdateStyleSheet(_Normal);
	}
	else
	{
		UpdateStyleSheet(_Selected);
	}
	QLabel::leaveEvent(event);
}

StateLabel::~StateLabel()
{}

void StateLabel::SetState(QString normal, QString hover, QString press,
	QString select, QString select_hover, QString select_press)
{
	_Normal = normal;
	_NormalHover = hover;
	_NormalPress = press;

	_Selected = select;
	_SelectedHover = select_hover;
	_SelectedPress = select_press;

	UpdateStyleSheet(_Normal);
}

void StateLabel::UpdateStyleSheet(QString str)
{
	setProperty("state", str);
	repolish(this);
	update();
}

 4. 样式表

/* 侧边栏 */
#SideBar
{
	background-color:rgb(46,46,46);
}

1.3 用户栏

1.3.1 UI布局

包含三个列表窗口,分别是通讯录,搜索列表 + 搜索框以及聊天用户信息列表

点击搜索展示搜索列表,点击侧边栏的聊天标签显示的是常用聊天用户,点击侧边栏的用户标签展示的是通讯录

1.3.2 整体布局

固定宽度为250,垂直布局的间隔为0

1.3.3 搜索框

该搜索框由一个搜索栏和可点击的按钮组成

高度为60,布局间隔如下

1.3.3.1 自定义控件
1.3.3.1.1 搜索输入编辑框

当文本输入时,检查文本长度是否超过8字节,如果超过只取前8字节

#ifndef CUSTOMIZEEDIT_H
#define CUSTOMIZEEDIT_H

#include <QLineEdit>
#include <QWidget>
#include <QFocusEvent>

class CustomizeEdit  : public QLineEdit
{
	Q_OBJECT

public:
	CustomizeEdit(QWidget *parent = 0);
	~CustomizeEdit();
	void SetMaxLength(int maxLen);

protected:
	virtual void focusOutEvent(QFocusEvent *event) override;

signals:
	void sigFoucusOut();

private:
	void LimitTextLength(QString text);

private:
	int _maxLen;
};

#endif // CUSTOMIZEEDIT_H


#include "CustomizeEdit.h"

CustomizeEdit::CustomizeEdit(QWidget *parent)
	: QLineEdit(parent), _maxLen(0)
{
	connect(this, &QLineEdit::textChanged, this, &CustomizeEdit::LimitTextLength);
}

CustomizeEdit::~CustomizeEdit()
{}

void CustomizeEdit::SetMaxLength(int maxLen)
{
	_maxLen = maxLen;
}

void CustomizeEdit::focusOutEvent(QFocusEvent* event)
{
	QLineEdit::focusOutEvent(event);
	emit sigFoucusOut();
}

void CustomizeEdit::LimitTextLength(QString text)
{
	if(_maxLen <= 0)
		return;

	QByteArray byteArray = text.toUtf8();

	if (byteArray.size() > _maxLen)
	{
		byteArray = byteArray.left(_maxLen);
		setText(QString::fromLocal8Bit(byteArray));
	}
}

样式表

/* 搜索输入框 */
#SearchEdit
{
	border: 2px solid #f1f1f1;
}

1.3.3.1.2 添加用户按钮

根据按钮状态来切换按钮的UI

#ifndef CLICKEDBTN_H
#define CLICKEDBTN_H

#include <QPushButton>

class ClickedBtn  : public QPushButton
{
	Q_OBJECT

public:
	ClickedBtn(QWidget *parent = 0);
	~ClickedBtn();

public:
	void SetState(QString normal, QString hover, QString pressed);
	void UpdateStyleSheet(QString str);													

protected:
	virtual void mousePressEvent(QMouseEvent *event) override;
	virtual void mouseReleaseEvent(QMouseEvent *event) override;
	virtual void enterEvent(QEvent *event) override;
	virtual void leaveEvent(QEvent *event) override;

private:
	QString _normal;
	QString _hover;
	QString _pressed;
};

#endif // CLICKEDBTN_H


#include "ClickedBtn.h"
#include "Global.h"

ClickedBtn::ClickedBtn(QWidget *parent)
	: QPushButton(parent)
{
	setCursor(Qt::PointingHandCursor);
	setFocusPolicy(Qt::NoFocus);
}

ClickedBtn::~ClickedBtn()
{}

void ClickedBtn::SetState(QString normal, QString hover, QString pressed)
{
	_normal = normal;
	_hover = hover;
	_pressed = pressed;
	UpdateStyleSheet(_normal);
}

void ClickedBtn::mousePressEvent(QMouseEvent* event)
{
	UpdateStyleSheet(_pressed);
	QPushButton::mousePressEvent(event);
}

void ClickedBtn::mouseReleaseEvent(QMouseEvent* event)
{
	UpdateStyleSheet(_hover);
	QPushButton::mouseReleaseEvent(event);
}

void ClickedBtn::enterEvent(QEvent* event)
{
	UpdateStyleSheet(_hover);
	QPushButton::enterEvent(event);
}

void ClickedBtn::leaveEvent(QEvent* event)
{
	UpdateStyleSheet(_normal);
	QPushButton::leaveEvent(event);
}

void ClickedBtn::UpdateStyleSheet(QString str)
{
	setProperty("state", str);
	repolish(this);
	update();
}

样式表

/* 添加用户按钮 */
#AddBtn[state='normal']
{
	border-image: url(:/Chat/Images/add_friend_normal.png);
}

#AddBtn[state='hover']
{
	border-image: url(:/Chat/Images/add_friend_hover.png);
}

#AddBtn[state='press']
{
	border-image: url(:/Chat/Images/add_friend_hover.png);
}

1.3.3.2 在聊天界面类中初始化

首先设置自定义控件的状态,然后添加搜索行为和清除行为,将搜索行为添加到搜索输入文本框的左边,清除行为添加到右边,当有输入时显示,没有输入时隐藏。

绑定清除行为点击对应的槽函数,清空文本并隐藏,然后展示搜索窗口

ui.AddBtn->SetState("normal", "hover", "press");
ui.SearchEdit->SetMaxLength(15);

// 添加搜索动作
QAction* searchAction = new QAction(ui.SearchEdit);
searchAction->setIcon(QIcon(":/Chat/Images/search.png"));
ui.SearchEdit->addAction(searchAction, QLineEdit::LeadingPosition);						// 将动作添加到搜索框,并指定位置 左边
ui.SearchEdit->setPlaceholderText(QString::fromLocal8Bit("搜索"));						// 输入框为空时显示的灰色提示文本

// 清除动作并设置按钮
QAction* clearAction = new QAction(ui.SearchEdit);
clearAction->setIcon(QIcon(":/Chat/Images/close_transparent.png"));
ui.SearchEdit->addAction(clearAction, QLineEdit::TrailingPosition);						// 将动作添加到搜索框,并指定位置 右边

// 文本改变时显示清除按钮
connect(ui.SearchEdit, &QLineEdit::textChanged, this, [this, clearAction](const QString& text)
	{
		if (text.isEmpty())
		{
			clearAction->setIcon(QIcon(":/Chat/Images/close_transparent.png"));
		}
		else
		{
			clearAction->setIcon(QIcon(":/Chat/Images/close_search.png"));
		}
	});

// 点击清除按钮时清空搜索框
connect(clearAction, &QAction::triggered, this, [this, clearAction]()
	{
		ui.SearchEdit->clear();
		clearAction->setIcon(QIcon(":/Chat/Images/close_transparent.png"));
		ui.SearchEdit->clearFocus();
		ShowSearch();
	});
ShowSearch();

由于有三个窗口创建ShowSeach决定显示哪一个UI窗口,初始时展示的是聊天消息窗口

// 聊天界面模式
enum ChatUIMode {
    SearchMode,                                 // 搜索模式
    ChatMode,                                   // 聊天模式
    ContactMode,                                // 联系模式
};

void ChatWidget::ShowSearch(bool bSearch)
{
	if (bSearch)
	{
		ui.chatUserList->hide();
		ui.CommonUserList->hide();
		ui.SearchList->show();
		_mode = ChatUIMode::SearchMode;
	}
	else if (_state == ChatUIMode::ChatMode)
	{
		ui.chatUserList->show();
		ui.CommonUserList->hide();
		ui.SearchList->hide();
		_mode = ChatUIMode::ChatMode;
	}
	else if (_state == ChatUIMode::ContactMode)
	{
		ui.CommonUserList->show();
		ui.chatUserList->hide();
		ui.SearchList->hide();
		_mode = ChatUIMode::ContactMode;
	}
}

1.3.4 聊天用户窗口

1.3.4.1 自定义控件

添加滚轮事件用来加载聊天用户

.H
#ifndef CHATUSERLIST_H
#define CHATUSERLIST_H

#include <QListWidget>
#include <QEvent>
#include <QScrollBar>

class ChatUserList  : public QListWidget
{
	Q_OBJECT

public:
	ChatUserList(QWidget *parent = 0);
	~ChatUserList();

protected:
	virtual bool eventFilter(QObject *obj, QEvent *event) override;

signals:
	void sig_loading_chat_user();
};
#endif // CHATUSERLIST_H


.CPP
#include "ChatUserList.h"
#include <QWheelEvent>
#include <QDebug>


ChatUserList::ChatUserList(QWidget* parent)
	: QListWidget(parent)
{
	setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	viewport()->installEventFilter(this);
}

ChatUserList::~ChatUserList()
{}

bool ChatUserList::eventFilter(QObject* obj, QEvent* event)
{
	if (obj == viewport())
	{
		// 鼠标进入视口时,设置垂直滚动条为可见
		if (event->type() == QEvent::Enter)
		{
			setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded);
		}
		// 鼠标离开视口时,设置垂直滚动条为不可见
		if (event->type() == QEvent::Leave)
		{
			setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
		}
		// 滚轮事件
		if (event->type() == QEvent::Wheel)
		{
			// 获取滚轮幅度和滚动步数
			QWheelEvent* wheelEvent = static_cast<QWheelEvent*>(event);
			int numDegrees = wheelEvent->angleDelta().y() / 8;
			int numSteps = numDegrees / 15;

			// 设置滚动幅度
			verticalScrollBar()->setValue(verticalScrollBar()->value() - numSteps);

			// 检查是否滚动到底部
			QScrollBar* scrollBar = verticalScrollBar();
			int maxScrollValue = scrollBar->maximum();
			int currentValue = scrollBar->value();

			qDebug() << "maxScrollValue:" << maxScrollValue << "currentValue:" << currentValue;

			// 如果滚动到底部,加载更多聊天用户
			if (maxScrollValue - currentValue <= 0)
			{
				qDebug() << "load more chat user";
				emit sig_loading_chat_user();
			}
			return true;
		}
	}
	return QListWidget::eventFilter(obj, event);
}

1.3.4.2 样式表
/* 聊天用户列表 */
#chatUserList
{
	background-color: rgb(247,247,248);
	border: none;
}

#chatUserList::item:selected
{
	background-color: #d3d7d4;
	border: none;
	outline: none;
}

#chatUserList::item:hover
{
	background-color: rgb(206,207,208);
	border: none;
	outline: none;
}

#chatUserList::item:focus
{
	border: none;
	outline: none;
}

1.3.4.3 聊天界面初始化
// 绑定加载用户信号
connect(ui.chatUserList, &ChatUserList::sig_loading_chat_user, this, &ChatWidget::SlotLoadingChatUser);
AddChatUserList();

1.3.4.3.1 加载用户的回调
void ChatWidget::SlotLoadingChatUser()
{
	if (bLoading)
		return;

	bLoading = true;
	LoadingWnd* loadingWnd = new LoadingWnd(this);
	//loadingWnd->setModel(true);
	loadingWnd->ShowLoading();
	qDebug() << "Add New Data to list......";
	AddChatUserList();
	loadingWnd->deleteLater();
	bLoading = false;
}

1.3.4.3.2 创建LoadingWnd来展示加载动画
.H

#ifndef LOADINGWND_H
#define LOADINGWND_H

#include <QWidget>
#include <QMovie>
#include "ui_LoadingWnd.h"

class LoadingWnd : public QWidget
{
	Q_OBJECT

public:
	LoadingWnd(QWidget *parent = nullptr);
	~LoadingWnd();

public:
	void ShowLoading();

private:
	Ui::LoadingWndClass ui;
	QMovie *movie;
};

#endif // LOADINGWND_H


.CPP

#include "LoadingWnd.h"
#include <QMovie>

LoadingWnd::LoadingWnd(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);

	setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint | Qt::WindowStaysOnTopHint);
	setAttribute(Qt::WA_TranslucentBackground);

	setFixedSize(parent->size());

	movie = new QMovie(":/Chat/Images/loading.gif");
	ui.LoadingLabel->setMovie(movie);
	movie->start();
}

LoadingWnd::~LoadingWnd()
{}

void LoadingWnd::ShowLoading()
{
	show();
	movie->start();
}

1.3.4.3.3 添加聊天项到聊天列表中
/* 测试时使用的全局变量 */
std::vector<QString>  strs = { "hello world !",
							 "nice to meet you",
							 "New year, new life",
							"You have to love yourself",
							"My love is written in the wind ever since the whole world is you" };

std::vector<QString> heads = {
	":/Chat/Images/head_1.jpg",
	":/Chat/Images/head_2.jpg",
	":/Chat/Images/head_3.jpg",
	":/Chat/Images/head_4.jpg",
	":/Chat/Images/head_5.jpg",
};

std::vector<QString> names = {
	"llfc",
	"zack",
	"golang",
	"cpp",
	"java",
	"nodejs",
	"python",
	"rust"
};

void ChatWidget::AddChatUserList()
{
	for(int i = 0; i < 13; i++)
	{
		int randomValue = QRandomGenerator::global()->bounded(100);									// 0~99随机数
		int str_index = randomValue % strs.size();																
		int head_index = randomValue % heads.size();
		int name_index = randomValue % names.size();

		/* 创建自定义控件 */
		ChatUserWnd* userItemWnd = new ChatUserWnd;
		userItemWnd->SetInfo(names[name_index], heads[head_index], strs[str_index]);
		/* 为控件添加容器 */
		QListWidgetItem* item = new QListWidgetItem;												
		item->setSizeHint(userItemWnd->sizeHint());
		ui.chatUserList->addItem(item);
		ui.chatUserList->setItemWidget(item, userItemWnd);											// 必须添加项的容器,告诉列表如何显示这个容器
	}
}

1.3.4.3.4 列表项基类
Enum

// 自定义QListWidgetItem的几种类型
enum ListItemType {
    ChatUserItem,                               // 聊天用户
    ContactUserItem,                            // 联系人用户
    SearchUserItem,                             // 搜索到的用户
    AddUserTipItem,                             // 提示添加用户
    InvalidItem,                                // 不可点击条目
    GroupTipItem,                               // 分组提示条目
};


.H

#ifndef LISTWIDGET_H
#define LISTWIDGET_H

#include <QWidget>
#include "Enum.h"
#include <QPaintEvent>

class ListItemBase : public QWidget
{
	Q_OBJECT

public:
	ListItemBase(QWidget *parent = 0);
	~ListItemBase();

protected:
	virtual void paintEvent(QPaintEvent *event) override;


public:
	ListItemType itemType;
};

#endif // LISTWIDGET_H



.CPP

#include "ListItemBase.h"
#include <QPainter>
#include <QStyleOption>

ListItemBase::ListItemBase(QWidget *parent)
	: QWidget(parent)
{}

ListItemBase::~ListItemBase()
{}

void ListItemBase::paintEvent(QPaintEvent * event)
{
	QStyleOption opt;
	opt.init(this);
	QPainter p(this);
	style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}

1.3.4.3.5 聊天项

总体布局

IconWnd

IconLabel

UserInfoWnd

两个标签正常没有任何修改

TimeWnd

TimeLabel正常

代码

.H

#ifndef CHATUSERWND_H
#define CHATUSERWND_H
#include "ListItemBase.h"
#include "ui_ChatUserWnd.h"

class ChatUserWnd : public ListItemBase
{
	Q_OBJECT

public:
	ChatUserWnd(QWidget *parent = nullptr);
	~ChatUserWnd();

public:
	virtual QSize sizeHint() const override;
	void SetInfo(QString name, QString head, QString msg);

private:
	Ui::ChatUserWndClass ui;

	QString m_name;
	QString m_head;
	QString m_msg;
};

#endif // CHATUSERWND_H


.CPP

#include "ChatUserWnd.h"

ChatUserWnd::ChatUserWnd(QWidget *parent)
	: ListItemBase(parent)
{
	ui.setupUi(this);
	itemType = ChatUserItem;
}

ChatUserWnd::~ChatUserWnd()
{}

QSize ChatUserWnd::sizeHint() const
{
	return QSize(250, 70);
}

void ChatUserWnd::SetInfo(QString name, QString head, QString msg)
{
	m_name = name;
	m_head = head;
	m_msg = msg;

	QPixmap headPixmap(m_head);

	ui.IconLabel->setPixmap(headPixmap.scaled(ui.IconLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
	ui.IconLabel->setScaledContents(true);

	ui.UserNameLabel->setText(m_name);
	ui.UserChatLabel->setText(m_msg);
}

1.3.5 搜索界面

1.3.5.1 SearchList

构造函数,该界面由哪些组件构成

由无效项 + 查找提示项 组成

当点击查找项时会发送查找结果到服务器,服务器返回搜索结果,处理后根据结果选择是否切换到搜索成功或失败界面

1.3.5.1.1 搜索用户的具体逻辑

当查找用户项被点击时,会向服务器发送搜索请求,服务器根据请求向数据库查找用户信息,然后将用户信息返回给客户端,客户端处理搜索结果

connect(this, &QListWidget::itemClicked, this, &SearchList::slotItemClicked);

connect(TCPMgr::Instance().get(), &TCPMgr::SigUserSearch, this, &SearchList::slotUserSearch);			// 接收服务器返回的搜索结果

void SearchList::slotItemClicked(QListWidgetItem* item)
{
	QWidget* widget = itemWidget(item);

	if(!widget)
	{
		qDebug() << "slot item clicked widget is nullptr";
		return;
	}

	ListItemBase* customItem = qobject_cast<ListItemBase*>(widget);
	if (!customItem)
	{
		qDebug() << "slot item clicked customItem is nullptr";
		return;
	}

	if (customItem->itemType == ListItemType::INVALID_ITEM)
	{
		qDebug() << "slot item clicked invalid item";
		return;
	}

	if (customItem->itemType == ListItemType::ADD_USER_TIP_ITEM)
	{
		// 如果搜索请求正在发送,无视重复点击
		if (_sendPending)
			return;
		
		// 如果搜索框是无效的,则不处理
		if (!_searchEdit)
			return;

		waitPending(true);																		// 加载动画

		CustomizeEdit* searchEdit = qobject_cast<CustomizeEdit*>(_searchEdit);
		QString uidStr = searchEdit->text();													// 获取用户名

		// 发送搜索好友请求
		QJsonObject obj;
		obj["uid"] = uidStr;
		QJsonDocument doc(obj);
		QByteArray data = doc.toJson(QJsonDocument::Compact);
		emit TCPMgr::Instance()->SigSendData(ReqID::ID_SEARCH_USER_REQUEST, data);

		return;
	}

	CloseFindWnd();
}

// 处理服务器返回的搜索结果
void SearchList::slotUserSearch(QSharedPointer<SearchInfo> searchInfo)
{
	waitPending(false);																			// 关闭加载动画
	if (searchInfo == nullptr)
	{
		m_findDlg = QSharedPointer<FindFailDlg>(new FindFailDlg(this));
	}
	else
	{
		// 如果搜索的是自己
		int selfUid = UserMgr::Instance()->GetUid();
		if (searchInfo->_uid == selfUid)
			return;

		// 如果是自己的好友,则直接跳转到聊天窗口
		bool bExisit = UserMgr::Instance()->CheckFriendByUid(searchInfo->_uid);
		if (bExisit)
		{
			emit sigJumpChatItem(searchInfo);
			return;
		}

		// 不是自己的好友,则显示搜索结果
		m_findDlg = QSharedPointer<FindSuccedDlg>(new FindSuccedDlg(this));
		FindSuccedDlg* findSuccedWnd = static_cast<FindSuccedDlg*>(m_findDlg.data());
		findSuccedWnd->SetSearchInfo(searchInfo);
	}
}

TCPMgr整合客户端发来的数据信息

_handlers.insert(ReqID::ID_SEARCH_USER_REQUEST, [this](ReqID id, int len, QByteArray data)
	{
		qDebug() << "handle id is" << id << "len is" << len << "data is" << data;

		// 解析消息体
		QJsonDocument jsonDoc = QJsonDocument::fromJson(data);

		if (jsonDoc.isNull())
		{
			qDebug() << QString::fromLocal8Bit("Json解析失败!!!");
			return;
		}

		// 如果解析失败,则返回空指针
		QJsonObject jsonObj = jsonDoc.object();
		if (!jsonObj.contains("error"))
		{
			int err = ErrorCodes::ERROR_JSON;
			qDebug() << "Login Failed, err is Json Parse Err" << err;

			emit SigUserSearch(nullptr);
			return;
		}

		// 如果服务器返回错误码,则返回空指针
		int error = jsonObj["error"].toInt();
		if (error != ErrorCodes::SUCCESS)
		{
			qDebug() << "Login Failed, err is" << error;
			emit SigUserSearch(nullptr);
			return;
		}

		// 返回服务器发来的用户信息
		QSharedPointer<SearchInfo> userList(new SearchInfo(jsonObj["uid"].toInt(), jsonObj["name"].toString(),
			jsonObj["nick"].toString(), jsonObj["desc"].toString(), jsonObj["sex"].toInt(), jsonObj["icon"].toString()));
		emit SigUserSearch(userList);
	});

1.3.5.1.2 样式表
/* 搜索列表(等同于用户列表) */
#searchList
{
	background-color: rgb(247,247,248);
	border: none;
}

#searchList::item:selected
{
	background-color: #d3d7d4;
	border: none;
}

#searchList::item:hover
{
	background-color: rgb(206,207,208);
	border: none;
	outline: none;
}

#searchList::focus
{
	border: none;
	outline: none;
}

#invalidItem
{
	background-color: #eaeaea;
	border: none;
}
/* end */

1.3.6 用户列表

1.3.6.1 联系人列表 ContactUserList
1.3.6.1.1 组件构成

构造函数中,按照如下方式生成组件

ContactUserList::ContactUserList(QWidget *parent)
	: QListWidget(parent)
{
	setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	viewport()->installEventFilter(this);

	// 加载数据用来测试
	addContactUserList();

	connect(this, &ContactUserList::itemClicked, this, &ContactUserList::slotItemClicked);
}

addContactUserList

void ContactUserList::addContactUserList()
{
	// 新朋友窗口标签
	GroupTipItem* groupTipItem = new GroupTipItem;
	QListWidgetItem* item = new QListWidgetItem;
	InitItem(groupTipItem, item);

	// 添加朋友窗口
	_addFriendItem = new ContactUserItem;
	_addFriendItem->setObjectName("newFriendItem");
	_addFriendItem->SetInfo(0, QString::fromLocal8Bit("新的朋友"), ":/Chat/Images/add_friend.png");
	_addFriendItem->itemType = ListItemType::APPLY_FRIEND_ITEM;
	QListWidgetItem* addFriendItem = new QListWidgetItem;
	InitItem(_addFriendItem, addFriendItem);
	setCurrentItem(addFriendItem);

	// 联系人窗口标签
	GroupTipItem* groupTipItem2 = new GroupTipItem;
	groupTipItem2->SetGroupTip(QString::fromLocal8Bit("联系人"));
	_groupItem = new QListWidgetItem;
	InitItem(groupTipItem2, _groupItem);

	// 加载后端传过来的联系人列表
	QVector<QSharedPointer<FriendInfo>> contactList = UserMgr::Instance()->GetContactPerPage();
	for (QSharedPointer<FriendInfo> contact : contactList)
	{
		ContactUserItem* userWidget = new ContactUserItem;
		userWidget->SetInfo(contact->_uid, contact->_name, contact->_icon);
		QListWidgetItem* item = new QListWidgetItem;
		InitItem(userWidget, item, false);
	}
	// 更新加载过后的联系人列表起始位置
	UserMgr::Instance()->UpdateContactLoadedCount();

	// 测试联系人列表												--------						为防止联系人没有这么多,测试使用
	for (int i = 0; i < 13; i++)
	{
		int randomValue = QRandomGenerator::global()->bounded(100);
		int str_index = randomValue % strs.size();
		int head_index = randomValue % heads.size();
		int name_index = randomValue % names.size();

		ContactUserItem* userWidget = new ContactUserItem;
		userWidget->SetInfo(0, names[name_index], heads[head_index]);
		QListWidgetItem* item = new QListWidgetItem;
		InitItem(userWidget, item, false);
	}
}

1.3.6.1.1.1 GroupTipItem

代码

#ifndef GROUPTIPITEM_H
#define GROUPTIPITEM_H

#include "ListItemBase.h"
#include "ui_GroupTipItem.h"

class GroupTipItem : public ListItemBase
{
	Q_OBJECT

public:
	GroupTipItem(QWidget *parent = nullptr);
	~GroupTipItem();

public:
	virtual QSize sizeHint() const override;
	void SetGroupTip(QString str);

private:
	QString _tip;
	Ui::GroupTipItemClass ui;
};

#endif // GROUPTIPITEM_H


#include "GroupTipItem.h"

GroupTipItem::GroupTipItem(QWidget *parent)
	: ListItemBase(parent)
{
	ui.setupUi(this);
	setObjectName("GroupTipItem");
	itemType = ListItemType::GROUP_TIP_ITEM;
}

GroupTipItem::~GroupTipItem()
{}

QSize GroupTipItem::sizeHint() const
{
	return QSize(250, 25);
}

void GroupTipItem::SetGroupTip(QString str)
{
	ui.newFriendLabel->setText(str);
}

UI布局

样式表

/* 联系人窗口项 */
#GroupTipItem 
{
    background-color: #eaeaea;
    border: none;
}

#GroupTipItem #newFriendLabel
{
    color: #2e2f30;
    font-size: 12px; /* 设置字体大小 */
    font-family: "Microsoft YaHei"; /* 设置字体 */
    border: none;
}

1.3.6.1.1.2 联系人列表项 ContactUserItem

代码

#ifndef CONTACTUSERITEM_H
#define CONTACTUSERITEM_H

#include "ListItemBase.h"
#include "ui_ContactUserItem.h"
#include "UserData.h"
#include <QSharedPointer>

/// <summary>
/// 联系人列表项
/// </summary>
class ContactUserItem : public ListItemBase
{
	Q_OBJECT

public:
	ContactUserItem(QWidget *parent = nullptr);
	~ContactUserItem();

	void InitUI(QString iconPath, QString name);

public:
	virtual QSize sizeHint() const override;

	void SetInfo(QSharedPointer<AuthInfo> auth_info);
	void SetInfo(QSharedPointer<AuthRsp> auth_rsp);
	void SetInfo(int uid, QString name, QString icon);
	void ShowRedPoint(bool show = false);
	QSharedPointer<UserInfo> GetInfo();

private:
	Ui::ContactUserItemClass ui;
	QSharedPointer<UserInfo> _userInfo;																								// 用户信息
};

#endif // CONTACTUSERITEM_H

#include "ContactUserItem.h"
#include <QPixmap>

ContactUserItem::ContactUserItem(QWidget *parent)
	: ListItemBase(parent)
{
	ui.setupUi(this);

	setObjectName("contactUserItem");

	itemType = ListItemType::CONTACT_USER_ITEM;
	ui.redPoint->raise();
	ShowRedPoint();
}

ContactUserItem::~ContactUserItem()
{}

void ContactUserItem::InitUI(QString iconPath, QString name)
{
	QPixmap pixmap(iconPath);

	ui.iconLabel->setPixmap(pixmap.scaled(ui.iconLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation));
	ui.iconLabel->setScaledContents(true);

	ui.UserNameLabel->setText(name);
}

QSize ContactUserItem::sizeHint() const
{
	return QSize(250, 70);
}

void ContactUserItem::SetInfo(QSharedPointer<AuthInfo> auth_info)
{
	_userInfo = QSharedPointer<UserInfo>(new UserInfo(auth_info));
	InitUI(_userInfo->_icon, _userInfo->_name);
}

void ContactUserItem::SetInfo(int uid, QString name, QString icon)
{
	_userInfo = QSharedPointer<UserInfo>(new UserInfo(uid, name, icon));
	InitUI(_userInfo->_icon, _userInfo->_name);
}

void ContactUserItem::SetInfo(QSharedPointer<AuthRsp> auth_rsp)
{
	_userInfo = QSharedPointer<UserInfo>(new UserInfo(auth_rsp));
	InitUI(_userInfo->_icon, _userInfo->_name);
}

void ContactUserItem::ShowRedPoint(bool show)
{
	if (show)
	{
		ui.redPoint->show();
	}
	else
	{
		ui.redPoint->hide();
	}
}

QSharedPointer<UserInfo> ContactUserItem::GetInfo()
{
	return _userInfo;
}

UI组成

样式表

/* 新朋友 - 项 */
#newFriendItem 
{
    border-bottom: 1px solid #eaeaea;
}

/* 联系人列表 */
#contactUserList 
{
    background-color: rgb(247,247,248);
    border: none;
}

#contactUserList::item:selected 
{
    background-color: #d3d7d4;
    border: none;
    outline: none;
}

#contactUserList::item:hover 
{
    background-color: rgb(206,207,208);
    border: none;
    outline: none;
}

#contactUserList::focus 
{
    border: none;
    outline: none;
}

/* 联系人列表项 */
#contactUserItem #iconLabel
{
	background-color: rgb(247,247,248);
}

1.4 聊天界面

使用QStackedWidget来自由切换

1.4.1 自定义控件ChatPage

一共四个窗口,从上往下看

最外层是水平布局,目前该布局中只有一个聊天展示窗口,该窗口为垂直布局

聊天展示窗口的布局

1.4.1.1 用户信息窗口TitleWnd

普通的窗口和用户信息标签

窗口的布局

1.4.1.2 展示聊天信息的窗口chatView

这一部分使用C++代码,因为聊天信息分为自己和别人,这一部分可以通过添加枚举,调整UI控件布局的方式来实现两种UI布局的切换,而不需要创建两个可视化UI来进行切换,只需要调整UI控件的布局即可

代码

.H

#ifndef CHATVIEW_H
#define CHATVIEW_H

#include <QWidget>
#include <QVBoxLayout>

class QScrollArea;

class ChatView  : public QWidget
{
	Q_OBJECT

public:
	ChatView(QWidget *parent = 0);
	~ChatView();

public:
	void AppendChatItem(QWidget* item);
	void PrependChatItem(QWidget* item);
	void InsertChatItem(QWidget* before, QWidget* item);

protected:
	virtual bool eventFilter(QObject* obj, QEvent* event) override;
	virtual void paintEvent(QPaintEvent* event) override;

private slots:
	void OnVScrollBarMoved(int min, int max);

private:
	void InitStyleSheet();
	void InitLayout();

private:
	QVBoxLayout* m_contentLayout;
	QScrollArea* m_scrollArea;
	bool bIsAppended;
};

#endif // CHATVIEW_H



.CPP

#include "ChatView.h"
#include <QScrollBar>
#include <QTimer>
#include <QEvent>
#include <QStyleOption>
#include <QStyle>
#include <QPainter>
#include <QScrollArea>

ChatView::ChatView(QWidget *parent)
	: QWidget(parent), bIsAppended(false)
{
	InitLayout();
	InitStyleSheet();
}

ChatView::~ChatView()
{}

void ChatView::InitLayout()
{
	// 1.主布局(水平布局)
	QHBoxLayout* mainLayout = new QHBoxLayout(this);
	mainLayout->setContentsMargins(0, 0, 0, 0);
	setLayout(mainLayout);

	// 2.滚动区域
	m_scrollArea = new QScrollArea;
	m_scrollArea->setObjectName("scrollArea");
	m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	m_scrollArea->setFrameShape(QFrame::NoFrame);

	// 3.获取滚动区域的滚动条,采用水平布局,将滚动条放在右边
	QScrollBar* vScrollBar = m_scrollArea->verticalScrollBar();
	connect(vScrollBar, &QScrollBar::rangeChanged, this, &ChatView::OnVScrollBarMoved);
	QHBoxLayout* scrollLayout = new QHBoxLayout(m_scrollArea);									// 水平布局
	scrollLayout->addWidget(vScrollBar, 0, Qt::AlignRight);
	scrollLayout->setContentsMargins(0, 0, 0, 0);
	vScrollBar->setHidden(true);
	m_scrollArea->setWidgetResizable(true);
	m_scrollArea->installEventFilter(this);

	// 4.内容窗口,该内容窗口的布局为垂直
	QWidget* contenWidget = new QWidget;
	contenWidget->setObjectName("contentWidget");
	contenWidget->setAutoFillBackground(true);
	m_contentLayout = new QVBoxLayout(contenWidget);
	m_contentLayout->setContentsMargins(0, 0, 15, 0);
	m_contentLayout->addWidget(new QWidget, 100000);
	m_contentLayout->setAlignment(Qt::AlignTop);

	// 5.设置滚动区域的窗口
	m_scrollArea->setWidget(contenWidget);
	
	// 6.将滚动区域放到主布局的左边
	mainLayout->addWidget(m_scrollArea, 1);
}

void ChatView::InitStyleSheet()
{

}

void ChatView::AppendChatItem(QWidget * item)
{
	m_contentLayout->insertWidget(m_contentLayout->count() - 1, item);
	bIsAppended = true;
}

void ChatView::PrependChatItem(QWidget* item)
{

}

void ChatView::InsertChatItem(QWidget* before, QWidget* item)
{

}

bool ChatView::eventFilter(QObject* obj, QEvent* event)
{
	if (event->type() == QEvent::Enter && obj == m_scrollArea)
	{
		m_scrollArea->verticalScrollBar()->setHidden(m_scrollArea->verticalScrollBar()->maximum() == 0);
	}
	if (event->type() == QEvent::Leave && obj == m_scrollArea)
	{
		m_scrollArea->verticalScrollBar()->setHidden(true);
	}

	return QWidget::eventFilter(obj, event);
}

void ChatView::paintEvent(QPaintEvent* event)
{
	QStyleOption opt;
	opt.init(this);
	QPainter p(this);
	style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}

void ChatView::OnVScrollBarMoved(int min, int max)
{
	if (bIsAppended)
	{
		QScrollBar* vScrollBar = m_scrollArea->verticalScrollBar();
		vScrollBar->setSliderPosition(vScrollBar->maximum());
		QTimer::singleShot(500, [this]()
			{
				bIsAppended = false;
			});
	}
}

样式表

/* 用户信息展示 */
ChatView
{
	border: 0px;
	outline: 0px;
}

关于布局的间隔,布局的间隔是相对于该布局所属窗口的间隔

关于滚动区域这个类的详细阐述,可以看下面这篇文章,在此处不做赘述

https://blog.youkuaiyun.com/liji_digital/article/details/87071543

QScrollArea有两层,QScrollArea本身是个QWidget,它内部又套了一个小QWidget。

在默认情况下,内部的这个小QWidget,也即scorllAreaWidgetContents,它的大小总是等于外部QWidget(也即QScrollArea)的大小。

除非我们给scorllAreaWidgetContents设置了宽高的最小值,这时,当QScrollArea的宽或高,一旦小于scorllAreaWidgetContents的宽或高,就会出现滚动条

如果你想通过ui提升的方式来使用自己写的子类,那么请你在ui拖入一个Frame容器或者widget容器等等进行提升,反正不要用拖入的QScrollArea容器进行提升。如果你非得想用QScrollArea容器进行提升,那么你想在QScrollArea的内部容器中添加控件时,请不要把添加控件的代码写在QScrollArea子类的构造函数中

1.4.1.3 工具窗口ToolWnd

UI高宽

UI布局

自定义可点击的标签

.H

#ifndef CLICKEDLABEL_H
#define CLICKEDLABEL_H

#include <QLabel>
#include <QString>
#include "Enum.h"

class ClickedLabel  : public QLabel
{
	Q_OBJECT

public:
	ClickedLabel(QWidget *parent = 0);
	~ClickedLabel();

public:
	virtual void mousePressEvent(QMouseEvent *event) override;
	virtual void mouseReleaseEvent(QMouseEvent *event) override;
	virtual void enterEvent(QEvent *event) override;
	virtual void leaveEvent(QEvent *event) override;

	ClickLabelState GetState() const;

	/* 设置状态对应的文本,方便从样式表中切换UI */
	void SetState(QString normal = "", QString hover = "", QString press = "",
		QString select = "", QString select_hover = "", QString select_press = "");


signals:
	void clicked(void);

private:
	void UpdateStyleSheet(QString str);													// 刷新样式

private:
	QString _Normal;																	// 未选中
	QString _NormalHover;
	QString _NormalPress;
	
	QString _Selected;																	// 选中
	QString _SelectedHover;
	QString _SelectedPress;

	ClickLabelState _CurrState;															// 当前标签状态
};
#endif // CLICKEDLABEL_H


.CPP

#include "ClickedLabel.h"

#include <QMouseEvent>
#include <QDebug>
#include "Global.h"

ClickedLabel::ClickedLabel(QWidget *parent)
	: QLabel(parent), _CurrState(ClickLabelState::Normal)
{
	setCursor(Qt::PointingHandCursor);
}

ClickedLabel::~ClickedLabel()
{}

/* 刷新样式 */
void ClickedLabel::UpdateStyleSheet(QString str)
{
	setProperty("state", str);
	repolish(this);
	update();
}

/* 鼠标按下事件 */
void ClickedLabel::mousePressEvent(QMouseEvent * event)
{
	if (event->button() == Qt::LeftButton)
	{
		if (_CurrState == ClickLabelState::Normal)
		{
			_CurrState = ClickLabelState::Selected;
		}
		UpdateStyleSheet(_SelectedPress);
		emit clicked();
	}

	QLabel::mousePressEvent(event);
}

void ClickedLabel::mouseReleaseEvent(QMouseEvent* event)
{
	if (event->button() == Qt::LeftButton)
	{
		if (_CurrState == ClickLabelState::Selected)
		{
			_CurrState = ClickLabelState::Normal;
		}
		UpdateStyleSheet(_NormalHover);
	}
}

/* 鼠标悬停进入事件 */
void ClickedLabel::enterEvent(QEvent* event)
{
	if (_CurrState == ClickLabelState::Normal)
	{
		qDebug() << "enter , change to hover" << _NormalHover;
		UpdateStyleSheet(_NormalHover);
	}
	else
	{
		qDebug() << "enter , change to selected hover" << _SelectedHover;
		UpdateStyleSheet(_SelectedHover);
	}

	QLabel::enterEvent(event);
}

/* 鼠标离开事件 */
void ClickedLabel::leaveEvent(QEvent* event)
{
	if (_CurrState == ClickLabelState::Normal)
	{
		qDebug() << "leave , change to normal" << _Normal;
		UpdateStyleSheet(_Normal);
	}
	else
	{
		qDebug() << "leave , change to selected" << _Selected;
		UpdateStyleSheet(_Selected);
	}
	QLabel::leaveEvent(event);
}

ClickLabelState ClickedLabel::GetState() const
{
	return _CurrState;
}

void ClickedLabel::SetState(QString normal, QString hover, QString press, QString select, QString select_hover, QString select_press)
{
	_Normal = normal;
	_NormalHover = hover;
	_NormalPress = press;

	_Selected = select;
	_SelectedHover = select_hover;
	_SelectedPress = select_press;

	UpdateStyleSheet(_Normal);
}

为控件设置状态

ui.EmojiLabel->SetState("normal", "hover", "press", "normal", "hover", "press");
ui.FileLabel->SetState("normal", "hover", "press", "normal", "hover", "press");

样式表

/* 表情包工具 */
#EmojiLabel[state='normal']
{
	border-image: url(:/Chat/Images/smile.png);
}

#EmojiLabel[state='hover']
{
	border-image: url(:/Chat/Images/smile_hover.png);
}

#EmojiLabel[state='press']
{
	border-image: url(:/Chat/Images/smile_press.png);
}

/* 文件工具 */
#FileLabel[state='normal']
{
	border-image: url(:/Chat/Images/filedir.png);
}

#FileLabel[state='hover']
{
	border-image: url(:/Chat/Images/filedir_hover.png);
}

#FileLabel[state='press']
{
	border-image: url(:/Chat/Images/filedir_press.png);
}

1.4.1.4 文本编辑框

自定义消息编辑框

.H

/* 聊天信息 */
struct MsgInfo {
	QString MsgType;					// 文件,图片,文本
	QString MsgContent;					// 文本内容
	QPixmap MsgPicture;					// 图片内容
};


#ifndef _MESSAGETEXTEDIT_H_
#define _MESSAGETEXTEDIT_H_

#include <QTextEdit>
#include <QObject>
#include <QMouseEvent>
#include <QApplication>
#include <QDrag>
#include <QMimeData>
#include <QMimeType>
#include <QFileInfo>
#include <QFileIconProvider>
#include <QPainter>
#include <QVector>
#include <QEvent>
#include "Struct.h"


class MessagetextEdit  : public QTextEdit
{
	Q_OBJECT

public:
	MessagetextEdit(QWidget *parent = 0);
	~MessagetextEdit();

public:
	QVector<MsgInfo> GetMsgList();
	void InsertFileFromUrl(const QStringList& urls);

signals:
	void send();

protected:
	/* 拖放事件 */
	virtual void dragEnterEvent(QDragEnterEvent *event) override;						// 拖放对象到窗口
	virtual void dropEvent(QDropEvent *event) override;									// 当拖放对象到窗口时,执行释放动作时
	
	/* 键盘事件 */
	virtual void keyPressEvent(QKeyEvent *event) override;

	/* 事件过滤器 */
	bool eventFilter(QObject *obj, QEvent *event) override;

private:
	void InsertImages(const QString& url);
	void InsertTextFile(const QString& url);
	bool CanInsertFromMimeData(const QMimeData* source) const;
	void InsertFromMimeData(const QMimeData* source);

	void FindImageByPlaceholder(QVector<MsgInfo>& msgList);								// 根据文本占位符查找对应的图片或者文本

private:
	bool IsImage(QString url);
	void InsertMsgList(QVector<MsgInfo>& msgList, QString type, QString text, QPixmap picture);

	QStringList GetUrl(QString text);
	QPixmap GetFileIconPixmap(const QString& url);
	QString GetFileSize(qint64 size);

private slots:
	void textEditChanged();

private:
	QVector<MsgInfo> mMsgList;															// 存储已经发送过的聊天图片
	QVector<MsgInfo> mGetMsgList;														// 返回发送的信息,按格式进行拆分
};

#endif //_MESSAGETEXTEDIT_H_

.CPP

#include "MessagetextEdit.h"
#include <QMessageBox>
#include <QDebug>
#include <QClipboard>
#include "Macro.h"

#define MAX_FILE_SIZE 100 * 1024 * 1024


MessagetextEdit::MessagetextEdit(QWidget *parent)
	: QTextEdit(parent)
{
	setMaximumHeight(60);
	installEventFilter(this);
}

MessagetextEdit::~MessagetextEdit()
{}

/* 当发送按钮点击时,判断文本类型  doc =  "我 '特殊字符' 你" 当插入图片时 */
QVector<MsgInfo> MessagetextEdit::GetMsgList()
{
	mGetMsgList.clear();
	QString type = MSG_TYPE_TEXT;

	QString doc = document()->toPlainText();
	QString text = "";

	qDebug() << "doc = " << doc;

	// 遍历文本的每一个字符
	for (int index = 0; index < doc.size(); index++)
	{
		// 如果遇到文本替换符,则将当前文本添加到消息列表中
		if (doc[index] == QChar::ObjectReplacementCharacter)
		{
			if (!text.isEmpty())
			{
				QPixmap pix;
				InsertMsgList(mGetMsgList, type, text, pix);
				text.clear();
			}

			FindImageByPlaceholder(mGetMsgList);
		}
		else
		{
			text.append(doc[index]);
		}
	}

	if (!text.isEmpty())
	{
		QPixmap pix;
		InsertMsgList(mGetMsgList, type, text, pix);
		text.clear();
	}

	mMsgList.clear();
	clear();
	return mGetMsgList;
}

void MessagetextEdit::InsertFileFromUrl(const QStringList& urls)
{
	if (urls.isEmpty())
		return;

	for (auto url : urls)
	{
		if (IsImage(url))
			InsertImages(url);
		else
			InsertTextFile(url);
	}
}

void MessagetextEdit::dragEnterEvent(QDragEnterEvent* event)
{
	// 自己发起的拖拽事件忽略
	if (event->source() == this)
	{
		event->ignore();
	}
	else
	{
		event->accept();
	}
}

void MessagetextEdit::dropEvent(QDropEvent* event)
{
	InsertFromMimeData(event->mimeData());															// 向mMsgList插入拖拽的数据
	event->accept();
}

void MessagetextEdit::keyPressEvent(QKeyEvent* event)
{
	if (event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return && !(event->modifiers() & Qt::ShiftModifier))
	{
		emit send();
		return;
	}
	QTextEdit::keyPressEvent(event);
}

/* 重载粘贴事件 */
bool MessagetextEdit::eventFilter(QObject* obj, QEvent* event)
{
	if (obj == this && event->type() == QEvent::KeyPress)
	{
		QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
		if (keyEvent->matches(QKeySequence::Paste))
		{
			const QMimeData* mimeData = QApplication::clipboard()->mimeData();
			InsertFromMimeData(mimeData);
			return true;
		}
	}
	return false;
}

bool MessagetextEdit::CanInsertFromMimeData(const QMimeData* source) const
{
	return QTextEdit::canInsertFromMimeData(source);
}

void MessagetextEdit::InsertFromMimeData(const QMimeData* source)
{
	qDebug() << "source->text() = " << source->text();
	
	// 从拖拽数据中提取文本,source->text()返回的是文本的路径,格式为file:///C:/Users/Mars/Desktop/1.png
	QStringList urls = GetUrl(source->text());									
	
	if (urls.isEmpty())
		return;

	for (auto url : urls)
	{
		if(IsImage(url))
			InsertImages(url);
		else
			InsertTextFile(url);
	}
}

void MessagetextEdit::InsertImages(const QString& url)
{
	QString type = MSG_TYPE_IMAGE;
	QImage image(url);
	if (image.width() > 120 || image.height() > 80)
	{
		if (image.width() > image.height())
			image = image.scaledToWidth(120, Qt::SmoothTransformation);
		else
			image = image.scaledToHeight(80, Qt::SmoothTransformation);
	}

	QTextCursor cursor = textCursor();
	cursor.insertImage(image, url);
	InsertMsgList(mMsgList, type, url, QPixmap::fromImage(image));
}

void MessagetextEdit::InsertTextFile(const QString& url)
{
	QString type = MSG_TYPE_FILE;
	QFileInfo fileInfo(url);
	if (fileInfo.isDir())
	{
		QMessageBox::information(this, QString::fromLocal8Bit("提示"), QString::fromLocal8Bit("只允许拖拽单个文件"));
		return;
	}

	if (fileInfo.size() > MAX_FILE_SIZE)
	{
		QMessageBox::information(this, QString::fromLocal8Bit("提示"), QString::fromLocal8Bit("文件大小不能超过100M"));
		return;
	}

	QPixmap picture = GetFileIconPixmap(url);
	QTextCursor cursor = textCursor();
	cursor.insertImage(picture.toImage(), url);
	InsertMsgList(mMsgList, type, url, picture);
}

/* 通过占位符去msgList中查找图片 */
void MessagetextEdit::FindImageByPlaceholder(QVector<MsgInfo>& msgList)
{
	int indexUrl = 0;
	while (indexUrl < mMsgList.size())
	{
		MsgInfo msg = mMsgList[indexUrl];
		if (document()->toHtml().contains(msg.MsgContent, Qt::CaseSensitive))
		{
			indexUrl++;
			msgList.append(msg);
			break;
		}
		indexUrl++;
	}
}

bool MessagetextEdit::IsImage(QString url)
{
	QString imageFormat = "bmp,jpg,png,tif,gif,pcx,tga,exif,fpx,svg,psd,cdr,pcd,dxf,ufo,eps,ai,raw,wmf,webp";
	QStringList imageFormatList = imageFormat.split(",");
	QFileInfo fileInfo(url);

	QString suffix = fileInfo.suffix();												// 获取文件名的后缀例如.png
	if (imageFormatList.contains(suffix, Qt::CaseInsensitive))
	{
		return true;
	}
	return false;
}

void MessagetextEdit::InsertMsgList(QVector<MsgInfo>& msgList, QString type, QString text, QPixmap picture)
{
	MsgInfo msgInfo;
	msgInfo.MsgType = type;
	msgInfo.MsgContent = text;
	msgInfo.MsgPicture = picture;
	msgList.append(msgInfo);
}

/* 对file:///C:/Users/Mars/Desktop/1.png 格式的文本进行解析,返回文件路径列表 */
QStringList MessagetextEdit::GetUrl(QString text)
{
	QStringList urls;

	if (text.isEmpty())
		return urls;

	QStringList lines = text.split("\n", Qt::SkipEmptyParts);					// 以换行符分割文本

	// 遍历每一行文本
	for (auto line : lines)
	{
		qDebug() << "line = " << line;

		line = line.trimmed();													// 去除文本两端的空白字符

		if (!line.isEmpty())
		{
			// 将文本解析成URL
			QUrl url(line);

			// 如果是本地文件
			if (url.isLocalFile())
			{
				urls.append(url.toLocalFile());
			}
			else 
			{
				// 创建文件信息对象检查路径
				QFileInfo fileInfo(line);

				// 确认路径在文件系统中真实存在
				if (fileInfo.exists()) 
				{
					// 添加到结果列表中
					urls.append(line);
				}
			}
		}
	}
	return urls;
}

QPixmap MessagetextEdit::GetFileIconPixmap(const QString& url)
{
	// 1.获取文件图标
	QFileIconProvider iconProvider;
	QFileInfo fileInfo(url);
	QIcon icon = iconProvider.icon(fileInfo);

	// 2.获取文件大小
	QString fileSizeStr = GetFileSize(fileInfo.size());
	qDebug() << "FileSize = " << fileSizeStr;
	
	// 3.设置字体
	QFont font(QString::fromLocal8Bit("宋体"), 10, QFont::Normal, false);
	QFontMetrics fontMetrics(font);

	// 4.获取文本宽度
	QSize textSize = fontMetrics.size(Qt::TextSingleLine, fileInfo.fileName());
	QSize fileSize = fontMetrics.size(Qt::TextSingleLine, fileSizeStr);
	int maxWidth = textSize.width() > fileSize.width() ? textSize.width() : fileSize.width();
	QPixmap pixmap(50 + maxWidth + 10, 50);
	pixmap.fill();

	// 5.绘制图片
	QPainter painter;
	painter.begin(&pixmap);

	QRect rect(0, 0, 50, 50);
	painter.drawPixmap(rect, icon.pixmap(40, 40));
	painter.setPen(Qt::black);

	QRect rectFileName(50 + 10, 3, textSize.width(), textSize.height());
	painter.drawText(rectFileName, fileInfo.fileName());

	QRect rectFileSize(50 + 10, textSize.height() + 5, fileSize.width(), fileSize.height());
	painter.drawText(rectFileSize, fileSizeStr);
	painter.end();

	return pixmap;
}

QString MessagetextEdit::GetFileSize(qint64 size)
{
	QString Unit;
	double num;
	if (size < 1024)
	{
		num = size;
		Unit = "B";
	}
	else if (size < 1024 * 1024)
	{
		num = size / 1024.0;
		Unit = "KB";
	}
	else if (size < 1024 * 1024 * 1024)
	{
		num = size / (1024.0 * 1024.0);
		Unit = "MB";
	}
	else
	{
		num = size / (1024.0 * 1024.0 * 1024.0);
		Unit = "GB";
	}
	return QString::number(num, 'f', 2) + " " + Unit;
}

void MessagetextEdit::textEditChanged()
{
}

样式表

/* 用户输入 */
#ChatEdit
{
	background: #ffffff;
	border: none;
	font-family: "Microsoft YaHei";
	font-size: 18px;
	padding: 5px;
}

整体流程在SendWnd中进行串联

1.4.1.5 发送窗口SendWnd

自定义控件ClickedBtn

.H

#ifndef CLICKEDBTN_H
#define CLICKEDBTN_H

#include <QPushButton>

class ClickedBtn  : public QPushButton
{
	Q_OBJECT

public:
	ClickedBtn(QWidget *parent = 0);
	~ClickedBtn();

public:
	void SetState(QString normal, QString hover, QString pressed);
	void UpdateStyleSheet(QString str);													

protected:
	virtual void mousePressEvent(QMouseEvent *event) override;
	virtual void mouseReleaseEvent(QMouseEvent *event) override;
	virtual void enterEvent(QEvent *event) override;
	virtual void leaveEvent(QEvent *event) override;

private:
	QString _normal;
	QString _hover;
	QString _pressed;
};

#endif // CLICKEDBTN_H


.CPP

#include "ClickedBtn.h"
#include "Global.h"

ClickedBtn::ClickedBtn(QWidget *parent)
	: QPushButton(parent)
{
	setCursor(Qt::PointingHandCursor);
	setFocusPolicy(Qt::NoFocus);
}

ClickedBtn::~ClickedBtn()
{}

void ClickedBtn::SetState(QString normal, QString hover, QString pressed)
{
	_normal = normal;
	_hover = hover;
	_pressed = pressed;
	UpdateStyleSheet(_normal);
}

void ClickedBtn::mousePressEvent(QMouseEvent* event)
{
	UpdateStyleSheet(_pressed);
	QPushButton::mousePressEvent(event);
}

void ClickedBtn::mouseReleaseEvent(QMouseEvent* event)
{
	UpdateStyleSheet(_hover);
	QPushButton::mouseReleaseEvent(event);
}

void ClickedBtn::enterEvent(QEvent* event)
{
	UpdateStyleSheet(_hover);
	QPushButton::enterEvent(event);
}

void ClickedBtn::leaveEvent(QEvent* event)
{
	UpdateStyleSheet(_normal);
	QPushButton::leaveEvent(event);
}

void ClickedBtn::UpdateStyleSheet(QString str)
{
	setProperty("state", str);
	repolish(this);
	update();
}

样式表

/* 接收按钮 */
#RecvBtn[state='normal']
{
	background-color: #f0f0f0;
	color: #2cb46e;
	font-family: "Microsoft YaHei";
	font-size: 16px;
	border-radius: 20px;					/* 设置圆角 */
}

#RecvBtn[state='hover']
{
	background: #d2d2d2;
	color: #2cb46e;
	font-family: "Microsoft YaHei";
	font-size: 16px;
	border-radius: 20px;					/* 设置圆角 */
}

#RecvBtn[state='press']
{
	background: #c6c6c6;
	color: #2cb46e;
	font-family: "Microsoft YaHei";
	font-size: 16px;
	border-radius: 20px;					/* 设置圆角 */
}

/* 发送按钮 */
#SendBtn[state='normal']
{
	background-color: #f0f0f0;
	color: #2cb46e;
	font-family: "Microsoft YaHei";
	font-size: 16px;
	border-radius: 20px;					/* 设置圆角 */
}

#SendBtn[state='hover']
{
	background: #d2d2d2;
	color: #2cb46e;
	font-family: "Microsoft YaHei";
	font-size: 16px;
	border-radius: 20px;					/* 设置圆角 */
}

#SendBtn[state='press']
{
	background: #c6c6c6;
	color: #2cb46e;
	font-family: "Microsoft YaHei";
	font-size: 16px;
	border-radius: 20px;					/* 设置圆角 */
}

在ChatPage中进行初始化

ui.RecvBtn->SetState("normal", "hover", "press");
ui.SendBtn->SetState("normal", "hover", "press");

1.4.2 发送信息的逻辑梳理

1.4.2.1 当发送按钮点击时
ChatPage::ChatPage(QWidget *parent)
	: QWidget(parent)
{
	ui.setupUi(this);
	ui.RecvBtn->SetState("normal", "hover", "press");
	ui.SendBtn->SetState("normal", "hover", "press");
	ui.EmojiLabel->SetState("normal", "hover", "press", "normal", "hover", "press");
	ui.FileLabel->SetState("normal", "hover", "press", "normal", "hover", "press");
	update();
	BindSlots();
}
void ChatPage::BindSlots()
{
	connect(ui.SendBtn, &QPushButton::clicked, this, &ChatPage::OnSendBtnClicked);
	connect(ui.ChatEdit, &MessagetextEdit::send, this, &ChatPage::OnSendBtnClicked);
}
void ChatPage::OnSendBtnClicked()
{
	MessagetextEdit* textEdit = ui.ChatEdit;
	ChatRole role = Self;
	QString userName = QString::fromLocal8Bit("Mr.Lu");
	QString userIcon = ":/Chat/Images/head_2.jpg";

	const QVector<MsgInfo>& msgList = textEdit->GetMsgList();					// 从编辑框获取已经拆解的输入文本

	// 遍历消息列表,创建对应的消息气泡并添加到聊天视图中
	for (int i = 0; i < msgList.size(); i++)
	{
		QString type = msgList[i].MsgType;
		ChatItemBase* item = new ChatItemBase(role);
		item->SetUserName(userName);
		item->SetUserIcon(QPixmap(userIcon));
		QWidget* bubble = nullptr;

		if (type == MSG_TYPE_TEXT)
		{
			bubble = new TextBubble(role, msgList[i].MsgContent);
		}
		else if (type == MSG_TYPE_IMAGE)
		{
			bubble = new PictureBubble(role, msgList[i].MsgPicture);
		}
		else if (type == MSG_TYPE_FILE)
		{

		}

		if (bubble)
		{
			item->SetWidget(bubble);
			ui.chatView->AppendChatItem(item);
		}
	}
}

1.4.2.2 聊天信息框基类

该类使用C++实现,可以用控件来组合成不同的UI布局,不需要创建两套UI了

// 角色类型
enum ChatRole
{
    Self,                                       // 自己
    Other,                                      // 其他人
};

.H

#ifndef CHATITEMBASE_H
#define CHATITEMBASE_H

#include <QWidget>
#include <QGridLayout>
#include <QLabel>
#include "Enum.h"
#include "ui_ChatItemBase.h"

class ChatItemBase : public QWidget
{
	Q_OBJECT

public:
	ChatItemBase(ChatRole role, QWidget *parent = nullptr);
	~ChatItemBase();

public:
	void SetUserName(const QString& name);
	void SetUserIcon(const QPixmap& icon);
	void SetWidget(QWidget* widget);

private:
	Ui::ChatItemBaseClass ui;
	ChatRole m_role;
	QLabel* m_nameLabel;
	QLabel* m_iconLabel;
	QWidget* m_bubble;
	QGridLayout* pGLayout;
};

#endif // CHATITEMBASE_H

.CPP

#include "ChatItemBase.h"

ChatItemBase::ChatItemBase(ChatRole role, QWidget *parent)
	: m_role(role), QWidget(parent)
{
	ui.setupUi(this);
	
	// 名称
	m_nameLabel = new QLabel();
	m_nameLabel->setObjectName("nameLabel");
	QFont font("Microsoft YaHei");
	font.setPointSize(9);
	m_nameLabel->setFont(font);
	m_nameLabel->setFixedHeight(20);

	// 头像
	m_iconLabel = new QLabel();
	m_iconLabel->setScaledContents(true);
	m_iconLabel->setFixedSize(42, 42);

	// 绿泡泡信息背景
	m_bubble = new QWidget();

	// 栅格布局
	pGLayout = new QGridLayout;
	pGLayout->setVerticalSpacing(3);
	pGLayout->setHorizontalSpacing(3);
	pGLayout->setMargin(3);

	// 弹簧
	QSpacerItem* pSpacer = new QSpacerItem(40, 20, QSizePolicy::Expanding, QSizePolicy::Minimum);
	
	// 判断消息类型,是自己发送的还是别人发送的,然后进行拼接
	if (m_role == ChatRole::Self)
	{
		m_nameLabel->setContentsMargins(0, 0, 8, 0);
		m_nameLabel->setAlignment(Qt::AlignRight);
		pGLayout->addWidget(m_nameLabel, 0, 1, 1, 1);
		pGLayout->addWidget(m_iconLabel, 0, 2, 2, 1, Qt::AlignTop);
		pGLayout->addItem(pSpacer, 1, 0, 1, 1);
		pGLayout->addWidget(m_bubble, 1, 1, 1, 1);
		pGLayout->setColumnStretch(0, 2);
		pGLayout->setColumnStretch(1, 3);
	}
	else
	{
		m_nameLabel->setContentsMargins(8, 0, 0, 0);
		m_nameLabel->setAlignment(Qt::AlignLeft);
		pGLayout->addWidget(m_iconLabel, 0, 0, 2, 1, Qt::AlignTop);
		pGLayout->addWidget(m_nameLabel, 0, 1, 1, 1);
		pGLayout->addWidget(m_bubble, 1, 1, 1, 1);
		pGLayout->addItem(pSpacer, 2, 2, 1, 1);
		pGLayout->setColumnStretch(2, 2);
		pGLayout->setColumnStretch(1, 3);
	}
	setLayout(pGLayout);
}

ChatItemBase::~ChatItemBase()
{}

void ChatItemBase::SetUserName(const QString & name)
{
	m_nameLabel->setText(name);
}

void ChatItemBase::SetUserIcon(const QPixmap& icon)
{
	m_iconLabel->setPixmap(icon);
}

void ChatItemBase::SetWidget(QWidget* widget)
{
	pGLayout->replaceWidget(m_bubble, widget);
	delete m_bubble;
	m_bubble = widget;
}

最后将布局中的绿泡泡窗口替换过去,一个聊天信息就创建好了

如图所示,接下来是绿泡泡窗口

1.4.2.3 绿泡泡窗口

代码实现

.H

#ifndef BUBBLEFRAME_H
#define BUBBLEFRAME_H

#include <QFrame>
#include <QPaintEvent>
#include <QHBoxLayout>
#include "Enum.h"

class BubbleFrame  : public QFrame
{
	Q_OBJECT

public:
	BubbleFrame(ChatRole role, QWidget* parent = 0);
	~BubbleFrame();

public:
	void AddWidget(QWidget* widget);
	void SetMargin(int margin);

protected:
	virtual void paintEvent(QPaintEvent* event) override;

private:
	QHBoxLayout* m_Hayout;
	ChatRole m_Role;
	int m_Margin;
};

#endif // BUBBLEFRAME_H


.CPP

#include "BubbleFrame.h"

#include <QPainter>
#include <QDebug>

#define WIDTH_TRIANGLE 8

BubbleFrame::BubbleFrame(ChatRole role, QWidget* parent)
	:m_Role(role), m_Margin(3), QFrame(parent)
{
	m_Hayout = new QHBoxLayout;
	if (m_Role == Self)
	{
		m_Hayout->setContentsMargins(m_Margin, m_Margin, WIDTH_TRIANGLE + m_Margin, m_Margin);
	}
	else
	{
		m_Hayout->setContentsMargins(WIDTH_TRIANGLE + m_Margin, m_Margin, m_Margin, m_Margin);
	}
	setLayout(m_Hayout);
	update();
}

BubbleFrame::~BubbleFrame()
{}

void BubbleFrame::AddWidget(QWidget * widget)
{
	if(m_Hayout->count() > 0)
		return;
	m_Hayout->addWidget(widget);
}

void BubbleFrame::SetMargin(int margin)
{
	m_Margin = margin;
}

void BubbleFrame::paintEvent(QPaintEvent* event)
{
	QPainter painter(this);
	painter.setPen(Qt::NoPen);

	if (m_Role == Other)
	{
		QColor bkColor(Qt::white);
		painter.setBrush(bkColor);
		QRect bkRect(WIDTH_TRIANGLE, 0, width() - WIDTH_TRIANGLE, height());
		painter.drawRoundedRect(bkRect, 5, 5);

		// draw triangle
		QPointF points[3] =
		{
			QPointF(bkRect.x(), 12),
			QPointF(bkRect.x() - WIDTH_TRIANGLE, 12),
			QPointF(bkRect.x(), 10 + 1.5 * WIDTH_TRIANGLE),
		};
		painter.drawPolygon(points, 3);
	}
	else
	{
		QColor bkColor(158, 234, 106);
		painter.setBrush(bkColor);
		QRect bkRect(0, 0, width() - WIDTH_TRIANGLE, height());
		painter.drawRoundedRect(bkRect, 5, 5);

		// draw triangle
		QPointF points[3] =
		{
			QPointF(bkRect.right(), 12),
			QPointF(bkRect.right() + WIDTH_TRIANGLE, 12),
			QPointF(bkRect.right(), 10 + 1.5 * WIDTH_TRIANGLE),
		};
		painter.drawPolygon(points, 3);
	}
}

1.4.2.4 文本绿泡泡窗口
.H

#ifndef TEXTBUBBLE_H
#define TEXTBUBBLE_H
#include "BubbleFrame.h"
#include <QTextEdit>

class TextBubble : public BubbleFrame
{
	Q_OBJECT

public:
	TextBubble(ChatRole role, const QString& text, QWidget *parent = 0);
	~TextBubble();

protected:
	virtual bool eventFilter(QObject* obj, QEvent* event) override;

private:
	void adjustTextHeight();
	void setPlainText(const QString& text);
	void InitStyleSheet();

private:
	QTextEdit* m_textEdit;
};

#endif // TEXTBUBBLE_H


.CPP

#include "TextBubble.h"
#include <QFontMetricsF>
#include <QDebug>
#include <QFont>
#include <QTimer>
#include <QTextBlock>
#include <QTextLayout>

TextBubble::TextBubble(ChatRole role, const QString& text, QWidget *parent)
	: BubbleFrame(role, parent)
{
	m_textEdit = new QTextEdit;
	m_textEdit->setReadOnly(true);
	m_textEdit->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	m_textEdit->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
	m_textEdit->installEventFilter(this);

	QFont font("Microsoft YaHei", 12);
	m_textEdit->setFont(font);
	setPlainText(text);
	AddWidget(m_textEdit);
	InitStyleSheet();
}

TextBubble::~TextBubble()
{}

bool TextBubble::eventFilter(QObject * obj, QEvent * event)
{
	// 当文本编辑框触发重绘时
	if (m_textEdit == obj && event->type() == QEvent::Paint)
	{
		adjustTextHeight();
	}
	return QObject::eventFilter(obj, event);
}

void TextBubble::adjustTextHeight()
{
	qreal docMargin = m_textEdit->document()->documentMargin();									// 获取字体到边框的距离
	QTextDocument* doc = m_textEdit->document();												// 获取文档对象
	qreal textHeight = 0;

	// 遍历每一段,计算每一段的高度
	for (QTextBlock it = doc->begin(); it != doc->end(); it = it.next())
	{
		QTextLayout* layout = it.layout();														// 获取每一段的布局对象
		QRectF textRect = layout->boundingRect();												// 获取每一段的边界矩形
		textHeight += textRect.height();														// 累加每一段的高度
	}
	
	int vMargin = layout()->contentsMargins().top();
	setFixedHeight(textHeight + docMargin * 2 + vMargin * 2);									// 设置气泡的高度
}

void TextBubble::setPlainText(const QString& text)
{
	m_textEdit->setPlainText(text);
	qreal docMargin = m_textEdit->document()->documentMargin();									// 获取字体到边缘的距离
	int margin_left = layout()->contentsMargins().left();										// 获取文本到左边框的距离
	int margin_right = layout()->contentsMargins().right();										// 获取文本到右边框的距离
	QFontMetricsF metrics(m_textEdit->font());													// 获取字体的高度和宽度
	QTextDocument* doc = m_textEdit->document();												// 获取文本对象
	int maxWidth = 0;

	// 遍历每一段,计算每一段的宽度
	for (QTextBlock it = doc->begin(); it != doc->end(); it = it.next())
	{
		int txtWidth = metrics.width(it.text());
		maxWidth = qMax(maxWidth, txtWidth);
	}

	// 设置气泡的最大宽度
	setMaximumWidth(maxWidth + docMargin * 2 + (margin_right + margin_left));
}

void TextBubble::InitStyleSheet()
{
	m_textEdit->setStyleSheet("QTextEdit{background:transparent;border:none;}");
}

1.4.2.5 图片绿泡泡
.H

#ifndef PICTUREBUBBLE_H
#define PICTUREBUBBLE_H

#include "BubbleFrame.h"

class PictureBubble  : public BubbleFrame
{
	Q_OBJECT

public:
	PictureBubble(ChatRole role, const QPixmap& picture, QWidget *parent = 0);
	~PictureBubble();
};

#endif // PICTUREBUBBLE_H


.CPP

#include "PictureBubble.h"
#include <QLabel>

#define PICTURE_MAX_WIDTH 160
#define PICTURE_MAX_HEIGHT 90

PictureBubble::PictureBubble(ChatRole role, const QPixmap& picture, QWidget* parent)
	:BubbleFrame(role, parent)
{
	// 设置图片并缩放
	QLabel* label = new QLabel;
	label->setScaledContents(true);
	QPixmap _pixmap = picture.scaled(QSize(PICTURE_MAX_WIDTH, PICTURE_MAX_HEIGHT), Qt::KeepAspectRatio, Qt::SmoothTransformation);
	label->setPixmap(_pixmap);
	AddWidget(label);

	// 设置窗口尺寸
	int leftMargin = layout()->contentsMargins().left();
	int rightMargin = layout()->contentsMargins().right();
	int vMargin = layout()->contentsMargins().bottom();
	setFixedSize(_pixmap.width() + leftMargin + rightMargin, _pixmap.height() + vMargin * 2);
}

PictureBubble::~PictureBubble()
{}

1.4.2.6 总结

当发送按钮点击时会解析文本编辑框中输入的文本,然后返回一个消息列表,遍历消息文本,如果有图片会将该消息拆分,然后根据消息类型创建不同的类型的绿泡泡插入到聊天消息布局的窗口中

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值