当前位置: 首页 > article >正文

微服务即时通讯系统的实现(客户端)----(1)

目录

  • 1. 项目整体介绍
    • 1.1 项目概况
    • 1.2 界面预览和功能介绍
    • 1.3 技术重点和服务器架构
  • 2. 项目环境搭建
    • 2.1 安装Qt6
    • 2.3 安装vcpkg
    • 2.3 安装protobuf
    • 2.4 构建项目
    • 2.5 配置CMake属性
  • 3. 项目核心数据结构的实现
    • 3.1 创建data.h存放核心的类
    • 3.2 工具函数的实现
    • 3.3 创建编译开关
  • 4. 界面整体布局的实现
    • 4.1 主页面的布局和实现
    • 4.2 主界面左侧页面各个功能的实现
    • 4.3 主界面中间页面各个功能的实现
    • 4.4 主界面右侧页面各个功能的实现
      • 4.4.1 实现会话标题栏
      • 4.4.2 实现消息展示区域
      • 4.4.3 实现消息编辑区域
  • 5. 实现主界面各个按钮的点击功能
    • 5.1 实现个人信息详细界面
    • 5.2 实现用户详细信息界面
    • 5.3 实现单聊消息会话详细信息界面
    • 5.4 实现创建群聊会话选择好友界面
    • 5.5 实现群聊消息会话详细信息界面
    • 5.6 实现添加好友界面
    • 5.7 实现历史消息界面
  • 6. 用户名登录/注册界面的实现
  • 7. 实现手机号登录/注册界面
  • 8. 实现全局通知类
  • 9. 构建界面注意事项
  • 10. 将项目所需要的图片导入Qt项目中

1. 项目整体介绍

1.1 项目概况

本项目是基于 C++ 的实现⼀个客户端-服务器结构的聊天程序。

  • 客户端:基于 Qt 6的实现。
  • 服务器:基于 C++ 的分布式微服务架构 + 主流后端组件。
服务器微服务个数7 个
服务器组件个数17 个
业务功能点40+
前后端交互接口40+
数据库表个数6 个
总代码量1.8w

1.2 界面预览和功能介绍

(1)下面所展示的界面是在没有接入服务器的情况下展示的效果:

(2)以下是客户端主要功能图:

1.3 技术重点和服务器架构

(1)本项目在设计的时候采用微服务框架设计,微服务就是将⼀个大的业务拆分称为多个子业务,分别在多台不同的机器节点上提供对应的服务,由网关服务统⼀接收多个客户端的各种不同请求,然后将请求分发到不同的子服务节点上进行处理,获取响应后,再转发给客户端。


(2)模块层次:


(3)服务拆分:

  • 入口网关服务器:主要用于与客户端直接交互,接收客户端的各项请求提供服务。
  • 用户管理子服务:主要用于管理⽤⼾的数据,以及关于⽤户信息的各项操作。
  • 好友管理子服务:主要用于管理好友与聊天会话管理相关的数据与操作。
  • 转发管理子服务:主要用于封装消息进行转存,然后告诉网关服务器⼀条消息应该发给谁。
  • 消息存储子服务:主要用于进行消息元信息的存储与搜索功能。
  • 文件管理子服务:主要用于管理系统中文件类型数据的存储,比如用户头像,文件消息等。
  • 语音转换子服务:用于调用语音识别SDK,进行语音识别,将语音转换为文字。

(4)技术重点:

  • gflags:针对程序运行所需的运行参数解析/配置文件解析框架。
  • gtest:针对程序编写到⼀定阶段后,进行的单元测试框架。
  • spdlog:针对项目中进行日志输出的框架。
  • protobuf:针对项目中的网络通信数据所采用的序列化和反序列化框架。
  • brpc:项目中的rpc调用使用的框架。
  • redis:高性能键值存储系统,用于项目中进行用户登录会话信息的存储管理。
  • mysql:关系型数据库系统,用于项目中的业务数据的存储管理。
  • ODB:项目中mysql数据库操作的ORM框架(Object-Relational Mapping,对象关系映射)。
  • Etcd:分布式、高可用的⼀致性键值存储系统,用于项目中实现服务注册与发现功能的框架。
  • cpp-httplib:用于搭建简单轻量HTTP服务器的框架。
  • websocketpp:用于搭建Websocket服务器的框架。
  • rabbitMQ:用于搭建消息队列服务器,用于项⽬中持久化消息的转发消费。
  • elasticsearch:用于搭建⽂档存储/搜索服务器,用于项⽬中历史消息的存储管理
  • 语音云平台:采用百度语音识别技术云平台实现语音转文字功能。
  • 短信云平台:采用阿里云短信云平台实现手机短信验证码通知功能。
  • cmake:项目工程的构建工具。
  • docker:项目工程的⼀键式部署工具。

2. 项目环境搭建

2.1 安装Qt6

(1)Qt6链接:https://www.qt.io/download-qt-installer-oss?hsCtaTracking=99d9dd4f-5681-48d2-b096-470725510d34%7C074ddad0-fdef-4e53-8aa8-5e8a876d6ab4


(2)注册登录Qt账户:


(3)进行到选择组件的时候勾选6.7及以上:


Qt6项目中,一旦代码规模变大了,使用MinGW编译速度就会慢很多。MSVC:vs自带编译器,必须配合VS2019 及其以上版。

(4)将Additional Libraries全部勾选上:

(5)开发工具勾选:


之后的安装只需要一直next即可。

2.3 安装vcpkg

(1)vcpkg 是一个开源的跨平台 C++ 包管理工具,用于简化库的下载、构建和管理过程。它支持多种操作系统,并自动处理依赖关系。参考文档:https://learn.microsoft.com/zh-cn/vcpkg/get_started/get-started?pivots=shell-powershell

(2)打开cmd命令行将vcpkg克隆下来:

git clone https://github.com/microsoft/vcpkg.git

(3)进入vcpkg并运行:

cd vcpkg && bootstrap-vcpkg.bat

2.3 安装protobuf

(1)在cmd命令行当中运行如下代码(必须在vcpkg文件当中运行):

./vcpkg.exe install protobuf protobuf:x64-windows

2.4 构建项目

(1)选择Qt widgets:

(2)选择路径和项目名称:

(3)选择构建项目:

(4)选择主窗口:

(5)选择构建套件:

将上述操作选择玩项目就算是构建完成了,接下来配置CMake。

2.5 配置CMake属性

(1)配置如下属性:



(2)当运行时找不到protobuf时配置需要点击左侧边栏 “项目”,然后修改 cmake的配置项:CMAKE_PREFIX_PATH。添加上 protoc 和 grpc 的路径前缀。当切换 debug release 时也要同时修改上述配置。是添加到 current configuration 标签页, 而不是 Initial Configuration 标签页。


(3)如果未能正确配置,会出现形如:


这样的错误。

3. 项目核心数据结构的实现

3.1 创建data.h存放核心的类

(1)在Header Files当中创建一个model文件夹,将data.h存放在此处。


(2)核心数据类的实现:

//
/// 用户信息
//
class UserInfo
{
public:
    QString userId = "";         // 用户编号
    QString nickname = "";       // 用户昵称
    QString description = "";    // 用户签名
    QString phone = "";          // 手机号码
    QIcon avatar;                // 用户头像
};

//
/// 消息信息
//
enum MessageType
{
    TEXT_TYPE,		// 文本消息
    IMAGE_TYPE, 	// 图片消息
    FILE_TYPE, 		// 文件消息
    SPEECH_TYPE 	// 语音消息
};

class Message
{
public:
    QString messageId = "";               // 消息的编号
    QString chatSessionId = "";         // 消息所属会话的编号
    QString time = "";                  // 消息的时间. 通过 "格式化" 时间的方式来表示. 形如 06-07 12:00:00
    MessageType messageType = TEXT_TYPE;// 消息类型
    UserInfo sender;					// 发送者的信息
    QByteArray content;					// 消息的正文内容
    QString fileId = "";				// 文件的身份标识. 当消息类型为 文件, 图片, 语音 的时候, 才有效. 当消息类型为 文本, 则为 ""
    QString fileName = ""; 				// 文件名称. 只是当消息类型为 文件 消息, 才有效. 其他消息均为 ""

    // 此处 extraInfo 目前只是在消息类型为文件消息时, 作为 "文件名" 补充.
    static Message makeMessage(MessageType messageType, const QString& chatSessionId,
                               const UserInfo& sender, const QByteArray& content,
                               const QString& extraInfo)
    {
        if(messageType == TEXT_TYPE)
        {
            return makeTextMessage(chatSessionId, sender, content);
        }
        else if(messageType == IMAGE_TYPE)
        {
            return makeImageMessage(chatSessionId, sender, content);
        }
        else if(messageType == FILE_TYPE)
        {
            return makeFileMessage(chatSessionId, sender, content, extraInfo);
        }
        else if(messageType == SPEECH_TYPE)
        {
            return makeSpeechMessage(chatSessionId, sender, content);
        }
        else
        {
            // 触发了未知的消息类型
            return Message();
        }
    }

private:
    // 通过这个方法生成唯一的 messageId
    static QString makeId()
    {
        return "M" + QUuid::createUuid().toString().sliced(25, 12);
    }

    static Message makeTextMessage(const QString& chatSessionId,
                                   const UserInfo& sender, const QByteArray& content)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = TEXT_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // 对于文本消息来说, 这俩属性不使用, 设为 ""
        message.fileId = "";
        message.fileName = "";

        return message;
    }

    static Message makeImageMessage(const QString& chatSessionId,
                                   const UserInfo& sender, const QByteArray& content)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = IMAGE_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // fileId 后续使用的时候再进一步设置
        message.fileId = "";
        // fileName 不使用, 直接设为 ""
        message.fileName = "";
        return message;
    }

    static Message makeFileMessage(const QString& chatSessionId, const UserInfo& sender,
                                   const QByteArray& content, const QString& fileName)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = FILE_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // fileId 后续使用的时候进一步设置
        message.fileId = "";
        message.fileName = fileName;

        return message;
    }

    static Message makeSpeechMessage(const QString& chatSessionId,
                                   const UserInfo& sender, const QByteArray& content)
    {
        Message message;
        message.messageId = makeId();
        message.chatSessionId = chatSessionId;
        message.messageType = SPEECH_TYPE;
        message.content = content;
        message.sender = sender;
        message.time = formatTime(getTime()); // 生成一个格式化时间

        // fileId 后续使用的时候进一步设置
        message.fileId = "";
        // fileName 不使用, 直接设为 ""
        message.fileName = "";

        return message;
    }
};

//
/// 会话信息
//
class ChatSessionInfo
{
public:
    QString chatSessionId = "";     // 会话编号
    QString chatSessionName = "";   // 会话名字, 如果会话是单聊, 名字就是对方的昵称; 如果是群聊, 名字就是群聊的名称.
    Message lastMessage;		    // 表示最新的消息.
    QIcon avatar;					// 会话头像. 如果会话是单聊, 头像就是对方的头像; 如果是群聊, 头像群聊的头像.
    QString userId = "";			// 对于单聊来说, 表示对方的用户 id, 对于群聊设为 ""
};

3.2 工具函数的实现

(1)项目当中可能会在各个模块当中用到同一个功能函数,所以将其存放在一起使用:

//
/// 工具函数. 后续很多模块可能都要用到
//
static inline QString getFileName(const QString& path)
{
    QFileInfo fileInfo(path);
    return fileInfo.fileName();
}

// 封装一个 "宏" 作为打印日志的方式.
#define TAG QString("[%1:%2]").arg(model::getFileName(__FILE__), QString::number(__LINE__))
// #define TAG "[" << __LINE__ << "]"

// qDebug 打印字符串的时候, 就会自动加上 " "
#define LOG() qDebug().noquote() << TAG

// 要求函数的定义如果写在 .h 中, 必须加 static 或者 inline (当然两个都加也可以), 避免链接阶段出现 "函数重定义" 的问题.
static inline QString formatTime(int64_t timestamp)
{
    // 先把时间戳, 转换成 QDateTime 对象
    QDateTime dateTime = QDateTime::fromSecsSinceEpoch(timestamp);

    // 把 QDateTime 对象转成 "格式化时间"
    return dateTime.toString("MM-dd HH:mm:ss");
}

// 通过这个函数得到 秒级 的时间
static inline int64_t getTime()
{
    return QDateTime::currentSecsSinceEpoch();
}

// 根据 QByteArray, 转成 QIcon
static inline QIcon makeIcon(const QByteArray& byteArray)
{
    QPixmap pixmap;
    pixmap.loadFromData(byteArray);
    QIcon icon(pixmap);
    return icon;
}

// 读写文件操作.
// 从指定文件中, 读取所有的二进制内容. 得到一个 QByteArray
static inline QByteArray loadFileToByteArray(const QString& path)
{
    QFile file(path);
    bool ok = file.open(QFile::ReadOnly);
    if(ok == false)
    {
        LOG() << "文件打开失败!";
        return QByteArray();
    }

    QByteArray content = file.readAll();
    file.close();
    return content;
}

// 把 QByteArray 中的内容, 写入到某个指定文件里
static inline void writeByteArrayToFile(const QString& path, const QByteArray& content)
{
    QFile file(path);
    bool ok = file.open(QFile::WriteOnly);
    if(ok == false)
    {
        LOG() << "文件打开失败!";
        return;
    }

    file.write(content);
    file.flush();
    file.close();
}

3.3 创建编译开关

(1)创建debug.h的头文件:


(2)具体实现的一些功能:

#ifndef DEBUG_H
#define DEBUG_H

// 测试 UI , 显⽰构造的假数据
#define TEST_UI 0

// 测试群组会话详情窗⼝
#define TEST_GROUP_SESSION_DETAIL 1

// 测试跳过登录窗⼝
#define TEST_SKIP_LOGIN 0

// 测试⽹络连通性
#define TEST_NETWORK 0

// 从⽹络获取数据
#define LOAD_DATA_FROM_NETWORK 1

// 是否连接测试服务器
#define CONNECT_TEST_SERVER 0

#endif // DEBUG_H

4. 界面整体布局的实现

4.1 主页面的布局和实现

(1)布局布局展示:

(2)MainWidget.h的实现:

class MainWidget : public QWidget
{
    Q_OBJECT

public:
    static MainWidget* getInstance();

    ~MainWidget();

public:
    void initMainWindow();
    void initLeftWindow();
    void initMidWindow();
    void initRightWindow();

    void initSignalSlot();
    void initWebsocket();

    void switchTabToSession();
    void switchTabToFriend();
    void switchTabToApply();

    void loadSessionList();
    void loadFriendList();
    void loadApplyList();

    void updateFriendList();
    void updateChatSessionList();
    void updateApplyList();

    void loadRecentMessage(const QString& chatSessionId);
    void updateRecentMessage(const QString& chatSessionId);

    // 点击好友项之后, 切换到会话列表的总的函数. 上方的 switchTabToSession 只是其中的一个环节.
    void switchSession(const QString& userId);

    MessageShowArea* getMessageShowArea();

private:
    // 对于单例模式来说, 最关键的部分, 不是 "创建实例" , 而是限制别人创建实例.
    MainWidget(QWidget *parent = nullptr);
    static MainWidget* instance;

private:
    Ui::MainWidget *ui;

    QWidget* windowLeft;        // 窗口最左侧部分
    QWidget* windowMid;         // 窗口中间部分
    QWidget* windowRight;

    QPushButton* userAvatar;    // 用户头像
    QPushButton* sessionTabBtn; // 会话标签页按钮
    QPushButton* friendTabBtn;  // 好友标签页按钮
    QPushButton* applyTabBtn;   // 好友申请标签页按钮

    QLineEdit* searchEdit;      // 用户搜索框
    QPushButton* addFriendBtn;  // 添加好友按钮

    QLabel* sessionTitleLabel;  // 显示会话标题
    QPushButton* extraBtn;      // 显示会话详情按钮

    SessionFriendArea* sessionFriendArea;
    MessageShowArea* messageShowArea;       // 消息展示区
    MessageEditArea* messageEditArea;       // 消息编辑区

    enum ActiveTab
    {
        SESSION_LIST,
        FRIEND_LIST,
        APPLY_LIST
    };

    ActiveTab activeTab = SESSION_LIST;
};

(3)MainWidget.cpp的实现:

MainWidget* MainWidget::instance = nullptr;

MainWidget* MainWidget::getInstance()
{
    if(instance == nullptr)
    {
        // 此处不传入参数, 以桌面为父窗口.
        // 由于此处的窗口是整个程序的主窗口, 父窗口就设定为桌面, 本身就是常规设定.
        instance = new MainWidget();
    }

    return instance;
}

MainWidget::~MainWidget()
{
    delete ui;
}

MainWidget::MainWidget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::MainWidget)
{
    ui->setupUi(this);

    this->setWindowTitle("我的微信");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));

    initMainWindow();   // 初始化主窗口的样式布局
    initLeftWindow();   // 初始化左侧窗口布局
    initMidWindow();    // 初始化中间窗口布局
    initRightWindow();  // 初始化右侧窗口布局

    // 初始化信号槽
    initSignalSlot();

    // 初始化 websocket
    initWebsocket();
}

