昨天凌晨完成了毕业设计的客户端,其实就是一个小工具,配合服务端使用,这里总结一下遇到的问题和解决方法。

既然已经发布了 2.0-stable 版本,那么我就使用他来写这篇文章了。

打包

首先就是打包的过程,之前我没有打包过完整的安装包,只是用官方工具发布 Release 版本。我这次将 innoSetup 将 Release 发布的程序打包成了安装包。

innoSetup

Inno Setup 是一款免费的 Windows 安装程序制作工具,由 Jordan Russell 开发。它是创建 Windows 安装包最流行的工具之一,以其小巧、高效和功能强大而著称。

特点:

  1. 免费开源:遵循 MIT 许可证,完全免费使用
  2. 轻量高效:生成的安装程序体积小,运行速度快
  3. 功能全面
    • 支持创建快捷方式
    • 支持注册表操作
    • 支持多语言安装界面
    • 支持静默安装
    • 支持自定义安装页面
  4. 脚本驱动:使用类似 Pascal 的脚本语言定义安装过程
  5. 支持现代 Windows:兼容 Windows 11/10/8/7 等版本

打包方法

我问了一下 Deepseek,但是他给了我一坨教程,看着晕,我这里就放上文配图最简单的办法。

然后下面看哪里需要修改,修改好后下一步直到这里。这里要注意,打包的时候如果 exe 在 release 文件夹里也会被作为文件压缩,可以把他拖出来然后再添加文件夹。

一路下一步到这里,过程中看哪里需要修改就修改,其他保持默认就好。第一个是输出文件夹,第二个是输出的名字,第三个是图标。

然后一路下一步就可以了,这样就打包完成了。

关于HTTP

HTTP 协议有很多请求方式,我这里主要用到的是 POST 请求。我这里是使用了单例模式,写了个 HTTP 管理类,因为 QNetworkAccessManager 本身支持异步请求,所以就没有进行二次封装。下面是一个 POST接口的调用代码。

void TCHttpService::apiLogin()
{
    QByteArray pwdMd5 = QCryptographicHash::hash(m_firstPwd.toUtf8(), QCryptographicHash::Md5);
    QString md5Hex = pwdMd5.toHex();

    QString urlStr;
   if (m_enableSsl)
        urlStr = "https://" + m_domain + "/api/login";
   else
		urlStr = "http://" + m_domain + "/api/login";

    QUrl url(urlStr);
    QJsonObject jsonObj;
    jsonObj["user"]=m_userName;
    jsonObj["pwd"]=md5Hex;
    QJsonDocument jsonDoc(jsonObj);
    QByteArray jsonData = jsonDoc.toJson();
    QMap<QString, QString> headers;
    headers.insert("Content-Type", "application/json");

    QNetworkReply* reply = nullptr;

    QNetworkRequest request(url);
    for (auto ite = headers.constBegin(); ite != headers.constEnd(); ite++){
       request.setRawHeader(ite.key().toUtf8(), ite.value().toUtf8());
    }
    reply = m_manager.post(request, jsonData);

    QObject::connect(reply, &QNetworkReply::finished, [this, reply]() {

        QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll());
        if (jsonDoc.isEmpty())
            return;
        QJsonObject jsonObj = jsonDoc.object();
        int code = jsonObj["code"].toInt();
        if (code == 0) {
            this->m_token = jsonObj["token"].toString();
            qDebug() << this->m_token;
            m_isOnline = true;
            ImageManager::instance()->addDomainUser(m_domain, m_userName);
            QString stdPath = QStandardPaths::writableLocation(QStandardPaths::AppConfigLocation);
            ImageManager::instance()->initialize(stdPath + "/picpanel.db");


            emit signal_loginSuc();
        }

    });

}

上传数据是使用另外一种数据包组织形式,可以获取上传进度。