void MainWidget::initMainWindow()
{
    QHBoxLayout* layout = new QHBoxLayout();
    // Spacing 就是 layout 内部元素之间的间隔距离. 设为 0 就是 "紧挨着"
    layout->setSpacing(0);
    // layout 里面的元素距离四个边界的距离.
    layout->setContentsMargins(0, 0, 0, 0);
    this->setLayout(layout);

    windowLeft = new QWidget();
    windowMid = new QWidget();
    windowRight = new QWidget();

    windowLeft->setFixedWidth(70);
    windowMid->setFixedWidth(310);
    windowRight->setFixedWidth(800);

    windowLeft->setStyleSheet("QWidget { background-color: rgb(46, 46, 46); }");
    windowMid->setStyleSheet("QWidget { background-color: rgb(247, 247, 247); }");
    windowRight->setStyleSheet("QWidget { background-color: rgb(245, 245, 245); }");

    layout->addWidget(windowLeft);
    layout->addWidget(windowMid);
    layout->addWidget(windowRight);
}

void MainWidget::initLeftWindow()
{
    QVBoxLayout* layout = new QVBoxLayout();
    layout->setSpacing(20);
    layout->setContentsMargins(0, 50, 0, 0);
    windowLeft->setLayout(layout);

    // 添加用户头像
    userAvatar = new QPushButton();
    userAvatar->setFixedSize(45, 45);
    userAvatar->setIconSize(QSize(45, 45));
    // 把这个默认头像的代码干掉就可以避免头像的变化
    //userAvatar->setIcon(QIcon(":/resource/image/defaultAvatar.png"));
    userAvatar->setStyleSheet("QPushButton { background-color: transparent; }");
    layout->addWidget(userAvatar, 1, Qt::AlignTop | Qt::AlignHCenter);

    // 添加会话标签页按钮
    sessionTabBtn = new QPushButton();
    sessionTabBtn->setFixedSize(45, 45);
    sessionTabBtn->setIconSize(QSize(30, 30));
    sessionTabBtn->setIcon(QIcon(":/resource/image/session_active.png"));
    sessionTabBtn->setStyleSheet("QPushButton { background-color: transparent; }");
    layout->addWidget(sessionTabBtn , 1, Qt::AlignTop | Qt::AlignHCenter);

    // 添加好友标签页按钮
    friendTabBtn = new QPushButton();
    friendTabBtn->setFixedSize(45, 45);
    friendTabBtn->setIconSize(QSize(30, 30));
    friendTabBtn->setIcon(QIcon(":/resource/image/friend_inactive.png"));
    friendTabBtn->setStyleSheet("QPushButton { background-color: transparent; }");
    layout->addWidget(friendTabBtn , 1, Qt::AlignTop | Qt::AlignHCenter);

    // 添加好友申请标签页按钮
    applyTabBtn = new QPushButton();
    applyTabBtn->setFixedSize(45, 45);
    applyTabBtn->setIconSize(QSize(30, 30));
    applyTabBtn->setIcon(QIcon(":/resource/image/apply_inactive.png"));
    applyTabBtn->setStyleSheet("QPushButton { background-color: transparent; }");
    layout->addWidget(applyTabBtn, 1, Qt::AlignTop | Qt::AlignHCenter);

    layout->addStretch(20);
}

4.2 主界面左侧页面各个功能的实现

(1)在MainWidget::initSignalSlot当中添加左侧按钮的信号槽(申请按钮、好友列表按钮、会话按钮):

/
/// 连接信号槽, 处理标签页按钮切换的问题
/
connect(sessionTabBtn, &QPushButton::clicked, this, &MainWidget::switchTabToSession);
connect(friendTabBtn, &QPushButton::clicked, this, &MainWidget::switchTabToFriend);
connect(applyTabBtn, &QPushButton::clicked, this, &MainWidget::switchTabToApply);

(2)槽函数的实现:

void MainWidget::switchTabToSession()
{
    // 1. 记录当前切换到了哪个标签页
    activeTab = SESSION_LIST;
    // 2. 调整图标显示情况, 把会话的按钮图标设为 active, 另外两个图标设为 inactive.
    sessionTabBtn->setIcon(QIcon(":/resource/image/session_active.png"));
    friendTabBtn->setIcon(QIcon(":/resource/image/friend_inactive.png"));
    applyTabBtn->setIcon(QIcon(":/resource/image/apply_inactive.png"));
    // 3. 在主窗口的中间部分, 加载出会话列表数据
    this->loadSessionList();
}

void MainWidget::switchTabToFriend()
{
    // 1. 记录当前切换到了哪个标签页
    activeTab = FRIEND_LIST;
    // 2. 调整图标显示情况, 把会话的按钮图标设为 active, 另外两个图标设为 inactive.
    friendTabBtn->setIcon(QIcon(":/resource/image/friend_active.png"));
    sessionTabBtn->setIcon(QIcon(":/resource/image/session_inactive.png"));
    applyTabBtn->setIcon(QIcon(":/resource/image/apply_inactive.png"));
    // 3. 在主窗口的中间部分, 加载出会话列表数据
    this->loadFriendList();
}

void MainWidget::switchTabToApply()
{
    // 1. 记录当前切换到了哪个标签页
    activeTab = APPLY_LIST;
    // 2. 调整图标显示情况, 把会话的按钮图标设为 active, 另外两个图标设为 inactive.
    applyTabBtn->setIcon(QIcon(":/resource/image/apply_active.png"));
    sessionTabBtn->setIcon(QIcon(":/resource/image/session_inactive.png"));
    friendTabBtn->setIcon(QIcon(":/resource/image/friend_inactive.png"));
    // 3. 在主窗口的中间部分, 加载出会话列表数据
    this->loadApplyList();
}

4.3 主界面中间页面各个功能的实现

(1)创建中间上方的搜索框和搜索按钮:

void MainWidget::initMidWindow()
{
    QGridLayout* layout = new QGridLayout();
    // 距离上方有 20px 的距离, 另外三个方向都不要边距
    layout->setContentsMargins(0, 20, 0, 0);
    layout->setHorizontalSpacing(0);
    layout->setVerticalSpacing(10);
    windowMid->setLayout(layout);

    searchEdit = new QLineEdit();
    searchEdit->setFixedHeight(30);
    searchEdit->setPlaceholderText("搜索");
    searchEdit->setStyleSheet("QLineEdit { border-radius: 5px; background-color: rgb(226, 226, 226); padding-left: 5px;}");

    addFriendBtn = new QPushButton();
    addFriendBtn->setFixedSize(30, 30);
    addFriendBtn->setIcon(QIcon(":/resource/image/cross.png"));
    QString style = "QPushButton { border-radius: 5px; background-color: rgb(226, 226, 226); }";
    style += " QPushButton:pressed { background-color: rgb(240, 240, 240); }";
    addFriendBtn->setStyleSheet(style);

    sessionFriendArea = new SessionFriendArea();

    // 为了更灵活的控制边距, 只影响搜索框按钮这一行, 不影响下方列表这一行
    // 创建空白的 widget 填充到布局管理器中.
    QWidget* spacer1 = new QWidget();
    spacer1->setFixedWidth(10);
    QWidget* spacer2 = new QWidget();
    spacer2->setFixedWidth(10);
    QWidget* spacer3 = new QWidget();
    spacer3->setFixedWidth(10);

    layout->addWidget(spacer1, 0, 0);
    layout->addWidget(searchEdit, 0, 1);
    layout->addWidget(spacer2, 0, 2);
    layout->addWidget(addFriendBtn, 0, 3);
    layout->addWidget(spacer3, 0, 4);
    layout->addWidget(sessionFriendArea, 1, 0, 1, 5);
}

(2)实现中间的会话列表、消息列表、好友申请列表。需要创建SessionFriendArea类来实现此功能:

//
/// 整个滚动区域的实现
//
class SessionFriendArea : public QScrollArea
{
    Q_OBJECT
public:
    explicit SessionFriendArea(QScrollArea *parent = nullptr);

    // 清空该区域中所有的 item
    void clear();

    // 添加一个 item 到该区域中, itemType 表示添加哪种 item, id 跟着不同的 itemType 有不同的含义.
    // 如果是 SessionItem, id 就是 chatSessionId
    // 如果是 FriendItem / ApplyItem, id 就是 userId
    void addItem(ItemType itemType, const QString& id, const QIcon& avatar, const QString& name, const QString& text);

    // 选中某个指定的 item, 通过 index 下标来进行选择
    void clickItem(int index);

private:
    QWidget* container;
};
  • SessionFriendArea类的具体实现:
SessionFriendArea::SessionFriendArea(QScrollArea *parent)
    : QScrollArea{parent}
{
    // 1. 设置必要的属性
    // 设置了这个属性, 才能够开启滚动效果
    this->setWidgetResizable(true);
    // 设置滚动条相关的样式
    this->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(46, 46, 46);}");
    this->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal { height: 0px; }");
    this->setStyleSheet("QWidget { border: none;}");

    // 2. 把 widget 创建出来
    container = new QWidget();
    this->setFixedWidth(310);
    this->setWidget(container);

    // 3. 给这个 widget 指定布局管理器, 以便后续添加元素进去
    QVBoxLayout* layout = new QVBoxLayout();
    layout->setContentsMargins(0, 0, 0, 0);
    layout->setSpacing(0);
    layout->setAlignment(Qt::AlignTop);
    container->setLayout(layout);

    // 构造出一些临时数据, 用来作为 "界面调试" 依据. 后续要删除掉
#if TEST_UI
    QIcon icon(":/resource/image/defaultAvatar.png");
    for (int i = 0; i < 30; ++i)
    {
        this->addItem(ApplyItemType, QString::number(i), icon, "张三" + QString::number(i), "最后一条消息" + QString::number(i));
    }
#endif
}

void SessionFriendArea::clear()
{
    QLayout* layout = container->layout();
    // 遍历布局管理器中的所有元素, 并依次从布局管理器中删除掉
    for(int i = layout->count() - 1; i >= 0; --i)
    {
        // takeAt 就能移除对应下标的元素
        QLayoutItem* item = layout->takeAt(i);
        // 别忘了, 还需要对这个对象进行 "释放"
        if(item->widget())
        {
            // 把这个移除的内容的 widget 进行释放.
            // 正常使用的时候, new 出来的对象添加到布局管理器的....
            delete item->widget();
        }
    }
}

// 此时这个函数添加的就不是 SessionFriendItem 了, 而是 SessionFriendItem 的子类.
// SessionItem, FriendItem, ApplyItem 其中的一个.
void SessionFriendArea::addItem(ItemType itemType, const QString& id, const QIcon& avatar, const QString& name, const QString& text)
{
    SessionFriendItem* item = nullptr;
    if(itemType == SessionItemType)
    {
        item = new SessionItem(this, id, avatar, name, text);
    }
    else if(itemType == FriendItemType)
    {
        item = new FriendItem(this, id, avatar, name, text);
    }
    else if(itemType == ApplyItemType)
    {
        item = new ApplyItem(this, id, avatar, name);
    }
    else
    {
        LOG() << "错误的 ItemType! itemType=" << itemType;
        return;
    }

    container->layout()->addWidget(item);
}

void SessionFriendArea::clickItem(int index)
{
    if(index < 0 || index >= container->layout()->count())
    {
        LOG() << "点击元素的下标超出范围! index=" << index;
        return;
    }

    QLayoutItem* layoutItem = container->layout()->itemAt(index);
    if(layoutItem == nullptr || layoutItem->widget() == nullptr)
    {
        LOG() << "指定的元素不存在! index=" << index;
        return;
    }

    SessionFriendItem* item = dynamic_cast<SessionFriendItem*>(layoutItem->widget());
    item->select();
}

(3)在与SessionFriendArea类同一个头文件当中创建SessionFriendItem类来实现列表的展示(会话、好友、好友申请列表的基类):

//
/// 滚动区域中的 Item 的实现
//
class SessionFriendItem : public QWidget
{
    Q_OBJECT

public:
    SessionFriendItem(QWidget* owner, const QIcon& avatar, const QString& name, const QString& text);

    void paintEvent(QPaintEvent* event) override;
    void mousePressEvent(QMouseEvent* event) override;
    void enterEvent(QEnterEvent* event) override;
    void leaveEvent(QEvent* event) override;

    void select();

    // active 函数期望实现 Item 被点击之后的业务逻辑.
    virtual void active();

private:
    // owner 就指向了上述的 SessionFriendArea
    QWidget* _owner;

    // 这个变量用来表示当前 Item 是否是 "选中" 状态
    bool selected = false;

protected:
    // 让这个成员被子类访问
    QLabel* messageLabel;
};
  • 样式布局:
SessionFriendItem::SessionFriendItem(QWidget* owner, const QIcon& avatar, const QString& name, const QString& text)
    :_owner(owner)
{
    this->setFixedHeight(70);
    this->setStyleSheet("QWidget { background-color: rgb(231, 231, 231); }");

    // 创建网格布局管理器
    QGridLayout* layout = new QGridLayout();
    layout->setContentsMargins(20, 0, 0, 0);
    layout->setHorizontalSpacing(10);
    layout->setVerticalSpacing(0);
    this->setLayout(layout);

    // 创建头像
    QPushButton* avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(50, 50);
    avatarBtn->setIconSize(QSize(50, 50));
    avatarBtn->setIcon(avatar);
    avatarBtn->setStyleSheet("QPushButton {border: none;}");
    avatarBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    // 创建名字
    QLabel* nameLabel = new QLabel();
    nameLabel->setText(name);
    nameLabel->setStyleSheet("QLabel { font-size: 18px; font-weight: 600; }");
    nameLabel->setFixedHeight(35);
    nameLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 创建消息预览的 label
    messageLabel = new QLabel();
    messageLabel->setText(text);
    messageLabel->setFixedHeight(35);
    messageLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 头像处于 0, 0 位置, 占据 2 行, 占据 2 列
    layout->addWidget(avatarBtn, 0, 0, 2, 2);
    // 名字处于 0, 2 位置, 占据 1 行, 占据 1 列
    layout->addWidget(nameLabel, 0, 2, 1, 8);
    // 消息预览处于 1, 2 位置, 占据 1 行, 占据 1 列
    layout->addWidget(messageLabel, 1, 2, 1, 8);
}
  • 实现鼠标悬停/选中元素效果:
void SessionFriendItem::paintEvent(QPaintEvent* event)
{
    (void)event;
    QStyleOption opt;
    opt.initFrom(this);
    QPainter p(this);
    style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}

void SessionFriendItem::mousePressEvent(QMouseEvent* event)
{
    (void)event;
    select();
}

void SessionFriendItem::enterEvent(QEnterEvent* event)
{
    (void)event;
    if(this->selected)
    {
        return;
    }

    // 设置一个更深的颜色
    this->setStyleSheet("QWidget { background-color: rgb(215, 215, 215);}");
}

void SessionFriendItem::leaveEvent(QEvent* event)
{
    (void)event;
    if(this->selected)
    {
        return;
    }

    // 还原背景色
    this->setStyleSheet("QWidget { background-color: rgb(231, 231, 231);}");
}

void SessionFriendItem::select()
{
    // 鼠标点击时会触发这个函数.
    // 拿到所有的兄弟元素
    const QObjectList children = this->parentWidget()->children();
    for(QObject* child : children)
    {
        if(!child->isWidgetType())
        {
            // 判定是否是 widget.
            continue;
        }

        SessionFriendItem* item = dynamic_cast<SessionFriendItem*>(child);
        if(item->selected)
        {
            item->selected = false;
            item->setStyleSheet("QWidget { background-color: rgb(231, 231, 231); }");
        }
    }

    // 点击时, 修改背景色.
    // 此处不仅仅要设置当前 item 背景色, 也要还原其他元素的背景色.
    this->setStyleSheet("QWidget { background-color: rgb(210, 210, 210); }");
    this->selected = true;

    // 调用 active
    this->active();
}

void SessionFriendItem::active()
{
    // 父类的 active
    // 并不需要实现任何逻辑.
}

(4)创建列表元素 - 聊天会话。创建SessionItem类来继承SessionFriendItem类:

enum ItemType
{
    SessionItemType,
    FriendItemType,
    ApplyItemType
};

//
/// 会话 Item 的实现
//
class SessionItem : public SessionFriendItem
{
    Q_OBJECT

public:
    SessionItem(QWidget* owner, const QString& chatSessionId, const QIcon& avatar,
                const QString& name, const QString& lastMessage);

    void active() override;

    void updateLastMessage(const QString& chatSessionId);

private:
    QString chatSessionId;      // 当前会话 id
    QString text;               // 最后一条消息的文本预览

};
  • SessionItem类功能的具体实现:
SessionItem::SessionItem(QWidget* owner, const QString& chatSessionId, const QIcon& avatar,
            const QString& name, const QString& lastMessage)
    :SessionFriendItem(owner, avatar, name, lastMessage)
    ,chatSessionId(chatSessionId)
    ,text(lastMessage)
{}

void SessionItem::active()
{
    LOG() << "SessionItem active. chatSessionId=" << chatSessionId;
 	// TODO
}

void SessionItem::updateLastMessage(const QString& chatSessionId)
{
    // TODO
}

(5)创建列表元素 - 好友会话。创建FriendItem类来继承SessionFriendItem类:

//
/// 好友 Item 的实现
//
class FriendItem : public SessionFriendItem
{
    Q_OBJECT

public:
    FriendItem(QWidget* owner, const QString& userId, const QIcon& avatar,
               const QString& name, const QString& description);

    void active() override;

private:
    QString userId;     // 好友的用户id

};
  • FriendItem类的具体实现:
FriendItem::FriendItem(QWidget* owner, const QString& userId, const QIcon& avatar,
           const QString& name, const QString& description)
    :SessionFriendItem(owner, avatar, name, description)
    ,userId(userId)
{}

void FriendItem::active()
{
    // 点击之后, 要激活对应的会话列表元素
    LOG() << "点击 FriendItem 触发的逻辑! userId=" << userId;
    // TODO
}

(5)创建列表元素 - 好友申请会话。创建ApplyItem类来继承SessionFriendItem类:

//
/// 好友申请 Item 的实现
//
class ApplyItem : public SessionFriendItem
{
    Q_OBJECT

public:
    // 此处不需要显示一个 附加的文本了. 比上面的两个 Item 的构造函数, 少了一个参数
    ApplyItem(QWidget* owner, const QString& userId, const QIcon& avatar, const QString& name);

    void active() override;
    
private:
    QString userId;     // 申请人的 userId

};
  • ApplyItem类的具体实现:
//
/// 好友申请 Item 的实现
//

ApplyItem::ApplyItem(QWidget* owner, const QString& userId, const QIcon& avatar, const QString& name)
    :SessionFriendItem(owner, avatar, name, "")
    ,userId(userId)
{
    // 1. 移除父类的 messageLabel
    QGridLayout* layout = dynamic_cast<QGridLayout*>(this->layout());
    layout->removeWidget(messageLabel);

    // 要记得释放内存, 否则会内存泄露.
    delete messageLabel;

    // 2.创建两个按钮出来
    QPushButton* acceptBtn = new QPushButton();
    acceptBtn->setText("同意");
    QPushButton* rejectBtn = new QPushButton();
    rejectBtn->setText("拒绝");

    // 3. 添加到布局管理器中
    layout->addWidget(acceptBtn, 1, 2, 1, 1);
    layout->addWidget(rejectBtn, 1, 3, 1, 1);
}

void ApplyItem::active()
{
    // 这个函数本身就不需要实现任何内容
    LOG() << "点击 ApplyItem 触发的逻辑! userId=" << userId;
}

4.4 主界面右侧页面各个功能的实现

(1)界面效果:

  • 点击右上角 … 按钮,打开新的窗口,显示这个消息会话的详情。
  • 点击用户头像,打开新窗口,显示用户详细信息。
  • 左侧下方四个按钮分别是发送图片、发送文件、发送语音、查看历史消息 (打开新的窗口)。

4.4.1 实现会话标题栏

(1)回到MainWidget的实现:

void MainWidget::initRightWindow()
{
    // 1. 创建右侧窗口的布局管理器
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(0);
    vlayout->setContentsMargins(0, 0, 0, 0);
    vlayout->setAlignment(Qt::AlignTop);
    windowRight->setLayout(vlayout);

    // 2. 创建上方标题栏
    QWidget* titleWidget = new QWidget();
    titleWidget->setFixedHeight(62);
    titleWidget->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    titleWidget->setObjectName("titleWidget");
    titleWidget->setStyleSheet("#titleWidget { border-bottom: 1px solid rgb(230, 230, 230); border-left: 1px solid rgb(230, 230, 230); }");
    vlayout->addWidget(titleWidget);

    // 3. 给标题栏, 添加标题 label 和 一个按钮
    QHBoxLayout* hlayout = new QHBoxLayout();
    hlayout->setSpacing(0);
    // 使标题的 label 和 按钮距离左右两侧的边界, 有点间距.
    hlayout->setContentsMargins(10, 0, 10, 0);
    titleWidget->setLayout(hlayout);

    sessionTitleLabel =  new QLabel();
    sessionTitleLabel->setStyleSheet("QLabel { font-size: 22px; border-bottom: 1px solid rgb(230, 230, 230);}");
    // 为了测试界面临时增加的. 实际这里的内容, 应该是使用从服务器获取的数据来设置.
    sessionTitleLabel->setText("会话标题");
    hlayout->addWidget(sessionTitleLabel);

    extraBtn = new QPushButton();
    extraBtn->setFixedSize(30, 30);
    extraBtn->setIconSize(QSize(30, 30));
    extraBtn->setIcon(QIcon(":/resource/image/more.png"));
    extraBtn->setStyleSheet("QPushButton { border:none; background-color: rgb(245, 245, 245); } QPushButton:pressed { background-color: rgb(220, 220, 220); }");
    hlayout->addWidget(extraBtn);

    // 4. 添加消息展示区
    messageShowArea = new MessageShowArea();
    vlayout->addWidget(messageShowArea);

    // 5. 添加消息编辑区
    messageEditArea = new MessageEditArea();
    // 确保消息编辑区, 处于窗口的下方.
    vlayout->addWidget(messageEditArea, 0, Qt::AlignBottom);
}

4.4.2 实现消息展示区域

(1)创建 MessageShowArea类来实现消息展示区:


/// 表示消息展示区

class MessageShowArea : public QScrollArea
{
    Q_OBJECT
public:
    MessageShowArea();

    // 尾插
    void addMessage(bool isLeft, const Message& message);
    // 头插
    void addFrontMessage(bool isLeft, const Message& message);
    // 清空消息
    void clear();
    // 滚动到末尾
    void scrollToEnd();

private:
    QWidget* container;
};
  • MessageShowArea类的具体实现:
MessageShowArea::MessageShowArea()
{
    // 1. 初始化基本属性
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    this->setWidgetResizable(true);
    // 设置滚动条的样式
    this->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(240, 240, 240); }");
    this->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal { height: 0;}");
    this->setStyleSheet("QScrollArea { border: none; }");

    // 2. 创建 Container 这样的 widget, 作为包含内部元素的容器
    container = new QWidget();
    this->setWidget(container);

    // 3. 给 container 添加布局管理器
    QVBoxLayout* layout = new QVBoxLayout();
    layout->setSpacing(0);
    layout->setContentsMargins(0, 0, 0, 0);
    container->setLayout(layout);

    // 添加 "构造测试数据" 逻辑.
#if TEST_UI
    model::UserInfo userInfo;
    userInfo.userId = QString::number(1000);
    userInfo.nickname = "张三";
    userInfo.description = "从今天开始认真敲代码";
    userInfo.avatar = QIcon(":/resource/image/defaultAvatar.png");
    userInfo.phone = "18612345678";
    Message message = Message::makeMessage(model::TEXT_TYPE, "", userInfo, QString("这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息这是一条测试消息").toUtf8(), "");
    this->addMessage(false, message);

    for (int i = 1; i <= 30; ++i)
    {
        model::UserInfo userInfo;
        userInfo.userId = QString::number(1000 + i);
        userInfo.nickname = "张三" + QString::number(i);
        userInfo.description = "从今天开始认真敲代码";
        userInfo.avatar = QIcon(":/resource/image/defaultAvatar.png");
        userInfo.phone = "18612345678";
        Message message = Message::makeMessage(model::TEXT_TYPE, "", userInfo, (QString("这是一条测试消息") + QString::number(i)).toUtf8(), "");
        this->addMessage(true, message);
    }
#endif
}

void MessageShowArea::addMessage(bool isLeft, const Message& message)
{
    // 构造 MessageItem, 添加到布局管理器中.
    MessageItem* messageItem = MessageItem::makeMessageItem(isLeft, message);
    container->layout()->addWidget(messageItem);
}

void MessageShowArea::addFrontMessage(bool isLeft, const Message& message)
{
    MessageItem* messageItem = MessageItem::makeMessageItem(isLeft, message);
    QVBoxLayout* layout = dynamic_cast<QVBoxLayout*>(container->layout());
    layout->insertWidget(0, messageItem);
}

void MessageShowArea::clear()
{
    // 遍历布局管理器, 删除里面的元素
    QLayout* layout = container->layout();
    for(int i = layout->count() - 1; i >= 0; --i)
    {
        QLayoutItem* item = layout->takeAt(i);
        if (item != nullptr && item->widget() != nullptr)
        {
            delete item->widget();
        }
    }
}

void MessageShowArea::scrollToEnd()
{
    // 实现思路:
    // 拿到滚动区域中的滚动条(垂直滚动条)
    // 获取到滚动条的最大值
    // 根据最大值, 设置滚动条的滚动位置.
    // 为了使滚动效果更佳, 能够在界面绘制好之后进行滚动条的设置
    // 给这里的滚动操作, 加上个 "延时"

    QTimer* timer = new QTimer();
    connect(timer, &QTimer::timeout, this, [=]() {
        // 获取到垂直滚动条的最大值
        int maxValue = this->verticalScrollBar()->maximum();
        // 设置滚动条的滚动位置
        this->verticalScrollBar()->setValue(maxValue);

        timer->stop();
        timer->deleteLater();
    });
    timer->start(500);
}

(2)创建消息对象MessageItem类来展示在MessageShowArea类当中:


/// 表示一个消息元素
/// 这个里面要能同时支持 文本消息, 图片消息, 文件消息, 语音消息.
/// 当前先只考虑文本消息. 另外几个后续慢慢添加.

class MessageItem : public QWidget
{
    Q_OBJECT

public:
    // 此处的 isLeft 表示这个 Item 是否是一个 "左侧消息"
    MessageItem(bool isleft);

    // 通过 工厂方法 创建 MessageItem 实例
    static MessageItem* makeMessageItem(bool isLeft, const Message& message);

    // 添加工厂函数
    static QWidget* makeTextMessageItem(bool isLeft, const QString& text);
    static QWidget* makeImageMessageItem(bool isLeft, const QString& fileId, const QByteArray& content);
    static QWidget* makeFileMessageItem(bool isLeft, const Message& message);
    static QWidget* makeSpeechMessageItem(bool isLeft, const Message& message);

private:
    bool isleft;
};
  • MessageItem类的具体实现:

/// 表示一个消息元素


MessageItem::MessageItem(bool isleft)
    :isleft(isleft)
{

}

MessageItem* MessageItem::makeMessageItem(bool isLeft, const Message& message)
{
    // 1. 创建对象和布局管理器
    MessageItem* messageItem = new MessageItem(isLeft);
    QGridLayout* layout = new QGridLayout();
    layout->setContentsMargins(30, 10, 40, 0);
    layout->setSpacing(10);
    // 这个 MessageItem 最低不能低于 100
    messageItem->setMinimumHeight(100);
    messageItem->setLayout(layout);

    // 2. 创建头像
    QPushButton* avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(40, 40);
    avatarBtn->setIconSize(QSize(40, 40));
    avatarBtn->setIcon(message.sender.avatar);
    avatarBtn->setStyleSheet("QPushButton { border: none;}");
    if(isLeft)
    {
        layout->addWidget(avatarBtn, 0, 0, 2, 1, Qt::AlignTop | Qt::AlignLeft);
    }
    else
    {
        layout->addWidget(avatarBtn, 0, 1, 2, 1, Qt::AlignTop | Qt::AlignLeft);
    }

    // 3. 创建名字和时间
    QLabel* nameLabel = new QLabel();
    nameLabel->setText(message.sender.nickname + " | " + message.time);
    nameLabel->setAlignment(Qt::AlignBottom);
    nameLabel->setStyleSheet("QLabel { font-size: 12px; color: rgb(178, 178, 178); }");
    if(isLeft)
    {
        layout->addWidget(nameLabel, 0, 1);
    }
    else
    {
        layout->addWidget(nameLabel, 0, 0, Qt::AlignRight);
    }

    // 4. 创建消息体
    QWidget* contentWidget = nullptr;
    switch (message.messageType)
    {
    case model::TEXT_TYPE:
        contentWidget = makeTextMessageItem(isLeft, message.content);
        break;
    case model::IMAGE_TYPE:
        contentWidget = makeImageMessageItem(isLeft, message.fileId, message.content);
        break;
    case model::FILE_TYPE:
        contentWidget = makeFileMessageItem(isLeft, message);
        break;
    case model::SPEECH_TYPE:
        contentWidget = makeSpeechMessageItem(isLeft, message);
        break;
    default:
        LOG() << "错误的消息类型! messageType=" << message.messageType;
    }

    if (isLeft)
    {
        layout->addWidget(contentWidget, 1, 1);
    }
    else
    {
        layout->addWidget(contentWidget, 1, 0);
    }

    // 5. 连接信号槽, 处理用户点击头像的操作
    connect(avatarBtn, &QPushButton::clicked, messageItem, [=]() {
        MainWidget* mainwidget = MainWidget::getInstance();
        UserInfoWidget* userinfowidget = new UserInfoWidget(message.sender, mainwidget);
        userinfowidget->exec();
    });

    // 6. 当用户修改了昵称的时候, 同步修改此处的用户昵称.
    if(!isLeft)
    {
        model::DataCenter* dataCenter = model::DataCenter::getInstance();
        connect(dataCenter, &model::DataCenter::changeNicknameDone, messageItem, [=]()
        {
            nameLabel->setText(dataCenter->getMyself()->nickname + " | " + message.time);
        });

        connect(dataCenter, &model::DataCenter::changeAvatarDone, messageItem, [=]()
        {
            UserInfo* myself = dataCenter->getMyself();
            avatarBtn->setIcon(myself->avatar);
        });
    }

    return messageItem;
}

QWidget* MessageItem::makeTextMessageItem(bool isLeft, const QString& text)
{
    MessageContentLabel* messageContentLabel = new MessageContentLabel(text, isLeft, model::MessageType::TEXT_TYPE, "", QByteArray());
    return messageContentLabel;
}

QWidget* MessageItem::makeImageMessageItem(bool isLeft, const QString& fileId, const QByteArray& content)
{
    MessageImageLabel* messageImageLabel = new MessageImageLabel(fileId, content, isLeft);
    return messageImageLabel;
}

QWidget* MessageItem::makeFileMessageItem(bool isLeft, const Message& message)
{
    MessageContentLabel* messageContentLabel = new MessageContentLabel("[文件] " + message.fileName, isLeft, message.messageType,
                                                                       message.fileId, message.content);
    return messageContentLabel;
}

QWidget* MessageItem::makeSpeechMessageItem(bool isLeft, const Message& message)
{
    MessageContentLabel* messageContentLabel = new MessageContentLabel("[语言]", isLeft, message.messageType,
                                                                       message.fileId, message.content);
    return messageContentLabel;
}

(3)创建文本消息MessageContentLabel类同时也可以用作显示文件消息和语音消息:

class MessageContentLabel : public QWidget
{
    Q_OBJECT

public:
    MessageContentLabel(const QString& text, bool isLeft, model::MessageType messageType, const QString& fileId,
                        const QByteArray& content);

    void paintEvent(QPaintEvent* event) override;

    void mousePressEvent(QMouseEvent* event) override;

    void updateUI(const QString& fileId, const QByteArray& fileContent);
    void saveAsFile(const QByteArray& content);
    void playDone();

    void contextMenuEvent(QContextMenuEvent* event) override;
    void speechConvertTextDone(const QString& fileId, const QString& text);

private:
    QLabel* label;
    bool isLeft;

    model::MessageType messageType;
    QString fileId;
    QByteArray content;

    bool loadContentDone = false;

};
  • MessageContentLabel类的具体实现:
MessageContentLabel::MessageContentLabel(const QString &text, bool isLeft, model::MessageType messageType, const QString& fileId,
                                         const QByteArray& content)
    :isLeft(isLeft)
    ,messageType(messageType)
    ,fileId(fileId)
    ,content(content)
{
    // 设置一下 SizePolicy
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);

    QFont font;
    font.setFamily("微软雅黑");
    font.setPixelSize(16);

    this->label = new QLabel(this);
    this->label->setText(text);
    this->label->setFont(font);
    this->label->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
    this->label->setWordWrap(true);    // 设置文本自动换行
    this->label->setStyleSheet("QLabel { padding: 0 10px; line-height: 1.2; background-color: transparent; }");

    // 针对文件消息, 并且 content 为空的情况下, 通过网络来加载数据
    if(messageType == model::TEXT_TYPE)
    {
        return;
    }

    if(this->content.isEmpty())
    {
        model::DataCenter* dataCenter = model::DataCenter::getInstance();
        connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &MessageContentLabel::updateUI);
        dataCenter->getSingleFileAsync(this->fileId);
    }
    else
    {
        // content 不为空, 说明当前的这个数据就是已经现成. 直接就把 表示加载状态的变量设为 true
        this->loadContentDone = true;
    }
}

// 这个函数会该控件被显示的时候自动调用到.
void MessageContentLabel::paintEvent(QPaintEvent* event)
{
    (void)event;
    // 1. 获取到父元素的宽度
    QObject* object = this->parent();
    if(!object->isWidgetType())
    {
        // 当前这个对象的父元素不是预期的 QWidget, 此时不需要进行任何后续的绘制操作.
        return;
    }

    QWidget* parent = dynamic_cast<QWidget*>(object);
    int width = parent->width() * 0.6;

    // 2. 计算当前文本, 如果是一行放置, 需要多宽.
    QFontMetrics metrics(this->label->font());
    int totalWidth = metrics.horizontalAdvance(this->label->text());

    // 3. 计算出此处的行数是多少 (40 表示左右各有 20px 的边距)
    int rows = (totalWidth / (width - 40)) + 1;
    if(rows == 1)
    {
        // 如果此时得到的行数就只有一行
        width = totalWidth + 40;
    }

    // 4. 根据行数, 计算得到高度. (20 表示上下各有 10px 的边距)
    int height = rows * (this->label->font().pixelSize() * 1.2 ) + 20;

    // 5. 绘制圆角矩形和箭头
    QPainter painter(this);
    QPainterPath path;
    // 设置 "抗锯齿"
    painter.setRenderHint(QPainter::Antialiasing);
    if(isLeft)
    {
        painter.setPen(QPen(QColor(255, 255, 255)));
        painter.setBrush(QColor(255, 255, 255));

        // 绘制圆角矩形
        painter.drawRoundedRect(10, 0, width, height, 10, 10);
        // 绘制箭头
        path.moveTo(10, 15);
        path.lineTo(0, 20);
        path.lineTo(10, 25);
        path.closeSubpath();     // 绘制的线形成闭合的多边形, 才能进行使用 Brush 填充颜色.
        painter.drawPath(path);  // 不要忘记真正的绘制操作

        this->label->setGeometry(10, 0, width, height);
    }
    else
    {
        painter.setPen(QPen(QColor(137, 217, 97)));
        painter.setBrush(QColor(137, 217, 97));

        // 圆角矩形左侧边的横坐标位置
        int leftPos = this->width() - width - 10; // 10 是用来容纳 箭头 的宽度
        // 圆角矩形右侧边的横坐标位置
        int rightPos = this->width() - 10;
        // 绘制圆角矩形
        painter.drawRoundedRect(leftPos, 0, width, height, 10, 10);
        // 绘制箭头
        path.moveTo(rightPos, 15);
        path.lineTo(rightPos + 10, 20);
        path.lineTo(rightPos, 25);
        path.closeSubpath();
        painter.drawPath(path);

        this->label->setGeometry(leftPos, 0, width, height);
    }

    // 6. 重新设置父元素的高度, 确保父元素足够高, 能够容纳下上述绘制的消息显示的区域
    //  注意高度要涵盖之前名字和时间的 label 的高度, 以及留点冗余空间.
    parent->setFixedHeight(height + 50);
}