void TCHttpService::apiUpload(const QString &filePath)
{

    QString urlStr;
    if (m_enableSsl)
        urlStr = "https://" + m_domain + "/api/upload";
    else
        urlStr = "http://" + m_domain + "/api/upload";



    QFile *file = new QFile(filePath);
    if (!file->open(QIODevice::ReadOnly)) {
        qDebug() << "无法打开文件:" << filePath;
        delete file;
        return;
    }

    // 计算文件 MD5
    QCryptographicHash hash(QCryptographicHash::Md5);
    hash.addData(file);
    QString md5 = hash.result().toHex();

    // 重置文件指针
    file->seek(0);

    // 获取文件大小
    qint64 fileSize = file->size();

    // 创建 multipart 请求
#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0))
    QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
#else
    QHttpMultiPart *multiPart = new QHttpMultiPart(QHttpMultiPart::FormData);
#endif
    // 添加文件部分
    QHttpPart filePart;
    filePart.setHeader(QNetworkRequest::ContentTypeHeader, QVariant("application/octet-stream"));
    filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
                       QVariant("form-data; name=\"file\"; filename=\"" + QFileInfo(filePath).fileName() + "\""));
    filePart.setBodyDevice(file);
    multiPart->append(filePart);

    // 添加用户信息部分
    QHttpPart userPart;
    userPart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"user\""));
    userPart.setBody(m_userName.toUtf8());
    multiPart->append(userPart);

    // 添加 MD5 部分
    QHttpPart md5Part;
    md5Part.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"md5\""));
    md5Part.setBody(md5.toUtf8());
    multiPart->append(md5Part);

    // 添加文件大小部分
    QHttpPart sizePart;
    sizePart.setHeader(QNetworkRequest::ContentDispositionHeader, QVariant("form-data; name=\"size\""));
    sizePart.setBody(QString::number(fileSize).toUtf8());
    multiPart->append(sizePart);

    // 创建网络请求
    QNetworkRequest request(urlStr); // 替换为实际的服务器地址
    request.setHeader(QNetworkRequest::ContentTypeHeader, "multipart/form-data; boundary=" + multiPart->boundary());

    // 发送请求
    // QNetworkAccessManager *manager = new QNetworkAccessManager(this);
    QNetworkReply *reply = m_manager.post(request, multiPart);

    // 连接信号槽
    connect(reply, &QNetworkReply::finished, this, [=]() {
        if (reply->error() == QNetworkReply::NoError) {
            qDebug() << "上传成功:";
            QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll());
            QJsonObject jsonObj = jsonDoc.object();
            QString fileUrl = jsonObj["url"].toString();
            ImageManager::instance()->addImageUrl(m_domain, m_userName, fileUrl);
            emit signal_uploadFileSec(fileUrl);
        } else {
            qDebug() << "上传失败:" << reply->errorString();
        }

        reply->deleteLater();
        multiPart->deleteLater();
    });

    connect(reply, &QNetworkReply::uploadProgress, this, [this](qint64 bytesSent, qint64 bytesTotal) {
        if (bytesTotal > 0) {
            int progress = (bytesSent * 100) / bytesTotal;
            emit signal_progressUpdate(progress);
            qDebug() << "上传进度:" << progress << "%";
        }
    });
}

关于 QSS

这次在开发过程中除了常规的 QSS 意外,用了一些其他的,比如 复选框的下拉框checkbox

QCheckBox {
    spacing: 0px;
    padding: 0px;
    margin: 0px;
}

QCheckBox::indicator {
    width: 18px;
    height: 18px;
}

QCheckBox::indicator:unchecked {
    image: url(:/qrc/image/checkbox_unchecked.png)
}

QCheckBox::indicator:checked {
    image: url(:/qrc/image/checkbox_checked.png)
}

QCheckBox::indicator:indeterminate {
    image: url(:/qrc/image/checkbox_parcial.png)
}

QComboBox QAbstractItemView {

    border-style: none;
    border-radius: 10px;
    outline: 0;
}

QComboBox QAbstractItemView::item {
    padding: 0px;
    margin: 0px;
    border-style: none;
}

QComboBox QAbstractItemView::item:selected {
    padding: 0px;
    margin: 0px;
    border-style: none;
    color: black;
    background-color: #F5F7FA;
}

QComboBox QAbstractItemView::item:hover {
    padding: 0px;
    margin: 0px;
    background-color: #F5F7FA;
    color: black;
    border-style: none;
}