void MessageContentLabel::mousePressEvent(QMouseEvent* event)
{
    // 实现鼠标点击之后, 触发文件另存为
    if(event->button() == Qt::LeftButton)
    {
        if(this->messageType == model::MessageType::FILE_TYPE)
        {
            // 真正触发另存为
            if(!this->loadContentDone)
            {
                Toast::showMessage("数据尚未加载成功, 请稍后重试");
                return;
            }

            saveAsFile(this->content);
        }
        else if(this->messageType == model::MessageType::SPEECH_TYPE)
        {
            if(!this->loadContentDone)
            {
                Toast::showMessage("数据尚未加载成功, 请稍后重试");
                return;
            }

            SoundRecorder* soundRecorder = SoundRecorder::getInstance();
            this->label->setText("播放中...");
            connect(soundRecorder, &SoundRecorder::soundPlayDone, this, &MessageContentLabel::playDone, Qt::UniqueConnection);
            soundRecorder->startPlay(this->content);
        }
    }
}

void MessageContentLabel::updateUI(const QString& fileId, const QByteArray& fileContent)
{
    // 也和刚才图片消息的处理一样, 就需要判定收到的数据属于哪个 fileId 的.
    if(fileId != this->fileId)
    {
        return;
    }

    this->content = fileContent;
    this->loadContentDone = true;

    // 对于文件消息来说, 要在界面上显示 "[文件] test.txt" 这样形式. 这个内容和文件 content 无关.
    // 在从服务器拿到文件正文之前, 界面内容应该就是绘制好了. 此时拿到正文之后, 界面应该也不必做出任何实质性的调整.
    // 所以下列的 this->update(), 没有也行.
    this->update();
}

void MessageContentLabel::saveAsFile(const QByteArray& content)
{
    // 弹出对话框, 让用户选择路径
    QString filePath = QFileDialog::getSaveFileName(this, "另存为", QDir::homePath(), "*");
    if(filePath.isEmpty())
    {
        LOG() << "用户取消了文件另存为";
        return;
    }

    model::writeByteArrayToFile(filePath, content);
}

void MessageContentLabel::playDone()
{
    if(this->label->text() == "播放中...")
    {
        this->label->setText("[语音]");
    }
}

void MessageContentLabel::contextMenuEvent(QContextMenuEvent *event)
{
    (void) event;
    if (messageType != model::MessageType::SPEECH_TYPE)
    {
        LOG() << "非语音消息暂时不支持右键菜单";
        return;
    }

    QMenu* menu = new QMenu(this);
    QAction* action = menu->addAction("语音转文字");
    menu->setStyleSheet("QMenu { color: rgb(0, 0, 0); }");
    connect(action, &QAction::triggered, this, [=]()
    {
        model::DataCenter* dataCenter = model::DataCenter::getInstance();
        connect(dataCenter, &model::DataCenter::speechConvertTextDone, this, &MessageContentLabel::speechConvertTextDone, Qt::UniqueConnection);
        dataCenter->speechConvertTextAsync(this->fileId, this->content);
    });

    // 此处弹出 "模态对话框" 显示菜单/菜单项. exec 会在用户进一步操作之前, 阻塞.
    menu->exec(event->globalPos());
    delete menu;
}

void MessageContentLabel::speechConvertTextDone(const QString &fileId, const QString &text)
{
    if(this->fileId != fileId)
    {
        // 直接跳过, 此时的结果不是针对这一条语音消息的结果.
        return;
    }

    // 修改界面内容
    this->label->setText("[语音转文字] " + text);
    this->update();
}

(4)创建一个MessageImageLabel类来表示图片消息:


/// 创建类表示 "图片消息" 正文部分

class MessageImageLabel : public QWidget
{
    Q_OBJECT

public:
    MessageImageLabel(const QString& fileId, const QByteArray& content, bool isLeft);

    void updateUI(const QString& fileId, const QByteArray& content);
    void paintEvent(QPaintEvent* event);

private:
    QPushButton* imageBtn;

    QString fileId;  		// 该图片在服务器对应的文件 id.
    QByteArray content;		// 图片的二进制数据
    bool isLeft;
};
  • MessageImageLabel类的具体实现:
MessageImageLabel::MessageImageLabel(const QString& fileId, const QByteArray& content, bool isLeft)
    :fileId(fileId)
    ,content(content)
    ,isLeft(isLeft)
{
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);

    imageBtn = new QPushButton(this);
    imageBtn->setStyleSheet("QPushButton { border: none; }");

    if(content.isEmpty())
    {
        // 此处这个控件, 是针对 "从服务器拿到图片消息" 这种情况.
        // 拿着 fileId, 去服务器获取图片内容
        model::DataCenter* dataCenter = model::DataCenter::getInstance();
        connect(dataCenter, &model::DataCenter::getSingleFileDone, this, &MessageImageLabel::updateUI);
        dataCenter->getSingleFileAsync(fileId);
    }
}

void MessageImageLabel::updateUI(const QString& fileId, const QByteArray& content)
{
    if(this->fileId != fileId)
    {
        // 没对上 fileId, 当前响应的图片是其他的 图片消息 请求的.
        return;
    }

    // 对上了, 真正显示图片内容
    this->content = content;

    // 进行绘制图片到界面上的操作.
    this->update();
}

void MessageImageLabel::paintEvent(QPaintEvent* event)
{
    (void)event;

    // 1. 先拿到该元素的父元素, 看父元素的宽度是多少.
    //    此处显示的图片宽度的上限 父元素宽度的 60% .
    QObject* object = this->parent();
    if(!object->isWidgetType())
    {
        // 这个逻辑理论上来说是不会存在的.
        return;
    }

    QWidget* parent = dynamic_cast<QWidget*>(object);
    int width = parent->width() * 0.6;

    // 2. 加载二进制数据为图片对象
    QImage image;
    if(content.isEmpty())
    {
        // 此时图片的响应数据还没回来.
        // 此处先拿一个 "固定默认图片" 顶替一下.
        QByteArray tmpContent = model::loadFileToByteArray(":/resource/image/image.png");
        image.loadFromData(tmpContent);
    }
    else
    {
        // 此处的 load 操作 QImage 能够自动识别当前图片是啥类型的 (png, jpg....)
        image.loadFromData(content);
    }

    // 3. 针对图片进行缩放.
    int height = 0;
    if(image.width() > width)
    {
        // 发现图片更宽, 就需要把图片缩放一下, 使用 width 作为实际的宽度
        // 等比例缩放.
        height = ((double)image.height() / image.width()) * width;
    }
    else
    {
        // 图片本身不太宽, 不需要缩放.
        width = image.width();
        height = image.height();
    }

    // pixmap 只是一个中间变量. QImage 不能直接转成 QIcon, 需要 QPixmap 中转一下
    QPixmap pixmap = QPixmap::fromImage(image);
    // imageBtn->setFixedSize(width, height);
    imageBtn->setIconSize(QSize(width, height));
    imageBtn->setIcon(QIcon(pixmap));

    // 4. 由于图片高度是计算算出来的. 该元素的父对象的高度, 能够容纳下当前的元素.
    //    此处 + 50 是为了能够容纳下 上方的 "名字" 部分. 同时留下一点 冗余 空间.
    parent->setFixedHeight(height + 50);

    // 5. 确定按钮所在的位置.
    //    左侧消息, 和右侧消息, 要显示的位置是不同的.
    if(isLeft)
    {
        imageBtn->setGeometry(10, 0, width, height);
    }
    else
    {
        int leftPos = this->width() - width - 10;
        imageBtn->setGeometry(leftPos, 0, width, height);
    }
}

4.4.3 实现消息编辑区域

(1)创建MessageEditArea类来实现消息编辑区:

class MessageEditArea : public QWidget
{
    Q_OBJECT
public:
    explicit MessageEditArea(QWidget *parent = nullptr);

private:
    QPushButton* sendImageBtn;		// 发送图⽚消息
    QPushButton* sendFileBtn;		// 发送⽂件消息
    QPushButton* sendSpeechBtn;		// 发送语⾳按钮
    QPushButton* showHistoryBtn;	// 显⽰历史消息按钮
    QPlainTextEdit* textEdit;		// 消息输⼊框
    QPushButton* sendTextBtn;		// 发送消息按钮
    QLabel* tipLabel;				// 提⽰信息 label

signals:
};
  • MessageEditArea类的具体实现:
MessageEditArea::MessageEditArea(QWidget *parent)
    : QWidget{parent}
{
    // 1. 设置必要的属性
    this->setFixedHeight(200);
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 2. 创建垂直方向的布局管理器
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(0);
    vlayout->setContentsMargins(3, 2, 10, 10);
    this->setLayout(vlayout);

    // 3. 创建水平方向的布局管理器
    QHBoxLayout* hlayout = new QHBoxLayout();
    hlayout->setSpacing(0);
    hlayout->setContentsMargins(10, 0, 0, 0);
    hlayout->setAlignment(Qt::AlignLeft | Qt::AlignTop);
    vlayout->addLayout(hlayout);

    // 4. 把上方的四个按钮, 创建好并添加到水平布局中
    QString btnStyle = "QPushButton { background-color: rgb(245, 245, 245); border: none; } QPushButton:pressed { background-color: rgb(255, 255, 255); }";
    QSize btnSize(35, 35);
    QSize iconSize(25, 25);

    sendImageBtn = new QPushButton();
    sendImageBtn->setFixedSize(btnSize);
    sendImageBtn->setIconSize(iconSize);
    sendImageBtn->setIcon(QIcon(":/resource/image/image.png"));
    sendImageBtn->setStyleSheet(btnStyle);
    hlayout->addWidget(sendImageBtn);

    sendFileBtn = new QPushButton();
    sendFileBtn->setFixedSize(btnSize);
    sendFileBtn->setIconSize(iconSize);
    sendFileBtn->setIcon(QIcon(":/resource/image/file.png"));
    sendFileBtn->setStyleSheet(btnStyle);
    hlayout->addWidget(sendFileBtn);

    sendSpeechBtn = new QPushButton();
    sendSpeechBtn->setFixedSize(btnSize);
    sendSpeechBtn->setIconSize(iconSize);
    sendSpeechBtn->setIcon(QIcon(":/resource/image/sound.png"));
    sendSpeechBtn->setStyleSheet(btnStyle);
    hlayout->addWidget(sendSpeechBtn);

    showHistoryBtn = new QPushButton();
    showHistoryBtn->setFixedSize(btnSize);
    showHistoryBtn->setIconSize(iconSize);
    showHistoryBtn->setIcon(QIcon(":/resource/image/history.png"));
    showHistoryBtn->setStyleSheet(btnStyle);
    hlayout->addWidget(showHistoryBtn);

    // 5. 添加多行编辑框
    textEdit = new QPlainTextEdit();
    textEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    textEdit->setStyleSheet("QPlainTextEdit { border: none; background-color: transparent; font-size: 14px; padding: 10px; }");
    textEdit->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(45, 45, 45); }");
    vlayout->addWidget(textEdit);

    // 6. 添加提示 "录制中" 这样的 QLabel
    tipLabel = new QLabel();
    tipLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    tipLabel->setText("录音中...");
    tipLabel->setAlignment(Qt::AlignCenter);
    tipLabel->setFont(QFont("微软雅黑", 24, 600));
    vlayout->addWidget(tipLabel);
    tipLabel->hide();

    // 7. 添加发送文本消息的按钮
    sendTextBtn = new QPushButton();
    sendTextBtn->setText("发送");
    sendTextBtn->setFixedSize(120, 40);
    QString style = "QPushButton { font-size: 16px; color: rgb(7, 193, 96); border: none; background-color: rgb(233, 233, 233); border-radius: 10px; } ";
    style += "QPushButton:hover { background-color: rgb(210, 210, 210); }";
    style += "QPushButton:pressed { background-color: rgb(190, 190, 190); }";
    sendTextBtn->setStyleSheet(style);
    vlayout->addWidget(sendTextBtn, 0, Qt::AlignRight | Qt::AlignVCenter);
}

5. 实现主界面各个按钮的点击功能

5.1 实现个人信息详细界面

(1)实现点击自己的头像,弹出对话框显示个人主页。我们需要重新创建一个新的类SelfInfoWidget来实现此功能:

(2)个人主页的主要界面如下:

(3)selfinfowidget.h的实现:

class SelfInfoWidget : public QDialog
{
    Q_OBJECT
public:
    SelfInfoWidget(QWidget* parent);
    
private:
    QGridLayout* layout;

    QPushButton* avatarBtn;
    QLabel* idTag;								// 显示 "序号"
    QLabel* idLabel;							// 显示 "1234"

    QLabel* nameTag;							// 显示 "昵称"
    QLabel* nameLabel; 							// 显示 "张三"
    QLineEdit* nameEdit;						// 编辑昵称
    QPushButton* nameModifyBtn;					// 修改名字
    QPushButton* nameSubmitBtn;					// 提交修改

    QLabel* descTag;							// 显示 "签名"
    QLabel* descLabel;							// 显示 "从今天开始认真敲代码"
    QLineEdit* descEdit;						// 编辑签名
    QPushButton* descModifyBtn;					// 修改签名
    QPushButton* descSubmitBtn;					// 提交修改

    QLabel* phoneTag;							// 显示 "电话"
    QLabel* phoneLabel;							// 显示 "18612345678"
    QLineEdit* phoneEdit;						// 编辑电话
    QPushButton* phoneModifyBtn;				// 修改电话
    QPushButton* phoneSubmitBtn;				// 提交修改

    QLabel* verifyCodeTag;						// 显示 "验证码"
    QLineEdit* verifyCodeEdit;					// 输入验证码
    QPushButton* getVerifyCodeBtn;				// 获取验证码按钮

    // 要修改的新的手机号码
    QString phoneToChange;

    // 倒计时的时间
    int leftTime = 30;

};

(4)selfinfowidget.cpp的实现:

SelfInfoWidget::SelfInfoWidget(QWidget* parent)
    :QDialog(parent)
{
    // 1. 设置整个窗口的属性
    this->setFixedSize(500, 250);
    this->setWindowTitle("个人信息");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    // 窗口被关闭时, 自动销毁这个对话框对象.
    this->setAttribute(Qt::WA_DeleteOnClose);
    // 把窗口移动到鼠标当前的位置
    this->move(QCursor::pos());

    // 2. 创建布局管理器
    layout = new QGridLayout();
    // layout->setSpacing(0);
    layout->setHorizontalSpacing(10);
    layout->setVerticalSpacing(3);
    layout->setContentsMargins(20, 20, 20, 0);
    layout->setAlignment(Qt::AlignTop);
    this->setLayout(layout);

    // 3. 创建头像
    avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(75, 75);
    avatarBtn->setIconSize(QSize(75, 75));
    avatarBtn->setStyleSheet("QPushButton { border: none; background-color: transparent; }");
    layout->addWidget(avatarBtn, 0, 0, 3, 1);

    QString labelStyle = "QLabel { font-size: 14px; font-weight: 800; }";
    QString btnStyle = "QPushButton { border: none; background-color: transparent; }";
    btnStyle += "QPushButton:pressed { background-color: rgb(210, 210, 210); }";
    QString editStyle = "QLineEdit { border: none; border-radius:5px; padding-left:2px; }";

    int height = 30;

    // 4. 添加用户的 id 的显示
    idTag = new QLabel();
    idTag->setFixedSize(50, height);
    idTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    idTag->setText("序号");
    idTag->setStyleSheet(labelStyle);

    idLabel = new QLabel();
    idLabel->setFixedHeight(height);
    idLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 5. 添加用户的名字的显示
    nameTag = new QLabel();
    nameTag->setFixedSize(50, height);
    nameTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    nameTag->setText("昵称");
    nameTag->setStyleSheet(labelStyle);

    nameLabel = new QLabel();
    nameLabel->setFixedHeight(height);
    nameLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    nameModifyBtn = new QPushButton();
    nameModifyBtn->setFixedSize(70, 25);
    nameModifyBtn->setIconSize(QSize(20, 20));
    nameModifyBtn->setIcon(QIcon(":/resource/image/modify.png"));
    nameModifyBtn->setStyleSheet(btnStyle);

    nameEdit = new QLineEdit();
    nameEdit->setFixedHeight(height);
    nameEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    nameEdit->setStyleSheet(editStyle);
    nameEdit->hide();

    nameSubmitBtn = new QPushButton();
    nameSubmitBtn->setFixedSize(70, 25);
    nameSubmitBtn->setIconSize(QSize(20, 20));
    nameSubmitBtn->setIcon(QIcon(":/resource/image/submit.png"));
    nameSubmitBtn->setStyleSheet(btnStyle);
    nameSubmitBtn->hide();

    // 6. 添加个性签名
    descTag = new QLabel();
    descTag->setFixedSize(50, height);
    descTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    descTag->setText("签名");
    descTag->setStyleSheet(labelStyle);

    descLabel = new QLabel();
    descLabel->setFixedHeight(height);
    descLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    descModifyBtn = new QPushButton();
    descModifyBtn->setFixedSize(70, 25);
    descModifyBtn->setIconSize(QSize(20, 20));
    descModifyBtn->setIcon(QIcon(":/resource/image/modify.png"));
    descModifyBtn->setStyleSheet(btnStyle);

    descEdit = new QLineEdit();
    descEdit->setFixedHeight(height);
    descEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    descEdit->setStyleSheet(editStyle);
    descEdit->hide();

    descSubmitBtn = new QPushButton();
    descSubmitBtn->setFixedSize(70, 25);
    descSubmitBtn->setIconSize(QSize(20, 20));
    descSubmitBtn->setIcon(QIcon(":/resource/image/submit.png"));
    descSubmitBtn->setStyleSheet(btnStyle);
    descSubmitBtn->hide();

    // 7. 添加电话
    phoneTag = new QLabel();
    phoneTag->setFixedSize(50, height);
    phoneTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    phoneTag->setText("电话");
    phoneTag->setStyleSheet(labelStyle);

    phoneLabel = new QLabel();
    phoneLabel->setFixedHeight(height);
    phoneLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    phoneModifyBtn = new QPushButton();
    phoneModifyBtn->setFixedSize(70, 25);
    phoneModifyBtn->setIconSize(QSize(20, 20));
    phoneModifyBtn->setIcon(QIcon(":/resource/image/modify.png"));
    phoneModifyBtn->setStyleSheet(btnStyle);

    phoneEdit = new QLineEdit();
    phoneEdit->setFixedHeight(height);
    phoneEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    phoneEdit->setStyleSheet(editStyle);
    phoneEdit->hide();

    phoneSubmitBtn = new QPushButton();
    phoneSubmitBtn->setFixedSize(70, 25);
    phoneSubmitBtn->setIconSize(QSize(20, 20));
    phoneSubmitBtn->setIcon(QIcon(":/resource/image/submit.png"));
    phoneSubmitBtn->setStyleSheet(btnStyle);
    phoneSubmitBtn->hide();

    // 8. 添加验证码
    verifyCodeTag = new QLabel();
    verifyCodeTag->setFixedSize(50, height);
    verifyCodeTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    verifyCodeTag->setText("验证码");
    verifyCodeTag->setStyleSheet(labelStyle);
    verifyCodeTag->hide();

    verifyCodeEdit = new QLineEdit();
    verifyCodeEdit->setFixedHeight(height);
    verifyCodeEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    verifyCodeEdit->setStyleSheet(editStyle);
    verifyCodeEdit->hide();

    getVerifyCodeBtn = new QPushButton();
    getVerifyCodeBtn->setText("获取验证码");
    getVerifyCodeBtn->setStyleSheet("QPushButton { border: none; background-color: transparent; } QPushButton:pressed { background-color: rgb(231, 231, 231); }");
    getVerifyCodeBtn->setFixedSize(70, height);
    getVerifyCodeBtn->hide();

    // 9. 添加到布局管理器. 第 0 列被头像占用了. 下列内容都是从第一列开始往后排
    layout->addWidget(idTag, 0, 1);
    layout->addWidget(idLabel, 0, 2);

    layout->addWidget(nameTag, 1, 1);
    layout->addWidget(nameLabel, 1, 2);
    layout->addWidget(nameModifyBtn, 1, 3);

    layout->addWidget(descTag, 2, 1);
    layout->addWidget(descLabel, 2, 2);
    layout->addWidget(descModifyBtn, 2, 3);

    layout->addWidget(phoneTag, 3, 1);
    layout->addWidget(phoneLabel, 3, 2);
    layout->addWidget(phoneModifyBtn, 3, 3);

    // 测试代码
#if TEST_UI
    idLabel->setText("1234");
    nameLabel->setText("张三");
    descLabel->setText("从今天开始认真敲代码");
    phoneLabel->setText("18612345678");
    avatarBtn->setIcon(QIcon(":/resource/image/defaultAvatar.png"));
#endif
}

(5)修改 MainWidget::initSignalSlot,添加弹出该窗口的信号和槽函数:

/
/// 点击自己的头像, 弹出对话框显示个人主页
/
connect(userAvatar, &QPushButton::clicked, this, [=]()
{
    SelfInfoWidget* selfInfoWidget = new SelfInfoWidget(this);
    selfInfoWidget->exec();		// 弹出模态对话框
    // selfInfoWidget->show();     // 弹出非模态
});

5.2 实现用户详细信息界面

(1)点击其用户头像时打开如下界面:

(2)创建UserInfoWidget类来实现用户信息窗口:

class UserInfoWidget : public QDialog
{
    Q_OBJECT
public:
    UserInfoWidget(const UserInfo& userInfo, QWidget* parent);

private:
    const UserInfo& userInfo;

    QPushButton* avatarBtn;
    QLabel* idTag;
    QLabel* idLabel;
    QLabel* nameTag;
    QLabel* nameLabel;
    QLabel* phoneTag;
    QLabel* phoneLabel;

    QPushButton* applyBtn;
    QPushButton* sendMessageBtn;
    QPushButton* deleteFriendBtn;
};

(3)UserInfoWidget类的具体实现:

UserInfoWidget::UserInfoWidget(const UserInfo& userInfo, QWidget* parent)
    :QDialog(parent)
    ,userInfo(userInfo)
{
    // 1. 设置基本属性
    this->setFixedSize(400, 200);
    this->setWindowTitle("用户详情");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setAttribute(Qt::WA_DeleteOnClose);
    this->move(QCursor::pos());

    // 2. 创建布局管理器
    QGridLayout* layout = new QGridLayout();
    layout->setVerticalSpacing(10);
    layout->setHorizontalSpacing(20);
    layout->setContentsMargins(40, 20, 0, 0);
    layout->setAlignment(Qt::AlignTop);
    this->setLayout(layout);

    // 3. 添加头像
    avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(75, 75);
    avatarBtn->setIconSize(QSize(75, 75));
    avatarBtn->setIcon(userInfo.avatar);

    QString labelStyle = "QLabel { font-weight: 800; padding-left: 20px;}";
    QString btnStyle = "QPushButton { border: 1px solid rgb(100, 100, 100); border-radius: 5px; background-color: rgb(240, 240, 240); }";
    btnStyle += "QPushButton:pressed { background-color: rgb(205, 205, 205); }";

    int width = 80;
    int height = 30;

    // 4. 添加用户序号
    idTag = new QLabel();
    idTag->setText("序号");
    idTag->setStyleSheet(labelStyle);
    idTag->setFixedSize(width, height);
    idTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    idLabel = new QLabel();
    idLabel->setText(userInfo.userId);
    idLabel->setFixedSize(width, height);
    idLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    // 5. 添加用户昵称
    nameTag = new QLabel();
    nameTag->setText("昵称");
    nameTag->setStyleSheet(labelStyle);
    nameTag->setFixedSize(width, height);
    nameTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    nameLabel = new QLabel();
    nameLabel->setText(userInfo.nickname);
    nameLabel->setFixedSize(width, height);
    nameLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    // 6. 设置电话
    phoneTag = new QLabel();
    phoneTag->setText("电话");
    phoneTag->setStyleSheet(labelStyle);
    phoneTag->setFixedSize(width, height);
    phoneTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    phoneLabel = new QLabel();
    phoneLabel->setText(userInfo.phone);
    phoneLabel->setFixedSize(width, height);
    phoneLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    // 7. 添加功能按钮
    applyBtn = new QPushButton();
    applyBtn->setText("好友申请");
    applyBtn->setFixedSize(80, 30);
    applyBtn->setStyleSheet(btnStyle);
    applyBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    sendMessageBtn = new QPushButton();
    sendMessageBtn->setText("发送消息");
    sendMessageBtn->setFixedSize(80, 30);
    sendMessageBtn->setStyleSheet(btnStyle);
    sendMessageBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    deleteFriendBtn = new QPushButton();
    deleteFriendBtn->setText("删除好友");
    deleteFriendBtn->setFixedSize(80, 30);
    deleteFriendBtn->setStyleSheet(btnStyle);
    deleteFriendBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);

    // 8. 添加上述内容到布局管理器中
    layout->addWidget(avatarBtn, 0, 0, 3, 1);

    layout->addWidget(idTag, 0, 1);
    layout->addWidget(idLabel, 0, 2);

    layout->addWidget(nameTag, 1, 1);
    layout->addWidget(nameLabel, 1, 2);

    layout->addWidget(phoneTag, 2, 1);
    layout->addWidget(phoneLabel, 2, 2);

    layout->addWidget(applyBtn, 3, 0);
    layout->addWidget(sendMessageBtn, 3, 1);
    layout->addWidget(deleteFriendBtn, 3, 2); 
}

5.3 实现单聊消息会话详细信息界面

(1)点击单聊点击 … 时打开的界面如下:

(2)创建SessionDetailWidget类实现会话详情窗口:

class SessionDetailWidget : public QDialog
{
    Q_OBJECT
public:
    SessionDetailWidget(QWidget* parent, const UserInfo& userInfo);

private:
    QPushButton* deleteFriendBtn;
    UserInfo userInfo;
};

(3)SessionDetailWidget类的具体实现:

SessionDetailWidget::SessionDetailWidget(QWidget* parent, const UserInfo& userInfo)
    :QDialog(parent)
    ,userInfo(userInfo)
{
    // 1. 设置基本属性
    this->setWindowTitle("会话详情");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setFixedSize(300, 300);
    this->setStyleSheet("QWidget { background-color: rgb(255, 255, 255); }");
    this->setAttribute(Qt::WA_DeleteOnClose);

    // 2. 创建布局管理器
    QGridLayout* layout = new QGridLayout();
    layout->setSpacing(10);
    layout->setContentsMargins(50, 0, 50, 0);
    this->setLayout(layout);

    // 3. 添加 "创建群聊" 按钮
    AvatarItem* createGroupBtn = new AvatarItem(QIcon(":/resource/image/cross.png"), "添加");
    layout->addWidget(createGroupBtn, 0, 0);

    // 4. 添加当前用户的信息 (临时构造的假数据)
#if TEST_UI
    AvatarItem* currentUser = new AvatarItem(QIcon(":/resource/image/defaultAvatar.png"), "张三123456");
    layout->addWidget(currentUser, 0, 1);
#endif
    AvatarItem* currentUser = new AvatarItem(userInfo.avatar, userInfo.nickname);
    layout->addWidget(currentUser, 0, 1);

    // 5. 添加 "删除好友" 按钮
    deleteFriendBtn = new QPushButton();
    deleteFriendBtn->setFixedHeight(50);
    deleteFriendBtn->setText("删除好友");
    QString style = "QPushButton { border: 1px solid rgb(90, 90, 90); border-radius: 5px; } ";
    style += "QPushButton:pressed { background-color: rgb(235, 235, 235); }";
    deleteFriendBtn->setStyleSheet(style);
    layout->addWidget(deleteFriendBtn, 1, 0, 1, 3);
}

(4)创建AvatarItem类来实现头像+昵称的组合控件:

class AvatarItem : public QWidget
{
    Q_OBJECT

public:
    AvatarItem(const QIcon& avatar, const QString& name);

    QPushButton* getAvatar()
    {
        return avatarBtn;
    }

private:
    QPushButton* avatarBtn;
    QLabel* nameLabel;

};

(5)AvatarItem类的具体实现:

AvatarItem::AvatarItem(const QIcon &avatar, const QString &name)
{
    // 1. 设置自身的基本属性
    this->setFixedSize(70, 80);

    // 2. 创建布局管理器
    QVBoxLayout* layout = new QVBoxLayout();
    layout->setSpacing(0);
    layout->setContentsMargins(0, 0, 0, 0);
    layout->setAlignment(Qt::AlignHCenter);
    this->setLayout(layout);

    // 3. 创建头像
    avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(45, 45);
    avatarBtn->setIconSize(QSize(45, 45));
    avatarBtn->setIcon(avatar);
    avatarBtn->setStyleSheet("QPushButton { border: none; }");

    // 4. 创建名字
    nameLabel = new QLabel();
    nameLabel->setText(name);
    QFont font("微软雅黑", 12);
    nameLabel->setFont(font);
    nameLabel->setAlignment(Qt::AlignCenter);

    // 5. 对名字做 "截断操作"
    const int MAX_WIDTH = 65;
    QFontMetrics metrics(font);
    int totalWidth = metrics.horizontalAdvance(name);
    if(totalWidth >= MAX_WIDTH)
    {
        // 需要截断
        QString tail = "...";
        int tailWidth = metrics.horizontalAdvance(tail);
        int availableWidth = MAX_WIDTH - tailWidth;
        int availableSize = name.size() * ((double)availableWidth / totalWidth);
        QString newName = name.left(availableSize);
        nameLabel->setText(newName + tail);
    }

    // 6.将按钮和文本设置到布局当中
    layout->addWidget(avatarBtn);
    layout->addWidget(nameLabel);
}

(6)实现弹出对话框在MainWidget::initSignalSlot当中实现槽函数:

    /
/// 点击会话详情按钮, 弹出会话详情窗口
/
connect(extraBtn, &QPushButton::clicked, this, [=]()
{
    // 判定当前会话是单聊还是群聊
    // 获取到当前会话详细信息, 通过会话中的 userId 属性
    ChatSessionInfo* chatSessionInfo = dataCenter->findChatSessionById(dataCenter->getCurrentChatSessionId());
    if(chatSessionInfo == nullptr)
    {
        LOG() << "当前会话不存在, 无法弹出会话详情对话框";
        return;
    }

    bool isSingleChat  = chatSessionInfo->userId != "";
    if(isSingleChat )
    {
        // 单聊, 弹出这个窗口
        UserInfo* userInfo = dataCenter->findFriendById(chatSessionInfo->userId);
        if(userInfo == nullptr)
        {
            LOG() << "单聊会话对应的用户不存在, 无法弹出会话详情窗口";
            return;
        }

        SessionDetailWidget* sessiondetailwidget = new SessionDetailWidget(this, *userInfo);
        sessiondetailwidget->exec();
    }
    else
    {
        GroupSessionDetailWidget* groupsessiondetailwidget = new GroupSessionDetailWidget(this);
        groupsessiondetailwidget->exec();
    } 
});

5.4 实现创建群聊会话选择好友界面

(1)当点击如下按钮是跳转出来选择已有好友进入群聊界面:

(2)创建ChooseFriendDialog类来实现好友选择窗口:

class ChooseFriendDialog : public QDialog
{
    Q_OBJECT
public:
    ChooseFriendDialog(QWidget* parent, const QString& userId);

    // 针对左侧窗口进行初始化
    void initLeft(QHBoxLayout *layout);

    // 针对右侧窗口进行初始化
    void initRight(QHBoxLayout *layout);
    void clickOkBtn();
    QList<QString> generateMemberList();

    void addFriend(const QString& userId, const QIcon& avatar, const QString& name, bool checked);
    void addSelectedFriend(const QString& userId, const QIcon &avatar, const QString &name);
    void deleteSelectedFriend(const QString& userId);

private:
    // 保存左侧全部好友列表的 QWidget
    QWidget* totalContainer;
    // 保存右侧选中好友列表的 QWidget
    QWidget* selectedContainer;

    // 当前选择窗口是点击哪个用户弹出来
    QString userId;
};

(3)ChooseFriendDialog类的具体实现:

ChooseFriendDialog::ChooseFriendDialog(QWidget* parent, const QString& userId)
    :QDialog(parent)
    ,userId(userId)
{
    // 1. 设置窗口的基本属性
    this->setWindowTitle("选择好友");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setFixedSize(750, 550);
    this->setStyleSheet("QDialog { background-color: rgb(255, 255, 255);}");
    this->setAttribute(Qt::WA_DeleteOnClose);

    // 2. 创建布局管理器
    QHBoxLayout* layout = new QHBoxLayout();
    layout->setContentsMargins(0, 0, 0, 0);
    layout->setSpacing(0);
    this->setLayout(layout);

    // 3. 针对左侧窗口进行初始化
    initLeft(layout);

    // 4. 针对右侧窗口进行初始化
    initRight(layout);
}
  • 实现筛选好友列表:
// 针对左侧窗口进行初始化
void ChooseFriendDialog::initLeft(QHBoxLayout *layout)
{
    // 1. 创建滚动区域
    QScrollArea* scrollArea = new QScrollArea();
    scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    scrollArea->setWidgetResizable(true);
    scrollArea->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal { height: 0px;}");
    scrollArea->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(255, 255, 255) }");
    scrollArea->setStyleSheet("QScrollArea { border:none; }");
    layout->addWidget(scrollArea, 1);

    // 2. 创建 QWidget 设置到滚动区域中.
    totalContainer = new QWidget();
    totalContainer->setObjectName("totalContainer");
    totalContainer->setStyleSheet("#totalContainer { background-color: rgb(255, 255, 255); }");
    scrollArea->setWidget(totalContainer);

    // 3. 创建左侧子窗口内部的 垂直布局管理器
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(0);
    vlayout->setContentsMargins(0, 0, 0, 0);
    vlayout->setAlignment(Qt::AlignTop);
    totalContainer->setLayout(vlayout);

    // 还需要进一步的添加 vlayout 内部的元素, 才能看到效果!
    // 此处也是先构造测试数据, 后续接入服务器之后, 从服务器拿到真实的好友列表, 再添加真实的数据