使用资源图片可以达到自定义复选框的效果。

未选中状态:

选中状态:

数据库

我的桌面软件使用 本地优先 原则,本地数据存储在 SQLite 数据库里。使用轻量级数据库比单纯的文件存储:如 JSON,TXT 等在大量文件存储场景下效果高且方便管理。

#include "urldatabase.h"
#include <QSqlQuery>
#include <QSqlError>
#include <QDebug>

ImageManager* ImageManager::m_instance = nullptr;
QMutex ImageManager::m_mutex;

ImageManager::ImageManager(QObject *parent) : QObject(parent)
{

}

ImageManager::~ImageManager()
{
    if (m_db.isOpen()) {
        m_db.close();
    }
}

ImageManager* ImageManager::instance()
{
    QMutexLocker locker(&m_mutex);
    if (!m_instance) {
        m_instance = new ImageManager();
    }
    return m_instance;
}

bool ImageManager::initialize(const QString &dbPath)
{
    m_db = QSqlDatabase::addDatabase("QSQLITE", "image_manager_connection");
    m_db.setDatabaseName(dbPath);

    if (!m_db.open()) {
        qWarning() << "Failed to open database:" << m_db.lastError().text();
        return false;
    }

    return createTables();
}

bool ImageManager::createTables()
{
    QSqlQuery query(m_db);

    // 创建域名表
    if (!query.exec("CREATE TABLE IF NOT EXISTS domains ("
                    "domain_id INTEGER PRIMARY KEY AUTOINCREMENT, "
                    "domain TEXT NOT NULL UNIQUE)")) {
        qWarning() << "Failed to create domains table:" << query.lastError().text();
        return false;
    }

    // 创建用户表
    if (!query.exec("CREATE TABLE IF NOT EXISTS users ("
                    "user_id INTEGER PRIMARY KEY AUTOINCREMENT, "
                    "username TEXT NOT NULL UNIQUE)")) {
        qWarning() << "Failed to create users table:" << query.lastError().text();
        return false;
    }

    // 创建域名-用户关联表(确保唯一)
    if (!query.exec("CREATE TABLE IF NOT EXISTS domain_users ("
                    "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                    "domain_id INTEGER NOT NULL, "
                    "user_id INTEGER NOT NULL, "
                    "UNIQUE(domain_id, user_id), "
                    "FOREIGN KEY(domain_id) REFERENCES domains(domain_id), "
                    "FOREIGN KEY(user_id) REFERENCES users(user_id))")) {
        qWarning() << "Failed to create domain_users table:" << query.lastError().text();
        return false;
    }

    // 创建图片URL表
    if (!query.exec("CREATE TABLE IF NOT EXISTS image_urls ("
                    "url_id INTEGER PRIMARY KEY AUTOINCREMENT, "
                    "domain_user_id INTEGER NOT NULL, "
                    "image_url TEXT NOT NULL, "
                    "upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, "
                    "FOREIGN KEY(domain_user_id) REFERENCES domain_users(id), "
                    "UNIQUE(domain_user_id, image_url))")) {
        qWarning() << "Failed to create image_urls table:" << query.lastError().text();
        return false;
    }

    return true;
}

bool ImageManager::addDomain(const QString &domain)
{
    QSqlQuery query(m_db);
    query.prepare("INSERT OR IGNORE INTO domains (domain) VALUES (?)");
    query.addBindValue(domain);
    return query.exec();
}

bool ImageManager::addUser(const QString &user)
{
    QSqlQuery query(m_db);
    query.prepare("INSERT OR IGNORE INTO users (username) VALUES (?)");
    query.addBindValue(user);
    return query.exec();
}

int ImageManager::getDomainId(const QString &domain)
{
    QSqlQuery query(m_db);
    query.prepare("SELECT domain_id FROM domains WHERE domain = ?");
    query.addBindValue(domain);
    if (query.exec() && query.next()) {
        return query.value(0).toInt();
    }
    return -1;
}