#if TEST_UI
    QIcon defaultAvatar(":/resource/image/defaultAvatar.png");
    for (int i = 0; i < 30; ++i)
    {
        this->addFriend(QString::number(1000 + i), defaultAvatar, "张三" + QString::number(i), false);
    }
#endif
}

void ChooseFriendDialog::addFriend(const QString& userId, const QIcon& avatar, const QString& name, bool checked)
{
    ChooseFriendItem* item = new ChooseFriendItem(this, userId, avatar, name, checked);
    totalContainer->layout()->addWidget(item);
}
  • 创建ChooseFriendItem类来实现好友元素:
class ChooseFriendItem : public QWidget {
    Q_OBJECT
public:
    ChooseFriendItem(ChooseFriendDialog* owner, const QString& userId, const QIcon& avatar, const QString& name, bool checked);

    void paintEvent(QPaintEvent* event) override;
    void enterEvent(QEnterEvent* event) override;
    void leaveEvent(QEvent* event) override;

    const QString& getUserId() const
    {
        return userId;
    }

    QCheckBox* getCheckBox()
    {
        return checkBox;
    }

private:
    bool isHover = false;

    QCheckBox* checkBox;
    QPushButton* avatarBtn;
    QLabel* nameLabel;
    ChooseFriendDialog* owner;			// 记录了哪个 QWidget 持有了这个 Item. 此处的 QWidget 应该是一个 ChooseFriendDialog

    QString userId;   					// 记录了当前 Item 对应的 userId 是啥.

};
  • ChooseFriendItem类的具体实现:
ChooseFriendItem::ChooseFriendItem(ChooseFriendDialog* owner, const QString& userId, const QIcon& avatar, const QString& name, bool checked)
    :userId(userId)
    ,owner(owner)
{
    // 1. 设置控件的基本属性
    this->setFixedHeight(50);
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 2. 设置布局管理器
    QHBoxLayout* layout = new QHBoxLayout();
    layout->setSpacing(10);
    layout->setContentsMargins(20, 0, 20, 0);
    this->setLayout(layout);

    // 3. 创建复选框
    checkBox = new QCheckBox();
    checkBox->setChecked(checked);
    checkBox->setFixedSize(25, 25);
    QString style = "QCheckBox { background-color: transparent; } QCheckBox::indicator { width: 20px; height: 20px; image: url(:/resource/image/unchecked.png);}";
    style += "QCheckBox::indicator:checked { image: url(:/resource/image/checked.png);}";
    checkBox->setStyleSheet(style);

    // 4. 创建头像
    avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(40, 40);
    avatarBtn->setIconSize(QSize(40, 40));
    avatarBtn->setIcon(QIcon(avatar));

    // 5. 创建名字
    nameLabel = new QLabel();
    nameLabel->setText(name);
    nameLabel->setStyleSheet("QLabel {background-color: transparent;}");

    // 6. 添加上述内容到布局管理器中
    layout->addWidget(checkBox);
    layout->addWidget(avatarBtn);
    layout->addWidget(nameLabel);

    // 7. 连接信号槽
    connect(checkBox, &QCheckBox::toggled, this, [=](bool checked)
    {
        if (checked)
        {
            // 勾选了复选框, 把当前这个 Item, 添加到右侧的已选择区域
            owner->addSelectedFriend(userId, avatar, name);
        }
        else
        {
            // 取消勾选
            owner->deleteSelectedFriend(userId);
        }
    });
}

void ChooseFriendItem::paintEvent(QPaintEvent* event)
{
    (void)event;
    QPainter painter(this);
    if(isHover)
    {
        // 绘制成深色
        painter.fillRect(this->rect(), QColor(230, 230, 230));
    }
    else
    {
        // 绘制成浅色
        painter.fillRect(this->rect(), QColor(255, 255, 255));
    }
}

void ChooseFriendItem::enterEvent(QEnterEvent* event)
{
    (void)event;
    isHover = true;
    // update 相当于 "更新界面"
    this->update();

    // 或者使用下列代码
    // this->repaint();
}

void ChooseFriendItem::leaveEvent(QEvent* event)
{
    (void)event;
    isHover = false;
    this->update();
}
  • 实现已选中好友列表:
// 针对右侧窗口进行初始化
void ChooseFriendDialog::initRight(QHBoxLayout *layout)
{
    // 1. 创建右侧的布局管理器
    QGridLayout* gridLayout = new QGridLayout();
    gridLayout->setContentsMargins(20, 0, 20, 20);
    gridLayout->setSpacing(10);
    layout->addLayout(gridLayout, 1);

    // 2. 创建 "提示" label
    QLabel* tipLabel = new QLabel();
    tipLabel->setText("选择联系人");
    tipLabel->setFixedHeight(30);
    tipLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    tipLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
    tipLabel->setStyleSheet("QLabel { font-size: 16px; font-weight: 700}");

    // 3. 创建滚动区域
    QScrollArea* scrollArea = new QScrollArea();
    scrollArea->setWidgetResizable(true);
    scrollArea->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(255, 255, 255);}");
    scrollArea->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal {height: 0px;}");
    scrollArea->setStyleSheet("QScrollArea {border: none;}");
    scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);

    // 4. 创建滚动区域中的 QWidget
    selectedContainer = new QWidget();
    selectedContainer->setObjectName("selectedContainer");
    selectedContainer->setStyleSheet("#selectedContainer { background-color: rgb(255, 255, 255); }");
    scrollArea->setWidget(selectedContainer);

    // 5. 创建 selectedContainer 中的 "垂直布局"
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(0);
    vlayout->setContentsMargins(0, 0, 0, 0);
    vlayout->setAlignment(Qt::AlignTop);
    selectedContainer->setLayout(vlayout);

    // 6. 创建底部按钮
    QString style = "QPushButton { color: rgb(7, 191, 96); background-color: rgb(240, 240, 240); border: none; border-radius: 5px;}";
    style += "QPushButton:hover { background-color: rgb(220, 220, 220); } QPushButton:pressed { background-color: rgb(200, 200, 200); }";

    QPushButton* okBtn = new QPushButton();
    okBtn->setFixedHeight(40);
    okBtn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    okBtn->setText("完成");
    okBtn->setStyleSheet(style);

    QPushButton* cancelBtn = new QPushButton();
    cancelBtn->setFixedHeight(40);
    cancelBtn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    cancelBtn->setText("取消");
    cancelBtn->setStyleSheet(style);

    // 7. 把上述控件添加到布局中
    gridLayout->addWidget(tipLabel, 0, 0, 1, 9);
    gridLayout->addWidget(scrollArea, 1, 0, 1, 9);
    gridLayout->addWidget(okBtn, 2, 1, 1, 3);
    gridLayout->addWidget(cancelBtn, 2, 5, 1, 3);

    // 构造一些数据用来进行测试界面
    // 此处的数据通过勾选左侧列表来生成.
    // QIcon defaultAvatar(":/resource/image/defaultAvatar.png");
    // for (int i = 0; i < 10; ++i)
    // {
    //     this->addSelectedFriend(QString::number(1000 + i), defaultAvatar, "张三" + QString::number(i));
    // }

    // 8. 添加信号槽, 处理 ok 和 cancel 的点击
    connect(okBtn, &QPushButton::clicked, this, &ChooseFriendDialog::clickOkBtn);
    connect(cancelBtn, &QPushButton::clicked, this, [=]()
    {
        // 关闭窗口
        this->close();
    });
}

void ChooseFriendDialog::addSelectedFriend(const QString& userId, const QIcon &avatar, const QString &name)
{
    ChooseFriendItem* item = new ChooseFriendItem(this, userId, avatar, name, true);
    selectedContainer->layout()->addWidget(item);
}

void ChooseFriendDialog::deleteSelectedFriend(const QString& userId)
{
    // 遍历 selectedContainer 中的每个 Item, 对比每个 Item 里的 userId , 是否是要删除的 userId.
    QVBoxLayout* vlayout = dynamic_cast<QVBoxLayout*>(selectedContainer->layout());

    // 由于是要 "遍历" + "删除" 需要从后往前进行
    for (int i = vlayout->count() - 1; i >= 0; --i)
    {
        auto* item = vlayout->itemAt(i);
        if (item == nullptr || item->widget() == nullptr)
        {
            continue;
        }

        ChooseFriendItem* chooseFriendItem = dynamic_cast<ChooseFriendItem*>(item->widget());
        // 判定当前的 Item 的 userId 是否是要删除的 userId
        if (chooseFriendItem->getUserId() != userId)
        {
            continue;
        }

        vlayout->removeWidget(chooseFriendItem);
        // 此处直接使用 delete 可能导致程序直接崩溃. 因为 delete 该对象的时候, 该对象内部的 QCheckBox 还在使用中 (触发着信号槽呢)
        // 改成 deleteLater, 就相当于把 delete 操作委托给 Qt 自身来完成了. 告诉 Qt 框架说, 你要删除这个对象. 至于啥时候删除 Qt
        // 会确保在 Qt 自身用完了之后, 去真正删除.
        // delete chooseFriendItem;
        chooseFriendItem->deleteLater();
    }

    // 再遍历一下左侧列表, 把左侧列表中对应 item 的 checkBox 勾选状态取消掉.
    QVBoxLayout* vlayoutLeft = dynamic_cast<QVBoxLayout*>(totalContainer->layout());
    for (int i = 0; i < vlayoutLeft->count(); ++i)
    {
        auto* item = vlayoutLeft->itemAt(i);
        if(item == nullptr || item->widget() == nullptr)
        {
            continue;
        }

        ChooseFriendItem* chooseFriendItem = dynamic_cast<ChooseFriendItem*>(item->widget());
        if(chooseFriendItem->getUserId() != userId)
        {
            continue;
        }

        // 取消 checkBox 选中状态
        chooseFriendItem->getCheckBox()->setChecked(false);
    }
}

5.5 实现群聊消息会话详细信息界面

(1)点击群聊 … 时打开的界面如下:

(2)创建GroupSessionDetailWidget类实现群组会话详情窗口:

class GroupSessionDetailWidget : public QDialog
{
    Q_OBJECT
public:
    GroupSessionDetailWidget(QWidget* parent);
    
    void addMember(AvatarItem* avatarItem);

private:
    QGridLayout* glayout;
    QLabel* groupNameLabel;

    // 表示当前要添加的 AvatarItem 处在的行和列
    // 由于整个界面上存在 + 这个按钮, 占据了 (0, 0) 位置. 接下来添加 AvatarItem 就要从
    // (0, 1) 位置添加了
    int curRow = 0;
    int curCol = 1;
};

(3)GroupSessionDetailWidget类的具体实现:

GroupSessionDetailWidget::GroupSessionDetailWidget(QWidget* parent)
    :QDialog(parent)
{
    // 1. 设置窗口的基本属性.
    this->setFixedSize(410, 600);
    this->setWindowTitle("群组详情");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setStyleSheet("QDialog { background-color: rgb(255, 255, 255); }");
    this->setAttribute(Qt::WA_DeleteOnClose);

    // 2. 创建布局管理器
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(0);
    vlayout->setContentsMargins(50, 20, 50, 50);
    vlayout->setAlignment(Qt::AlignTop);
    this->setLayout(vlayout);

    // 3. 创建滚动区域
    // 3.1 创建 QScrollArea 对象
    QScrollArea* scrollArea = new QScrollArea();
    scrollArea->setWidgetResizable(true);
    scrollArea->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(255, 255, 255); }");
    scrollArea->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal { height: 0; }");
    scrollArea->setFixedSize(310, 350);
    scrollArea->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    scrollArea->setStyleSheet("QWidget { background-color: transparent; border: none; }");

    // 3.2 创建一个 QScrollArea 内部的 QWidget
    QWidget* container = new QWidget();
    scrollArea->setWidget(container);

    // 3.3 给 container 里面添加一个 网格布局
    glayout = new QGridLayout();
    glayout->setSpacing(10);
    glayout->setContentsMargins(0, 0, 0, 0);
    glayout->setAlignment(Qt::AlignTop | Qt::AlignLeft);
    container->setLayout(glayout);

    // 3.4 把滚动区域, 添加到布局管理器中
    vlayout->addWidget(scrollArea);

    // 4. 添加 "添加按钮"
    AvatarItem* addBtn = new AvatarItem(QIcon(":/resource/image/cross.png"), "添加");
    glayout->addWidget(addBtn, 0, 0);

    // 5. 添加 "群聊名称"
    QLabel* groupNameTag = new QLabel();
    groupNameTag->setText("群聊名称");
    groupNameTag->setStyleSheet("QLabel {font-weight: 700; font-size: 16px;}");
    groupNameTag->setFixedHeight(50);
    groupNameTag->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
    // 设置文字在 QLabel 内部的对齐方式.
    groupNameTag->setAlignment(Qt::AlignBottom);
    // 这里设置的 QLabel 在布局管理器中的对齐方式.
    vlayout->addWidget(groupNameTag);

    // 6. 添加 真实的群聊名字 和 修改按钮
    // 6.1 创建水平布局
    QHBoxLayout* hlayout = new QHBoxLayout();
    hlayout->setSpacing(0);
    hlayout->setContentsMargins(0, 0, 0, 0);
    vlayout->addLayout(hlayout);

    // 6.2 创建真实群聊名字的 label
    groupNameLabel = new QLabel();
    groupNameLabel->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding);
    groupNameLabel->setFixedHeight(50);
    groupNameLabel->setStyleSheet("QLabel { font-size: 18px; }");
    hlayout->addWidget(groupNameLabel, 0, Qt::AlignLeft | Qt::AlignVCenter);

    // 6.3 创建 "修改按钮"
    QPushButton* modifyBtn = new QPushButton();
    modifyBtn->setFixedSize(30, 30);
    modifyBtn->setIconSize(QSize(30, 30));
    modifyBtn->setIcon(QIcon(":/resource/image/modify.png"));
    modifyBtn->setStyleSheet("QPushButton { border: none; background-color: transparent; } QPushButton:pressed { background-color: rgb(230, 230, 230); }");
    hlayout->addWidget(modifyBtn, 0, Qt::AlignRight | Qt::AlignVCenter);

    // 7. 退出群聊按钮
    QPushButton* exitGroupBtn = new QPushButton();
    exitGroupBtn->setText("退出群聊");
    exitGroupBtn->setFixedHeight(50);
    exitGroupBtn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    QString btnStyle = "QPushButton { border: 1px solid rgb(90, 90, 90); border-radius: 5px; background-color: transparent;}";
    btnStyle += "QPushButton:pressed { background-color: rgb(230, 230, 230); }";
    exitGroupBtn->setStyleSheet(btnStyle);
    vlayout->addWidget(exitGroupBtn);

    // 此处构造假的数据用来测试界面
#if TEST_UI
    groupNameLabel->setText("人类吃喝行为研究小组");
    QIcon avatar(":/resource/image/defaultAvatar.png");
    for(int i = 0; i < 20; ++i)
    {
        AvatarItem* item = new AvatarItem(avatar, "张三" + QString::number(i));
        this->addMember(item);
    }
#endif
}

void GroupSessionDetailWidget::addMember(AvatarItem* avatarItem)
{
    const int MAX_COL = 4;
    if(curCol >= MAX_COL)
    {
        // 换行操作
        ++curRow;
        curCol = 0;
    }

    glayout->addWidget(avatarItem, curRow, curCol);
    ++curCol;
}

5.6 实现添加好友界面

(1)点击主界面上方的 “+” 按钮,弹出添加好友界面:

(2)创建AddFriendDialog类实现添加好友窗口:

class AddFriendDialog : public QDialog
{
    Q_OBJECT
public:
    AddFriendDialog(QWidget* parent);

    // 往窗口中新增一个好友搜索结果
    void addResult(const UserInfo& userInfo);

    // 清空界面上所有的好友结果
    void clear();

    void setSearchKey(const QString& searchKey);

private:
    QLineEdit* searchEdit;

    // 整个窗口总的网格布局
    QGridLayout* layout;

    // 保存搜索好友的结果
    QWidget* resultContainer;
};

(3)AddFriendDialog类的具体实现:

AddFriendDialog::AddFriendDialog(QWidget* parent)
    :QDialog(parent)
{
    // 1. 设置基本属性
    this->setFixedSize(500, 500);
    this->setWindowTitle("添加好友");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setStyleSheet("QDialog {background-color: rgb(255, 255, 255); }");
    this->setAttribute(Qt::WA_DeleteOnClose);	// 不要忘记这个属性!!!

    // 2. 添加布局管理器
    layout = new QGridLayout();
    layout->setSpacing(10);
    layout->setContentsMargins(20, 20, 20, 0);
    this->setLayout(layout);

    // 3. 创建搜索框
    searchEdit = new QLineEdit();
    searchEdit->setFixedHeight(50);
    searchEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    QString style = "QLineEdit { border: none; border-radius: 10px; font-size: 16px; background-color: rgb(240, 240, 240); padding-left: 5px;}";
    searchEdit->setStyleSheet(style);
    searchEdit->setPlaceholderText("按手机号/用户序号/昵称搜索");
    layout->addWidget(searchEdit, 0, 0, 1, 8);

    // 4. 创建搜索按钮
    QPushButton* searchBtn = new QPushButton();
    searchBtn->setFixedSize(50, 50);
    searchBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    searchBtn->setIconSize(QSize(30, 30));
    searchBtn->setIcon(QIcon(":/resource/image/search.png"));
    QString btnStyle = "QPushButton { border: none; background-color: rgb(240, 240, 240); border-radius: 10px; }";
    btnStyle += "QPushButton:hover { background-color: rgb(220, 220, 220); } QPushButton:pressed { background-color: rgb(200, 200, 200); } ";
    searchBtn->setStyleSheet(btnStyle);
    layout->addWidget(searchBtn, 0, 8, 1, 1);

    // 5. 添加滚动区域
    initResultArea();

    // 构造假的数据, 验证界面效果
#if TEST_UI
    QIcon avatar(":/resource/image/defaultAvatar.png");
    for(int i = 0; i < 20; ++i)
    {
        // new 出来这个对象, 再往 addResult 中添加. FriendResultItem 中持有了 UserInfo 的 const 引用. 需要确保引用是有效的引用
        UserInfo* userInfo = new UserInfo();
        userInfo->userId = QString::number(1000 + i);
        userInfo->nickname = "张三" + QString::number(i);
        userInfo->description = "这是一段个性签名";
        userInfo->avatar = avatar;
        this->addResult(*userInfo);
    }
#endif
}

void AddFriendDialog::initResultArea()
{
    // 1. 创建滚动区域对象
    QScrollArea* scrollArea = new QScrollArea();
    scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    scrollArea->setWidgetResizable(true);
    scrollArea->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal {height: 0;} ");
    scrollArea->verticalScrollBar()->setStyleSheet("QScrollBar:vertical {width: 2px; background-color: rgb(255, 255, 255);}");
    scrollArea->setStyleSheet("QScrollArea { border: none; }");
    layout->addWidget(scrollArea, 1, 0, 1, 9);

    // 2. 创建 QWidget
    resultContainer = new QWidget();
    resultContainer->setObjectName("resultContainer");
    resultContainer->setStyleSheet("#resultContainer { background-color: rgb(255, 255, 255); } ");
    scrollArea->setWidget(resultContainer);

    // 3. 给这个 QWidget 里面添加元素, 需要给它创建垂直的布局管理器
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(0);
    vlayout->setContentsMargins(0, 0, 0, 0);
    resultContainer->setLayout(vlayout);
}

// 往窗口中新增一个好友搜索结果
void AddFriendDialog::addResult(const UserInfo& userInfo)
{
    FriendResultItem* item = new FriendResultItem(userInfo);
    resultContainer->layout()->addWidget(item);
}

// 清空界面上所有的好友结果
void AddFriendDialog::clear()
{
    // 从后往前遍历
    QVBoxLayout* layout = dynamic_cast<QVBoxLayout*>(resultContainer->layout());
    for(int i = layout->count(); i >= 0; --i)
    {
        QLayoutItem* layoutItem = layout->takeAt(i);
        if(layoutItem == nullptr || layoutItem->widget() == nullptr)
        {
            continue;
        }

        // 删除这里面持有的元素
        delete layoutItem->widget();
    }
}

void AddFriendDialog::setSearchKey(const QString &searchKey)
{
    searchEdit->setText(searchKey);
}

(4)创建FriendResultItem类实现好友搜索结果元素:

class FriendResultItem : public QWidget
{
    Q_OBJECT

public:
    FriendResultItem(const UserInfo& userInfo);

private:
    const UserInfo& userInfo;

    QPushButton* addBtn;
};

(5)FriendResultItem类的具体实现:

FriendResultItem::FriendResultItem(const UserInfo& userInfo)
    :userInfo(userInfo)
{
    // 1. 设置基本属性
    this->setFixedHeight(70);
    this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 2. 创建布局管理器
    QGridLayout* layout = new QGridLayout();
    layout->setSpacing(0);
    layout->setHorizontalSpacing(10);
    layout->setContentsMargins(0, 0, 20, 0);
    this->setLayout(layout);

    // 3. 创建头像
    QPushButton* avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(50, 50);
    avatarBtn->setIconSize(QSize(50, 50));
    avatarBtn->setIcon(userInfo.avatar);
    layout->addWidget(avatarBtn);

    // 4. 创建昵称
    QLabel* nameLabel = new QLabel();
    nameLabel->setFixedHeight(35);      // 整个 Item 高度是 70. 昵称和个性签名各自占一半.
    nameLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    nameLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
    nameLabel->setStyleSheet("QLabel { font-size: 16px; font-weight: 700;}");
    nameLabel->setText(userInfo.nickname);

    // 5. 创建个性签名
    QLabel* descLabel = new QLabel();
    descLabel->setFixedHeight(35);
    descLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    descLabel->setAlignment(Qt::AlignLeft | Qt::AlignVCenter);
    descLabel->setStyleSheet("QLabel { font-size: 14px; }");
    descLabel->setText(userInfo.description);

    // 6. 创建添加好友按钮
    addBtn = new QPushButton();
    addBtn->setFixedSize(100, 40);
    addBtn->setText("添加好友");
    QString btnStyle = "QPushButton { border: none; background-color: rgb(137, 217, 97); color: rgb(255, 255, 255); border-radius: 10px;} ";
    btnStyle += "QPushButton:pressed { background-color: rgb(200, 200, 200); }";
    addBtn->setStyleSheet(btnStyle);


    // 7. 把上述内容, 添加到布局管理器中
    layout->addWidget(avatarBtn, 0, 0, 2, 1);
    layout->addWidget(nameLabel, 0, 1);
    layout->addWidget(descLabel, 1, 1);
    layout->addWidget(addBtn, 0, 2, 2, 1);
}

(6)在MainWidget::initSignalSlot中弹出添加好友对话框:

  • 处理 + 按钮的点击:
connect(addFriendBtn, &QPushButton::clicked, this, [=]()
{
    AddFriendDialog* addFriendDialog = new AddFriendDialog(this);
    addFriendDialog->exec();
});
  • 处理输入框输入:
connect(searchEdit, &QLineEdit::textEdited, this, [=]()
{
    const QString& searchKey = searchEdit->text();
    AddFriendDialog* addFriendDialog = new AddFriendDialog(this);
    addFriendDialog->setSearchKey(searchKey);
    // 清空主窗口的文本内容
    searchEdit->setText("");
    addFriendDialog->exec();
});

5.7 实现历史消息界面

(1)点击查看历史消息按钮时弹出如下该窗口:


(2)创建HistoryMessageWidget类实现历史消息窗口:

class HistoryMessageWidget : public QDialog
{
    Q_OBJECT

public:
    HistoryMessageWidget(QWidget* parent);

    // 在窗口中添加一个历史消息
    void addHistoryMessage(const Message& message);

    // 清空窗口中所有的历史消息
    void clear();

private:
    // 持有所有的历史消息结果的容器对象
    QWidget* container;

    QLineEdit* searchEdit;
    QRadioButton* keyRadioBtn;
    QRadioButton* timeRadioBtn;
    QDateTimeEdit* begTimeEdit;
    QDateTimeEdit* endTimeEdit;

    void initScrollArea(QGridLayout* layout);
};

(3)HistoryMessageWidget类的具体实现:

HistoryMessageWidget::HistoryMessageWidget(QWidget* parent)
    :QDialog(parent)
{
    // 1. 设置窗口本身属性
    this->setFixedSize(600, 600);
    this->setWindowTitle("历史消息");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setStyleSheet("QWidget { background-color: rgb(255, 255, 255); }");
    this->setAttribute(Qt::WA_DeleteOnClose);

    // 2. 创建布局管理器.
    QGridLayout* layout = new QGridLayout();
    layout->setSpacing(10);
    layout->setContentsMargins(30, 30, 30, 0);
    this->setLayout(layout);

    // 3. 创建单选按钮
    keyRadioBtn = new QRadioButton();
    timeRadioBtn = new QRadioButton();
    keyRadioBtn->setText("按关键字查询");
    timeRadioBtn->setText("按时间查询");
    // 默认按照关键词查询
    keyRadioBtn->setChecked(true);
    layout->addWidget(keyRadioBtn, 0, 0, 1, 2);
    layout->addWidget(timeRadioBtn, 0, 2, 1, 2);

    // 4. 创建搜索框
    searchEdit = new QLineEdit();
    searchEdit->setFixedHeight(50);
    searchEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    searchEdit->setPlaceholderText("要搜索的关键词");
    searchEdit->setStyleSheet("QLineEdit { border: none; border-radius: 10px; background-color: rgb(240, 240, 240); font-size: 16px; padding-left: 10px; }");
    layout->addWidget(searchEdit, 1, 0, 1, 8);

    // 5. 创建搜索按钮
    QPushButton* searchBtn = new QPushButton();
    searchBtn->setFixedSize(50, 50);
    searchBtn->setIconSize(QSize(50, 50));
    searchBtn->setIcon(QIcon(":/resource/image/search.png"));
    QString btnStyle = "QPushButton { border: none; background-color: rgb(240, 240, 240); border-radius: 10px; }";
    btnStyle += "QPushButton:pressed { background-color: rgb(220, 220, 220); }";
    searchBtn->setStyleSheet(btnStyle);
    layout->addWidget(searchBtn, 1, 8, 1, 1);

    // 6. 创建时间相关的部分控件, 初始情况下要隐藏
    QLabel* begTag = new QLabel();
    begTag->setText("开始时间");
    QLabel* endTag = new QLabel();
    endTag->setText("结束时间");
    begTimeEdit = new QDateTimeEdit();
    endTimeEdit = new QDateTimeEdit();
    // [联调新增]
    begTimeEdit->setDisplayFormat("yyyy-MM-dd hh:mm");
    endTimeEdit->setDisplayFormat("yyyy-MM-dd hh:mm");
    begTimeEdit->setFixedHeight(40);
    endTimeEdit->setFixedHeight(40);
    begTag->hide();
    endTag->hide();
    begTimeEdit->hide();
    endTimeEdit->hide();

    // 7. 创建滚动区域
    initScrollArea(layout);

    // 8. 设置槽函数
    connect(keyRadioBtn, &QRadioButton::clicked, this, [=]()
    {
        // 把时间相关的控件, 隐藏起来
        layout->removeWidget(begTag);
        layout->removeWidget(begTimeEdit);
        layout->removeWidget(endTag);
        layout->removeWidget(endTimeEdit);
        begTag->hide();
        begTimeEdit->hide();
        endTag->hide();
        endTimeEdit->hide();

        // 把关键词搜索框显示加入布局
        layout->addWidget(searchEdit, 1, 0, 1, 8);
        searchEdit->show();
    });

    connect(timeRadioBtn, &QRadioButton::clicked, this, [=]()
    {
        layout->removeWidget(searchEdit);
        searchEdit->hide();

        // 把时间相关的控件, 添加到布局中, 并且进行显示.
        layout->addWidget(begTag, 1, 0, 1, 1);
        layout->addWidget(begTimeEdit, 1, 1, 1, 3);
        layout->addWidget(endTag, 1, 4, 1, 1);
        layout->addWidget(endTimeEdit, 1, 5, 1, 3);
        begTag->show();
        begTimeEdit->show();
        endTag->show();
        endTimeEdit->show();
    });

    connect(searchBtn, &QPushButton::clicked, this, &HistoryMessageWidget::clickSearchBtn);

    // 构造测试数据
#if TEST_UI
    for (int i = 0; i < 30; ++i)
    {
        // 注意此处代码和前面的差别.
        // 前面有个代码, UserInfo 必须要 new 出来才能构造. 当时 Item 对象里, 持有了 const UserInfo& , 不是 new 的话
        // 就可能使引用指向的对象失效的.
        // 此处后续的代码, 都是按照传值的方式来使用 message 的内容, 不 new 也行.
        model::UserInfo sender;
        sender.userId = "";
        sender.nickname = "张三" + QString::number(i);
        sender.avatar = QIcon(":/resource/image/defaultAvatar.png");
        sender.description = "";
        sender.phone = "18612345678";
        Message message = Message::makeMessage(model::TEXT_TYPE, "", sender, QString("消息内容" + QString::number(i)).toUtf8(), "");
        this->addHistoryMessage(message);
    }
#endif
}

// 在窗口中添加一个历史消息
void HistoryMessageWidget::addHistoryMessage(const Message& message)
{
    HistoryItem* item = HistoryItem::makeHistoryItem(message);
    container->layout()->addWidget(item);
}

// 清空窗口中所有的历史消息
void HistoryMessageWidget::clear()
{
    QVBoxLayout* layout = dynamic_cast<QVBoxLayout*>(container->layout());
    for(int i = layout->count() - 1; i >= 0; --i)
    {
        // 之前使用的是 takeAt. 效果和这个是一样的.
        QWidget* w = layout->itemAt(i)->widget();
        if(w == nullptr)
        {
            continue;
        }

        layout->removeWidget(w);
        w->deleteLater();
    }
}

// 展示消息内容区域:
void HistoryMessageWidget::initScrollArea(QGridLayout* layout)
{
    // 1. 创建滚动区域对象
    QScrollArea* scrollArea = new QScrollArea();
    scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    scrollArea->setWidgetResizable(true);
    scrollArea->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 2px; background-color: rgb(255, 255, 255); }");
    scrollArea->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal { height: 0; }");
    scrollArea->setStyleSheet("QScrollArea { border: none; }");

    // 2. 创建 QWidget, 持有要加入的新的内容
    container = new QWidget();
    scrollArea->setWidget(container);

    // 3. 创建 container 中的布局管理器.
    QVBoxLayout* vlayout = new QVBoxLayout();
    vlayout->setSpacing(10);
    vlayout->setContentsMargins(0, 0, 0, 0);
    vlayout->setAlignment(Qt::AlignTop);
    container->setLayout(vlayout);

    // 4. 把滚动区加入到整个 layout 中
    layout->addWidget(scrollArea, 2, 0, 1, 9);
}

(4)创建HistoryItem类实现历史消息条目:

class HistoryItem : public QWidget
{
    Q_OBJECT

public:
    HistoryItem() {}

    static HistoryItem* makeHistoryItem(const Message& message);
};

(5)HistoryItem类的具体实现:

HistoryItem* HistoryItem::makeHistoryItem(const Message& message)
{
    // 1. 创建出对象
    HistoryItem* item = new HistoryItem();
    item->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred);

    // 2. 创建布局
    QGridLayout* layout = new QGridLayout();
    layout->setVerticalSpacing(0);
    layout->setHorizontalSpacing(10);
    layout->setContentsMargins(0, 0, 0, 0);
    item->setLayout(layout);

    // 3. 创建头像
    QPushButton* avatarBtn = new QPushButton();
    avatarBtn->setFixedSize(40, 40);
    avatarBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    avatarBtn->setIconSize(QSize(40, 40));
    // 当前消息发送者的头像
    avatarBtn->setIcon(message.sender.avatar);
    avatarBtn->setStyleSheet("QPushButton { border: none; }");

    // 4. 创建昵称和时间
    QLabel* nameLabel = new QLabel();
    nameLabel->setText(message.sender.nickname + " | " + message.time);
    nameLabel->setFixedHeight(20);   // 高度设置为头像高度的一半
    nameLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);

    // 5. 消息内容部分
    QWidget* contentWidget = nullptr;
    if(message.messageType == model::TEXT_TYPE)
    {
        // 文本消息
        QLabel* label = new QLabel();
        label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
        label->setWordWrap(true);
        label->setText(QString(message.content));
        label->adjustSize();	// 设置让 label 能够自动调整大小
        contentWidget = label;
    }
    else if(message.messageType == model::IMAGE_TYPE)
    {
        // 图片消息
        contentWidget = new ImageButton(message.fileId, message.content);
    }
    else if(message.messageType == model::FILE_TYPE)
    {
        // 文件消息
        contentWidget = new FileLabel(message.fileId, message.fileName);
    }
    else if(message.messageType == model::SPEECH_TYPE)
    {
        // 语言消息
        contentWidget = new SpeechLabel(message.fileId);
    }
    else
    {
        LOG() << "错误的消息类型! messageType=" << message.messageType;
    }

    // 6. 把上述控件添加到布局中
    layout->addWidget(avatarBtn, 0, 0, 2, 1);
    layout->addWidget(nameLabel, 0, 1, 1, 1);
    layout->addWidget(contentWidget, 1, 1, 5, 1);

    return item;
}

图片消息, 文件消息, 语音消息 放到后面再实现

(6)弹出历史消息对话框,在 MessageEditArea::initSignalSlot 中连接信号槽:

connect(showHistoryBtn, &QPushButton::clicked, this, [=]()
{
    HistoryMessageWidget* historyMessageWidget = new HistoryMessageWidget(this);
    historyMessageWidget->exec();
});

6. 用户名登录/注册界面的实现

(1)程序启动,会先打开登录注册窗口:


(2)创建LoginWidget类实现用户注册登录窗口:

class LoginWidget : public QDialog
{
    Q_OBJECT

public:
    LoginWidget(QWidget* parent);

    void switchMode();

private:
    bool isLoginMode = true;

    QLineEdit* usernameEdit;
    QLineEdit* passwordEdit;
    QLineEdit* verifyCodeEdit;
    VerifyCodeWidget* verifyCodeWidget;

    QLabel* titleLabel;
    QPushButton* submitBtn;
    QPushButton* phoneModeBtn;
    QPushButton* switchModeBtn;

};