int ImageManager::getUserId(const QString &user)
{
    QSqlQuery query(m_db);
    query.prepare("SELECT user_id FROM users WHERE username = ?");
    query.addBindValue(user);
    if (query.exec() && query.next()) {
        return query.value(0).toInt();
    }
    return -1;
}

bool ImageManager::addDomainUser(const QString &domain, const QString &user)
{
    if (!addDomain(domain) || !addUser(user)) {
        return false;
    }

    int domainId = getDomainId(domain);
    int userId = getUserId(user);

    if (domainId == -1 || userId == -1) {
        return false;
    }

    QSqlQuery query(m_db);
    query.prepare("INSERT OR IGNORE INTO domain_users (domain_id, user_id) VALUES (?, ?)");
    query.addBindValue(domainId);
    query.addBindValue(userId);
    return query.exec();
}

bool ImageManager::addImageUrl(const QString &domain, const QString &user, const QUrl &imageUrl)
{
    int domainId = getDomainId(domain);
    int userId = getUserId(user);

    if (domainId == -1 || userId == -1) {
        return false;
    }

    QSqlQuery query(m_db);
    query.prepare("SELECT id FROM domain_users WHERE domain_id = ? AND user_id = ?");
    query.addBindValue(domainId);
    query.addBindValue(userId);

    if (!query.exec() || !query.next()) {
        return false;
    }

    int domainUserId = query.value(0).toInt();

    query.prepare("INSERT OR IGNORE INTO image_urls (domain_user_id, image_url) VALUES (?, ?)");
    query.addBindValue(domainUserId);
    query.addBindValue(imageUrl.toString());
    return query.exec();
}

bool ImageManager::removeImageUrl(const QString &domain, const QString &user, const QUrl &imageUrl)
{
    int domainId = getDomainId(domain);
    int userId = getUserId(user);

    if (domainId == -1 || userId == -1) {
        return false;
    }

    QSqlQuery query(m_db);
    query.prepare("SELECT id FROM domain_users WHERE domain_id = ? AND user_id = ?");
    query.addBindValue(domainId);
    query.addBindValue(userId);

    if (!query.exec() || !query.next()) {
        return false;
    }

    int domainUserId = query.value(0).toInt();
    query.prepare("DELETE FROM image_urls WHERE domain_user_id = ? AND image_url = ?");
    query.addBindValue(domainUserId);
    query.addBindValue(imageUrl.toString());


    return query.exec();
}

QList<QUrl> ImageManager::getImageUrls(const QString &domain, const QString &user)
{
    QList<QUrl> urls;

    int domainId = getDomainId(domain);
    int userId = getUserId(user);

    if (domainId == -1 || userId == -1) {
        return urls;
    }

    QSqlQuery query(m_db);
    query.prepare("SELECT i.image_url FROM image_urls i "
                  "JOIN domain_users du ON i.domain_user_id = du.id "
                  "WHERE du.domain_id = ? AND du.user_id = ? "
                  "ORDER BY i.upload_time DESC");
    query.addBindValue(domainId);
    query.addBindValue(userId);

    if (query.exec()) {
        while (query.next()) {
            urls.append(QUrl(query.value(0).toString()));
        }
    }

    return urls;
}

bool ImageManager::containsDomainUser(const QString &domain, const QString &user)
{
    int domainId = getDomainId(domain);
    int userId = getUserId(user);

    if (domainId == -1 || userId == -1) {
        return false;
    }

    QSqlQuery query(m_db);
    query.prepare("SELECT 1 FROM domain_users WHERE domain_id = ? AND user_id = ?");
    query.addBindValue(domainId);
    query.addBindValue(userId);

    return query.exec() && query.next();
}

展望

  • 首先完善打磨一下细节,比如跨平台适配,我在 Mac 上测试过,通知有点问题,另外一些 Qt 无法提供的接口需要调用系统 API。
  • 尝试适配一些其他 OSS 存储桶和 S3等接口,这个可以参考 PicGO 项目。站在巨人的肩膀上,出个一个分支也好。
  • 我希望做一个 Obsidian 插件,因为我日常使用 Obsidian。
  • 希望有更多人可以使用我的图床服务,或者参与进来。