(3)LoginWidget类的具体实现:

LoginWidget::LoginWidget(QWidget* parent)
    :QDialog(parent)
{
    // 1. 设置本窗口的基本属性
    this->setFixedSize(400, 350);
    this->setWindowTitle("登录");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setStyleSheet("QWidget { background-color: rgb(255, 255, 255); }");
    this->setAttribute(Qt::WA_DeleteOnClose);

    // 2. 创建布局管理器
    QGridLayout* layout = new QGridLayout();
    layout->setSpacing(0);
    layout->setContentsMargins(50, 0, 50, 0);
    this->setLayout(layout);

    // 3. 创建标题
    titleLabel = new QLabel();
    titleLabel->setText("登录");
    titleLabel->setAlignment(Qt::AlignCenter);
    titleLabel->setFixedHeight(50);
    titleLabel->setStyleSheet("QLabel { font-size: 40px; font-weight: 600; }");

    // 4. 创建用户名输入框
    QString editStyle = "QLineEdit { border: none; border-radius: 10px; font-size: 20px; background-color: rgb(240, 240, 240); padding-left:5px; }";
    usernameEdit = new QLineEdit();
    usernameEdit->setFixedHeight(40);
    usernameEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    usernameEdit->setPlaceholderText("输入用户名");
    usernameEdit->setStyleSheet(editStyle);

    // 5. 创建密码输入框
    passwordEdit = new QLineEdit();
    passwordEdit->setFixedHeight(40);
    passwordEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    passwordEdit->setPlaceholderText("输入密码");
    passwordEdit->setStyleSheet(editStyle);
    passwordEdit->setEchoMode(QLineEdit::Password);

    // 6. 创建验证码输入框
    verifyCodeEdit = new QLineEdit();
    verifyCodeEdit->setFixedHeight(40);
    verifyCodeEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    verifyCodeEdit->setPlaceholderText("输入验证码");
    verifyCodeEdit->setStyleSheet(editStyle);

    // 7. 创建显示验证码图片的控件 (此处先用 QPushButton 来表示一下, 后续进一步编写这里的逻辑)
    //   后续会自定义 QWidget, 通过画图 api 来实现这里的验证码功能.
    // QPushButton* verifyCodeWidget = new QPushButton();
    // verifyCodeWidget->setText("验证码");
    // verifyCodeWidget->setStyleSheet("QWidget { border: none; }");
    verifyCodeWidget = new VerifyCodeWidget();

    // 8. 创建登录按钮
    submitBtn = new QPushButton();
    submitBtn->setText("登录");
    submitBtn->setFixedHeight(40);
    submitBtn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    QString btnGreenStyle = "QPushButton { border: none; border-radius: 10px; background-color: rgb(44, 182, 61); color: rgb(255, 255, 255); }";
    btnGreenStyle += "QPushButton:pressed { background-color: rgb(240, 240, 240); }";
    submitBtn->setStyleSheet(btnGreenStyle);

    // 9. 创建切换到手机号登录按钮
    phoneModeBtn = new QPushButton();
    phoneModeBtn->setFixedSize(100, 40);
    phoneModeBtn->setText("手机号登录");
    QString btnWhiteStyle = "QPushButton { border: none; border-radius: 10px; background-color: transparent; }";
    btnWhiteStyle += "QPushButton:pressed { background-color: rgb(240, 240, 240); }";
    phoneModeBtn->setStyleSheet(btnWhiteStyle);

    // 10. 创建切换模式(登录和注册)按钮
    switchModeBtn = new QPushButton();
    switchModeBtn->setFixedSize(100, 40);
    switchModeBtn->setText("注册");
    switchModeBtn->setStyleSheet(btnWhiteStyle);

    // 11. 添加到布局管理器中
    layout->addWidget(titleLabel, 0, 0, 1, 5);
    layout->addWidget(usernameEdit, 1, 0, 1, 5);
    layout->addWidget(passwordEdit, 2, 0, 1, 5);
    layout->addWidget(verifyCodeEdit, 3, 0, 1, 4);
    layout->addWidget(verifyCodeWidget, 3, 4, 1, 1);
    layout->addWidget(submitBtn, 4, 0, 1, 5);
    layout->addWidget(phoneModeBtn, 5, 0, 1, 1);
    layout->addWidget(switchModeBtn, 5, 4, 1, 1);

    // 12. 处理信号槽
    connect(switchModeBtn, &QPushButton::clicked, this, &LoginWidget::switchMode);

    connect(phoneModeBtn, &QPushButton::clicked, this, [=]()
    {
        // 此处还可以把 isLoginMode 这个值传到新的窗口中, 让新的窗口决定自己是登录状态还是注册状态. 大家自行尝试实现.
        PhoneLoginWidget* phoneLoginWidget = new PhoneLoginWidget(nullptr);
        phoneLoginWidget->show();

        // 关闭当前窗口
        this->close();
    });

    connect(submitBtn, &QPushButton::clicked, this, &LoginWidget::clickSubmitBtn);
}

// 实现界⾯切换
void LoginWidget::switchMode()
{
    if (isLoginMode)
    {
        // 当前是登录模式, 切换到注册模式
        this->setWindowTitle("注册");
        titleLabel->setText("注册");
        submitBtn->setText("注册");
        phoneModeBtn->setText("手机号注册");
        switchModeBtn->setText("登录");
    }
    else
    {
        // 当前是注册模式, 切换到登录模式
        this->setWindowTitle("登录");
        titleLabel->setText("登录");
        submitBtn->setText("登录");
        phoneModeBtn->setText("手机号登录");
        switchModeBtn->setText("注册");
    }

    isLoginMode = !isLoginMode;
}

7. 实现手机号登录/注册界面

(1)注册界面展示:

(2)创建PhoneLoginWidget类实现手机号注册登录窗口:

class PhoneLoginWidget : public QDialog
{
    Q_OBJECT
public:
    PhoneLoginWidget(QWidget* parent);

    void switchMode();

private:
    QLineEdit* phoneEdit;
    QPushButton* sendVerifyCodeBtn;
    QLineEdit* verifyCodeEdit;
    QLabel* titleLabel;
    QPushButton* submitBtn;
    QPushButton* switchModeBtn;

    bool isLoginMode = true;
    QString currentPhone = "";    // 记录是使用哪个手机号发送的验证码
    QTimer* timer;
    int leftTime = 30;

};

(3)PhoneLoginWidget类的具体实现:

PhoneLoginWidget::PhoneLoginWidget(QWidget* parent)
    :QDialog(parent)
{
    // 1. 设置窗口的基本属性
    this->setFixedSize(400, 350);
    this->setWindowTitle("登录");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setStyleSheet("QWidget { background-color: rgb(255, 255, 255); }");
    this->setAttribute(Qt::WA_DeleteOnClose);

    // 2. 创建核心布局管理器
    QGridLayout* layout = new QGridLayout();
    layout->setSpacing(10);
    layout->setContentsMargins(50, 0, 50, 0);
    this->setLayout(layout);

    // 3. 创建标题
    titleLabel = new QLabel();
    titleLabel->setText("登录");
    titleLabel->setFixedHeight(50);
    titleLabel->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    titleLabel->setStyleSheet("QLabel { font-size: 40px; font-weight: 600; }");
    titleLabel->setAlignment(Qt::AlignCenter);

    // 4. 创建手机号输入框
    QString editStyle = "QLineEdit { border: none; background-color: rgb(240, 240, 240); font-size: 20px; border-radius: 10px; padding-left: 5px;}";
    phoneEdit = new QLineEdit();
    phoneEdit->setPlaceholderText("输入手机号");
    phoneEdit->setFixedHeight(40);
    phoneEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    phoneEdit->setStyleSheet(editStyle);

    // 5. 创建验证码输入框
    verifyCodeEdit = new QLineEdit();
    verifyCodeEdit->setPlaceholderText("输入短信验证码");
    verifyCodeEdit->setFixedHeight(40);
    verifyCodeEdit->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    verifyCodeEdit->setStyleSheet(editStyle);

    // 6. 创建发送验证码按钮
    QString btnWhiteStyle = "QPushButton { border: none; border-radius: 10px; background-color: transparent; }";
    btnWhiteStyle += "QPushButton:pressed { background-color: rgb(240, 240, 240); }";
    sendVerifyCodeBtn = new QPushButton();
    sendVerifyCodeBtn->setFixedSize(100, 40);
    sendVerifyCodeBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    sendVerifyCodeBtn->setText("发送验证码");
    sendVerifyCodeBtn->setStyleSheet(btnWhiteStyle);

    // 7. 创建提交按钮
    submitBtn = new QPushButton();
    submitBtn->setFixedHeight(40);
    submitBtn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    submitBtn->setText("登录");
    QString btnGreenStyle = "QPushButton { border: none; border-radius: 10px; background-color: rgb(44, 182, 61); color: rgb(255, 255, 255); }";
    btnGreenStyle += "QPushButton:pressed { background-color: rgb(240, 240, 240); }";
    submitBtn->setStyleSheet(btnGreenStyle);

    // 8. 创建 "切换到用户名" 模式按钮
    QPushButton* userModeBtn = new QPushButton();
    userModeBtn->setFixedSize(100, 40);
    userModeBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    userModeBtn->setText("切换到用户名");
    userModeBtn->setStyleSheet(btnWhiteStyle);

    // 9. 切换登录注册模式
    switchModeBtn = new QPushButton();
    switchModeBtn->setFixedSize(100, 40);
    switchModeBtn->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
    switchModeBtn->setText("注册");
    switchModeBtn->setStyleSheet(btnWhiteStyle);

    // 10. 添加到布局管理器
    layout->addWidget(titleLabel, 0, 0, 1, 5);
    layout->addWidget(phoneEdit, 1, 0, 1, 5);
    layout->addWidget(verifyCodeEdit, 2, 0, 1, 4);
    layout->addWidget(sendVerifyCodeBtn, 2, 4, 1, 1);
    layout->addWidget(submitBtn, 3, 0, 1, 5);
    layout->addWidget(userModeBtn, 4, 0, 1, 1);
    layout->addWidget(switchModeBtn, 4, 4, 1, 1);
}

// 切换注册/登录模式
void PhoneLoginWidget::switchMode()
{
    if(isLoginMode)
    {
        // 切换到注册模式
        this->setWindowTitle("注册");
        titleLabel->setText("注册");
        submitBtn->setText("注册");
        switchModeBtn->setText("登录");
    }
    else
    {
        // 切换到登录模式
        this->setWindowTitle("登录");
        titleLabel->setText("登录");
        submitBtn->setText("登录");
        switchModeBtn->setText("注册");
    }

    isLoginMode = !isLoginMode;
}

8. 实现全局通知类

(1)创建Toast 类:

class Toast : public QDialog
{
    Q_OBJECT

public:
    // 此处不需要指定父窗口. 全局通知的父窗口就是 桌面.
    Toast(const QString& text);

    // 并不需要手动来 new 这个对象, 而是通过 showMessage 来弹出窗口
    static void showMessage(const QString& text);

};

(2)Toast 类具体实现:

Toast::Toast(const QString& text)
{
    // 1. 设置窗口的基本参数
    this->setFixedSize(800, 150);
    this->setWindowTitle("消息通知");
    this->setWindowIcon(QIcon(":/resource/image/logo.png"));
    this->setAttribute(Qt::WA_DeleteOnClose);
    this->setStyleSheet("QDialog { background-color: rgb(255, 255, 255); }");
    // 去掉窗口的标题栏
    this->setWindowFlags(Qt::FramelessWindowHint);

    // 2. 先考虑一下窗口的位置.
    // 获取到整个屏幕的尺寸, 通过 primaryScreen 来获取.
    QScreen* screen = QApplication::primaryScreen();
    int width = screen->size().width();
    int height = screen->size().height();
    int x = (width - this->width()) / 2;
    int y = height - this->height() - 100;	// 此处的 100 是窗口底边距离屏幕底边的间隔
    this->move(x, y);

    // 3. 添加一个布局管理器
    QVBoxLayout* layout = new QVBoxLayout();
    layout->setSpacing(0);
    layout->setContentsMargins(0, 0, 0, 0);
    this->setLayout(layout);

    // 4. 创建显示文本的 Label
    QLabel* label = new QLabel();
    label->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
    label->setAlignment(Qt::AlignCenter);
    label->setStyleSheet("QLabel { font-size: 32px; }");
    label->setText(text);
    layout->addWidget(label);

    // 5. 实现 2s 之后自动关闭.
    QTimer* timer = new QTimer(this);
    connect(timer, &QTimer::timeout, this, [=]()
    {
        timer->stop();
        // 核心代码, 关闭当前窗口
        this->close();
    });

    timer->start(2000);
}

void Toast::showMessage(const QString &text)
{
    Toast* toast = new Toast(text);
    toast->show();
}

9. 构建界面注意事项

(1)直接通过 QSS 给 QWidget 设置背景色,有时候会失效。尤其是 QWidget 的子类的时候.具体原因还不清楚。官方文档说:

  • 原因没有解释:
void CustomWidget::paintEvent(QPaintEvent *)
{
 	QStyleOption opt;
 	opt.init(this);
 	QPainter p(this);
 	style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
}

(2)QScrollArea 不能通过 QSS 直接设置背景色。要给 QScrollArea 中持有的 QWidget 设置。

(3)QCheckBox 不能通过 QSS 的 border-radius 设置圆形。形如下列代码, 不能生效:

QString style ="QCheckBox { border-radius: 12.5px; background-color: white; } QCheckBox::indicator { width:25px; height: 25px; border-radius: 12.5px;} ";
style += "QCheckBox::indicator:checked{ color: white; background-color: rgb(7, 193, 96); }";
checkBox->setStyleSheet(style);

需要使用替换背景图的方式来完成.

(4)滚动区域代码示例:

  • 使用 QScrollBar::vertical 设置垂直滚动条样式。
  • 使用 QScrollBar::horizontal 设置水平滚动条样式。
  • 使用 QScrollBar::handle:vertical 设置垂直滚动条滑块样式。
  • setWidgetResizable(true) 务必要添加。
QScrollArea* scrollArea = new QScrollArea();
scrollArea->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
// ⼀定要添加这个设置, 否则⽆法正确显⽰.
scrollArea->setWidgetResizable(true);
// 隐藏⽔平滚动条. 把垂直滚动条设置的细⼀些.
scrollArea->verticalScrollBar()->setStyleSheet("QScrollBar:vertical { width: 
2px; background-color: rgb(255, 255, 255); } QScrollBar::handle:vertical 
{background-color: rgb(205, 205, 205);}");
scrollArea->horizontalScrollBar()->setStyleSheet("QScrollBar:horizontal { 
height: 0px;}");
scrollArea->setStyleSheet("QScrollArea { border: none; }");
selectedContainer = new QWidget();
selectedContainer->setObjectName("selectedContainer");

selectedContainer->setStyleSheet("#selectedContainer {background-color: 
rgb(255, 255, 255);}");
scrollArea->setWidget(selectedContainer);

(5)针对登录窗口进行 delete 后程序崩溃:

  • 使用 deleteLater 也不行。
  • 使用 this->setAttribute(Qt::WA_DeleteOnClose)也不行。
  • 原因是这个变量 LoginWidget 是在 main 中定义在栈上的不能 delete !
void LoginWidget::switchToPhone()
{
 	PhoneLoginWidget* widget = new PhoneLoginWidget();
 	widget->show();
 	// 关闭当前窗⼝
 	this->close();
 	// 注意!!! 此处不能 delete, 否则程序会崩溃.
 	// 因为该 LoginWidget 是在 main 中定义在栈上的变量, 是不能 delete 的!
 	delete this;
 	// this->deleteLater();
}

10. 将项目所需要的图片导入Qt项目中

(1)创建qrc目录:

(2)文件名resource:

(3)将图添加到resource的根目录当中:

(4)后续代码就是前后端交互接口的设计和实现。见博客:https://blog.csdn.net/m0_65558082/article/details/143770334?spm=1001.2014.3001.5502。

客户端整体代码链接:https://gitee.com/liu-yechi/new_code/tree/master/chat_system/client。


http://www.kler.cn/a/396536.html

相关文章:

  • 基于Spring Boot的电子商务系统设计
  • 密码学在网络安全中的应用
  • A030-基于Spring boot的公司资产网站设计与实现
  • Wxml2Canvas小程序将dom转为图片,bug总结
  • opencv kdtree pcl kdtree 效率对比
  • C++的一些模版
  • lab2:docker基础实战
  • 软件设计师-计算机体系结构分类
  • 前端开发---css实现移动和放大效果
  • 设计模式-Facade(门面模式)GO语言版本
  • React的基础API介绍(二)
  • PHP:通往动态Web开发世界的桥梁
  • Flutter:Dio下载文件到本地
  • SpringBoot有几种获取Request对象的方法
  • 深度学习基础—Beam search集束搜索
  • 【原创】java+ssm+mysql物流信息网系统设计与实现
  • 木舟0基础学习Java的第三十三天(OA企业管理系统)
  • SpringBootCloud 服务注册中心Nacos对服务进行管理
  • 比特币前景再度不明,剧烈波动性恐即将回归
  • C/C++语言基础--initializer_list表达式、tuple元组、pair对组简介
  • vue2将webpack改为vite
  • 《Kotlin实战》-附录
  • 大数据实验9:Spark安装和编程实践
  • Jackson与GSON的深度对比
  • mybatis-plus: mapper-locations: “classpath*:/mapper/**/*.xml“配置!!!解释
  • 初学人工智不理解的名词3