【QT】文件与数据库:从本地小仓库到数据大管家

在这里插入图片描述

个人主页:Guiat
归属专栏:QT

在这里插入图片描述

正文

小明盯着电脑上凌乱的txt、excel和一堆图片发愁:“要是能有个万能收纳盒,还能智能整理就好了…” 这时,Qt 举着小旗子跳出来:“选我!文件读写+数据库,让你的数据既住得好又找得快!”

1. 文件操作:Qt 的万能收纳术

想象你的程序是个勤快的管家。它要记住用户的配置(放抽屉)、保存生成的报告(存档案室)、加载图片资源(从仓库取货)。Qt 提供了一套强大的工具,让管家轻松搞定这些文件杂务。

1.1 基础文件读写:QFile 的百宝箱

核心绝招:QFile
它就是程序与硬盘文件的桥梁,负责开门、搬数据、关门。

#include <QFile>
#include <QDebug>

// 1. 写文件:给"备忘录.txt"写点东西
QFile outFile("备忘录.txt");
if (outFile.open(QIODevice::WriteOnly | QIODevice::Text)) { // 开门准备写文本
    QTextStream stream(&outFile);
    stream << "重要提醒:" << Qt::endl;
    stream << " * 下午3点和小美约会" << Qt::endl;
    stream << " * 记得买咖啡!" << Qt::endl;
    outFile.close(); // 关门
    qDebug() << "备忘录写好咯!";
} else {
    qDebug() << "糟糕,本子找不到了!错误:" << outFile.errorString();
}

// 2. 读文件:看看备忘录写了啥
QFile inFile("备忘录.txt");
if (inFile.open(QIODevice::ReadOnly | QIODevice::Text)) { // 开门准备读文本
    QTextStream inStream(&inFile);
    while (!inStream.atEnd()) {
        QString line = inStream.readLine(); // 一行行读
        qDebug() << "读到内容:" << line;
    }
    inFile.close();
} else {
    qDebug() << "备忘录丢了?错误:" << inFile.errorString();
}

【举例】 这就相当于:

  1. 管家拿出一个叫“备忘录.txt”的新本子(QFile创建文件)。
  2. 打开本子准备写(open),用笔(QTextStream)写下约会提醒。
  3. 写完合上本子(close)。
  4. 下午管家又拿出这个本子,打开(open),一行行念出内容(readLine)。

1.2 信息侦探:QFileInfo 的档案袋

管家光会读写本子还不够,有时得知道本子的详细信息:多大?什么时候改的?藏在哪里?QFileInfo 就是档案管理员。

#include <QFileInfo>

QFileInfo fileInfo("备忘录.txt");

qDebug() << "文件大名:" << fileInfo.fileName(); // 备忘录.txt
qDebug() << "绝对住址:" << fileInfo.absoluteFilePath(); // C:/Users/小明/Documents/备忘录.txt
qDebug() << "体重(大小):" << fileInfo.size() << "字节";
qDebug() << "最后修改时间:" << fileInfo.lastModified().toString(); // 2023-10-27 14:30:00
qDebug() << "是藏起来的文件吗?" << fileInfo.isHidden(); // false

【mermaid图 - QFileInfo侦察行动】

graph TD
    A[程序: 想知道文件信息] --> B[QFileInfo侦探]
    B --> C{锁定目标文件<br>“备忘录.txt”}
    C --> D[行动1: 查文件名]
    C --> E[行动2: 查绝对路径]
    C --> F[行动3: 量大小]
    C --> G[行动4: 看修改时间]
    C --> H[行动5: 查是否隐藏]
    D --> I[报告结果]
    E --> I
    F --> I
    G --> I
    H --> I
    I --> J[程序获得情报]

1.3 目录大总管:QDir 的整理魔法

管家需要管理成堆的本子(文件)和文件夹(目录)。QDir 就是负责整理书架和文件夹的能手。

#include <QDir>
#include <QDebug>

// 1. 创建新文件夹 - 给"财务报告"建个专属柜子
QDir().mkdir("财务报告"); // 创建文件夹
qDebug() << "财务报告柜子建好了吗?" << QDir("财务报告").exists(); // true

// 2. 遍历文件夹 - 看看"项目资料"柜子里都有啥
QDir projectDir("项目资料");
QStringList allEntries = projectDir.entryList(QDir::Files | QDir::NoDotAndDotDot); // 只列出文件,忽略"."和".."
qDebug() << "项目资料柜里的文件:";
for (const QString &file : allEntries) {
    qDebug() << " * " << file;
}

// 3. 文件过滤 - 找出所有的图片(.png, .jpg)
QStringList imageFiles = projectDir.entryList(QStringList() << "*.png" << "*.jpg", QDir::Files);
qDebug() << "找到的图片:" << imageFiles;

// 4. 删除文件 - 扔掉过期的"旧计划.txt"
if (QFile::remove("项目资料/旧计划.txt")) {
    qDebug() << "旧计划成功扔掉!";
} else {
    qDebug() << "哎呀,没找到旧计划...";
}

【举例】 管家的一天:

  1. 新建一个叫“财务报告”的文件夹 (mkdir)。
  2. 打开“项目资料”文件夹 (QDir),拿出里面所有文件的清单 (entryList)。
  3. 在清单里只挑出图片文件 (*.png, *.jpg)。
  4. 找到“旧计划.txt”这个文件,把它删掉 (QFile::remove)。

2. 数据库登场:Qt 的智能数据仓库

当“记事本”不够用了——数据量大、需要快速查找、复杂关系、多人共享?数据库就是终极解决方案!Qt 通过 QtSql 模块让程序轻松连接各种数据库。

2.1 连接数据库:打开仓库大门

Qt 支持多种数据库 (SQLite, MySQL, PostgreSQL 等),通过统一的 QSqlDatabase 接口连接。我们先从轻量级、无需服务器的 SQLite 开始,它就是一个单文件数据库。

#include <QSqlDatabase>
#include <QSqlError>
#include <QDebug>

// 1. 添加 SQLite 数据库驱动
QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE");

// 2. 设置数据库文件路径 (如果文件不存在,SQLite会自动创建)
db.setDatabaseName("mydatabase.db");

// 3. 开门!尝试连接
if (!db.open()) {
    qDebug() << "糟糕!仓库大门打不开!错误:" << db.lastError().text();
    return; // 连接失败,处理错误
} else {
    qDebug() << "成功连接到数据库仓库!";
}

// ... 这里进行数据库操作 (查询、插入等) ...

// 4. 用完记得关门!
db.close();
qDebug() << "数据库仓库门已关闭。";

【mermaid图 - Qt连接数据库流程】

graph LR
    A[你的Qt程序] --> B[QtSql模块]
    B --> C[选择数据库驱动<br>QSQLITE, QMYSQL...]
    C --> D[设置连接参数<br>数据库名、用户名、密码等]
    D --> E[调用 QSqlDatabase::open]
    E --> F{连接成功?}
    F -->|Yes| G[执行SQL操作]
    F -->|No| H[处理错误<br>db.lastError()]
    G --> I[操作完成关闭连接<br>db.close]

2.2 SQL 操作:指挥仓库干活 (QSqlQuery)

连接到数据库后,使用 QSqlQuery 来执行 SQL 命令。它就是你的传令兵,负责和数据库仓库沟通。

#include <QSqlQuery>
#include <QSqlError>

// 0. 确保数据库已连接 (db.open() 成功)

// 1. 创建表 - 建一个"联系人"档案柜
QSqlQuery query;
QString createTableSQL = "CREATE TABLE IF NOT EXISTS contacts ("
                         "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                         "name TEXT NOT NULL, "
                         "phone TEXT, "
                         "email TEXT)";
if (!query.exec(createTableSQL)) {
    qDebug() << "建档案柜失败!错误:" << query.lastError().text();
} else {
    qDebug() << "联系人档案柜准备就绪!";
}

// 2. 插入数据 - 给档案柜增加记录
QString insertSQL = "INSERT INTO contacts (name, phone, email) "
                    "VALUES (?, ?, ?)"; // ? 是占位符,防止SQL注入
query.prepare(insertSQL);
query.addBindValue("张三"); // 绑定第一个 ? 的值
query.addBindValue("13800138000");
query.addBindValue("zhangsan@example.com");
if (!query.exec()) {
    qDebug() << "添加张三失败:" << query.lastError().text();
}

// 添加李四
query.addBindValue("李四");
query.addBindValue("");
query.addBindValue("lisi@company.com");
if (!query.exec()) {
    qDebug() << "添加李四失败:" << query.lastError().text();
}

// 3. 查询数据 - 找出所有联系人
if (query.exec("SELECT id, name, phone, email FROM contacts")) {
    while (query.next()) { // 遍历结果集的每一行
        int id = query.value("id").toInt();
        QString name = query.value("name").toString();
        QString phone = query.value("phone").toString();
        QString email = query.value("email").toString();
        qDebug() << "联系人[" << id << "]:" << name << ", 电话:" << (phone.isEmpty() ? "无" : phone) << ", 邮箱:" << email;
    }
} else {
    qDebug() << "查询失败:" << query.lastError().text();
}

// 4. 更新数据 - 更新张三的电话
QString updateSQL = "UPDATE contacts SET phone = ? WHERE name = ?";
query.prepare(updateSQL);
query.addBindValue("13900139000"); // 新电话
query.addBindValue("张三"); // 条件:名字是张三
if (query.exec()) {
    qDebug() << "张三电话更新成功!";
}

// 5. 删除数据 - 删除李四的记录
QString deleteSQL = "DELETE FROM contacts WHERE name = ?";
query.prepare(deleteSQL);
query.addBindValue("李四");
if (query.exec()) {
    qDebug() << "李四记录已删除!";
}

【关键点解释】

  • prepare() + addBindValue(): 这是最佳实践!使用占位符 (?) 和绑定值,可以有效防止SQL注入攻击,并且提高代码可读性和效率(尤其对于重复执行的语句)。
  • query.next(): 用于遍历查询结果集。每次调用移动到下一行,直到返回 false 表示结束。
  • query.value(): 获取当前行中指定列的值。可以用列名(字符串)或列索引(整数,从0开始)。

2.3 错误处理:仓库里的警报器

数据库操作随时可能出错(连接失败、SQL语法错误、数据约束冲突等)。健壮的程序必须处理错误! Qt 主要通过 QSqlErrorQSqlQuery::lastError() / QSqlDatabase::lastError() 来提供错误信息。

// 假设一个会出错的查询:查询一个不存在的列 'age'
QSqlQuery badQuery;
if (!badQuery.exec("SELECT name, age FROM contacts")) { // 表中没有'age'列!
    QSqlError error = badQuery.lastError();
    qDebug() << "操作出错了!";
    qDebug() << " * 错误类型:" << error.type(); // 例如 QSqlError::StatementError
    qDebug() << " * 数据库驱动错误码:" << error.nativeErrorCode(); // 数据库特定的错误码
    qDebug() << " * 错误描述:" << error.databaseText(); // 数据库返回的错误信息
    qDebug() << " * 驱动错误描述:" << error.driverText(); // 驱动层附加的错误信息
    qDebug() << " * 完整错误:" << error.text();
}

// 处理连接错误 (通常在 db.open() 后检查)
if (!db.isOpen()) {
    QSqlError connError = db.lastError();
    qDebug() << "数据库连接失败:" << connError.text();
    // 根据错误类型提示用户或采取其他措施
}

【举例】 警报器响了:

  • 管家想查“联系人”档案柜里的“年龄”(age)信息。
  • 但档案柜设计时根本没“年龄”这一栏!(contacts 表没有 age 列)。
  • 数据库仓库拉响警报(lastError()),报告错误类型(type)、具体错误码(nativeErrorCode)、详细原因(databaseText/driverText)。
  • 管家收到警报(qDebug输出错误信息),知道哪里出了问题,可以修正命令或通知主人。

3. 模型/视图:Qt 的炫酷展示架 (Model/View)

手动用 QSqlQuery 获取数据再塞进 QListWidgetQTableWidget 虽然可行,但当数据量大或需要灵活展示时就很吃力。Qt 的 Model/View 架构数据管理(Model)数据显示(View)用户交互(Delegate) 分离,高效且灵活。对于数据库,QSqlTableModelQSqlQueryModel 是神器!

3.1 QSqlTableModel:表格的直通车

QSqlTableModel 提供了一个可读写的模型,直接映射到一个数据库表。修改模型中的数据会自动同步到数据库(通常需要调用 submitAll())。

#include <QSqlTableModel>
#include <QTableView>
#include <QHeaderView>
#include <QDebug>

// 1. 创建模型,关联到 'contacts' 表
QSqlTableModel *model = new QSqlTableModel(this, db); // this 指定父对象方便内存管理
model->setTable("contacts"); // 关联数据库表
model->setEditStrategy(QSqlTableModel::OnManualSubmit); // 编辑策略: 手动提交
model->select(); // 加载数据!相当于执行 SELECT * FROM contacts

// 2. 可选: 设置表头
model->setHeaderData(0, Qt::Horizontal, tr("ID"));
model->setHeaderData(1, Qt::Horizontal, tr("姓名"));
model->setHeaderData(2, Qt::Horizontal, tr("电话"));
model->setHeaderData(3, Qt::Horizontal, tr("邮箱"));

// 3. 创建视图 (这里用QTableView) 并设置模型
QTableView *tableView = new QTableView;
tableView->setModel(model); // 关键!把模型设置给视图
tableView->horizontalHeader()->setStretchLastSection(true); // 最后一列拉伸填充
tableView->setSelectionBehavior(QAbstractItemView::SelectRows); // 整行选择
tableView->show();

// 4. 示例: 在代码中插入一行 (用户通常在视图里编辑)
QSqlRecord newRecord = model->record(); // 获取一个空记录结构
newRecord.setValue("name", "王五");
newRecord.setValue("phone", "15000150000");
newRecord.setValue("email", "wangwu@web.com");
if (model->insertRecord(-1, newRecord)) { // -1 表示追加到末尾
    qDebug() << "新记录插入模型成功。";
} else {
    qDebug() << "插入失败:" << model->lastError().text();
}

// 5. 手动提交所有挂起的更改到数据库
if (model->submitAll()) {
    qDebug() << "所有更改已保存到数据库!";
} else {
    qDebug() << "提交失败:" << model->lastError().text();
    model->revertAll(); // 回滚所有未提交的更改
}

// 6. 示例: 删除当前选中的行
QModelIndexList selectedIndexes = tableView->selectionModel()->selectedRows();
if (!selectedIndexes.isEmpty()) {
    int row = selectedIndexes.first().row(); // 获取选中的第一行
    model->removeRow(row); // 标记该行待删除
    // 同样需要 submitAll() 才能真正从数据库删除
}

【关键点解释】

  • setEditStrategy: 控制何时将更改写入数据库。
    • OnFieldChange: 字段修改后立即提交(可能效率低)。
    • OnRowChange: 行焦点改变时提交。
    • OnManualSubmit: 最常用,用户/代码调用 submitAll()revertAll() 时才提交/回滚所有更改。更安全可控。
  • select(): 必须调用!从数据库重新加载数据到模型。
  • 视图 (QTableView): 负责展示模型的数据。设置模型 (setModel) 后自动显示数据。
  • 增删改: 通过模型的方法 (insertRecord, removeRow, setData) 操作数据。这些操作只在模型缓存中,直到调用 submitAll() 才写入数据库。revertAll() 可以撤销缓存中的更改。

3.2 QSqlQueryModel:只读的快速通道

QSqlQueryModel 提供一个只读模型,用于展示任何 SELECT 查询的结果。它比 QSqlTableModel 更轻量,但不能直接编辑。

#include <QSqlQueryModel>
#include <QTableView>

// 1. 创建模型并执行查询
QSqlQueryModel *queryModel = new QSqlQueryModel;
queryModel->setQuery("SELECT name AS 姓名, phone AS 电话 FROM contacts WHERE phone != '' ORDER BY name");

// 2. (可选) 检查查询是否成功
if (queryModel->lastError().isValid()) {
    qDebug() << "查询出错:" << queryModel->lastError().text();
    return;
}

// 3. 创建视图并设置模型
QTableView *queryView = new QTableView;
queryView->setModel(queryModel);
queryView->show();

// 4. 如何获取特定单元格数据 (只读)
QModelIndex index = queryModel->index(0, 0); // 第0行,第0列 (姓名)
QString name = queryModel->data(index).toString();
qDebug() << "第一个有电话联系人的名字是:" << name;

【何时选择】

  • 需要只读展示复杂查询结果(连接多个表、聚合函数等)?用 QSqlQueryModel
  • 需要对单个表进行读写操作?用 QSqlTableModel
  • 需要完全定制数据源和行为?继承 QAbstractTableModel 实现自己的模型(更高级)。

3.3 模型与视图的桥梁:理解 Model/View

【mermaid图 - Model/View架构协作】

Qt Application
提供数据
显示数据
用户操作
编辑/选择
发送编辑信号
更新内部缓存
读写数据
提交/回滚
Model
模型
QSqlTableModel/QSqlQueryModel
View
视图
QTableView/QListView
Database
数据库
User
用户

【核心思想】

  1. 分离: Model 只关心数据是什么、在哪里、怎么存取。View 只关心怎么把数据漂亮地展示给用户,并处理用户的点击、选择、编辑等操作。Delegate (委托) 负责在 View 中渲染特定的数据项(例如,用颜色条显示进度、用下拉框编辑枚举值)。
  2. 通信: 当 Model 的数据改变时(例如,数据库更新了),它会发出信号(如 dataChanged())。View 监听这些信号并自动更新显示。当用户在 View 中编辑数据时,View 会通过 Model 的接口(如 setData())尝试修改 Model 的数据。
  3. 效率: 对于大型数据集,View 只会请求和渲染当前可见区域的数据(得益于模型的 fetchMore() 等机制),避免内存浪费。
  4. 灵活性: 同一个 Model 可以同时被多个不同的 View 使用(例如,一个表格视图和一个树形视图展示同一份数据的不同侧面)。可以轻松更换 View 而不影响 Model 和数据。

4. 实战:打造个人通讯录管家

理论学了一箩筐,动手做个迷你项目巩固下!目标:一个基于 SQLite 数据库、使用 Model/View 的简易通讯录。

4.1 设计数据库表

CREATE TABLE contacts (
    id INTEGER PRIMARY KEY AUTOINCREMENT, -- 唯一标识,自增长
    name TEXT NOT NULL,                 -- 姓名 (必填)
    phone TEXT,                         -- 电话
    email TEXT,                         -- 邮箱
    group TEXT,                         -- 分组 (家人/朋友/同事...)
    notes TEXT                          -- 备注
);

4.2 主界面与功能 (Widgets 实现)

// mainwindow.h
#include <QMainWindow>
#include <QSqlTableModel>

namespace Ui { class MainWindow; }

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

private slots:
    void on_addButton_clicked();      // 添加联系人
    void on_deleteButton_clicked();   // 删除选中联系人
    void on_saveButton_clicked();     // 保存所有更改
    void on_cancelButton_clicked();   // 撤销所有更改
    void on_filterEdit_textChanged(const QString &text); // 过滤联系人

private:
    Ui::MainWindow *ui;
    QSqlTableModel *model; // 通讯录数据模型
    QSqlDatabase db;       // 数据库连接
};
// mainwindow.cpp
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include <QSqlDatabase>
#include <QSqlTableModel>
#include <QSqlError>
#include <QMessageBox>
#include <QDebug>

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

    // 1. 初始化数据库连接
    db = QSqlDatabase::addDatabase("QSQLITE");
    db.setDatabaseName("addressbook.db"); // 数据库文件

    if (!db.open()) {
        QMessageBox::critical(this, "错误", "无法打开数据库:\n" + db.lastError().text());
        return;
    }

    // 2. 创建表 (如果不存在)
    QSqlQuery query;
    if (!query.exec("CREATE TABLE IF NOT EXISTS contacts ("
                   "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                   "name TEXT NOT NULL, "
                   "phone TEXT, "
                   "email TEXT, "
                   "\"group\" TEXT, " // group 是 SQL 关键字,需要引号
                   "notes TEXT)")) {
        QMessageBox::critical(this, "错误", "创建表失败:\n" + query.lastError().text());
    }

    // 3. 创建模型并关联表
    model = new QSqlTableModel(this, db);
    model->setTable("contacts");
    model->setEditStrategy(QSqlTableModel::OnManualSubmit); // 手动提交
    // 设置友好表头
    model->setHeaderData(model->fieldIndex("name"), Qt::Horizontal, tr("姓名"));
    model->setHeaderData(model->fieldIndex("phone"), Qt::Horizontal, tr("电话"));
    model->setHeaderData(model->fieldIndex("email"), Qt::Horizontal, tr("邮箱"));
    model->setHeaderData(model->fieldIndex("group"), Qt::Horizontal, tr("分组"));
    model->setHeaderData(model->fieldIndex("notes"), Qt::Horizontal, tr("备注"));

    // 4. 将模型设置给TableView
    ui->tableView->setModel(model);
    ui->tableView->setSelectionBehavior(QAbstractItemView::SelectRows); // 整行选择
    ui->tableView->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); // 自适应宽度
    model->select(); // 加载数据!

    // 5. 连接信号槽 (UI按钮点击事件)
    connect(ui->addButton, &QPushButton::clicked, this, &MainWindow::on_addButton_clicked);
    connect(ui->deleteButton, &QPushButton::clicked, this, &MainWindow::on_deleteButton_clicked);
    connect(ui->saveButton, &QPushButton::clicked, this, &MainWindow::on_saveButton_clicked);
    connect(ui->cancelButton, &QPushButton::clicked, this, &MainWindow::on_cancelButton_clicked);
    connect(ui->filterEdit, &QLineEdit::textChanged, this, &MainWindow::on_filterEdit_textChanged);
}

MainWindow::~MainWindow()
{
    db.close(); // 关闭数据库
    delete ui;
}

void MainWindow::on_addButton_clicked()
{
    // 在模型末尾插入一行空记录
    int newRow = model->rowCount();
    model->insertRow(newRow);

    // 可选: 设置新行某些字段的默认值
    // model->setData(model->index(newRow, model->fieldIndex("group")), "朋友");

    // 让视图滚动到新行并进入编辑状态 (可能需要)
    QModelIndex newIndex = model->index(newRow, model->fieldIndex("name"));
    ui->tableView->setCurrentIndex(newIndex);
    ui->tableView->edit(newIndex);
}

void MainWindow::on_deleteButton_clicked()
{
    // 获取当前选中的行
    QModelIndexList selected = ui->tableView->selectionModel()->selectedRows();
    if (selected.isEmpty()) {
        QMessageBox::information(this, "提示", "请先选择一个联系人!");
        return;
    }

    // 确认删除
    if (QMessageBox::question(this, "确认删除", "确定要删除选中的联系人吗?") == QMessageBox::Yes) {
        // 注意:删除选中的多行时,需要从后往前删,避免索引变化
        for (int i = selected.count() - 1; i >= 0; --i) {
            int rowToDelete = selected[i].row();
            model->removeRow(rowToDelete);
        }
        // 注意:此时只是标记删除,需要 save 才会真正从数据库删除
    }
}

void MainWindow::on_saveButton_clicked()
{
    // 尝试提交所有挂起的更改 (插入、删除、修改)
    model->database().transaction(); // 开始事务,保证原子性
    if (model->submitAll()) {
        model->database().commit(); // 提交事务
        QMessageBox::information(this, "成功", "数据已保存!");
    } else {
        model->database().rollback(); // 回滚事务
        QMessageBox::critical(this, "错误", "保存失败:\n" + model->lastError().text());
        model->revertAll(); // 模型也回滚
    }
}

void MainWindow::on_cancelButton_clicked()
{
    // 撤销所有未提交的更改
    model->revertAll();
    QMessageBox::information(this, "提示", "所有未保存的更改已撤销。");
}

void MainWindow::on_filterEdit_textChanged(const QString &text)
{
    // 动态设置过滤条件 (按姓名模糊匹配)
    QString filter = QString("name LIKE '%%1%'").arg(text); // %text%
    model->setFilter(filter);
    model->select(); // 重新查询
}

【功能亮点】

  1. CRUD 完整: 添加 (Add)、查看 (TableView)、编辑 (直接在表格编辑)、删除 (Delete)、保存 (Save)、撤销 (Cancel)。
  2. 实时过滤: 在搜索框输入时,列表实时显示姓名包含输入字符的联系人。
  3. 事务支持: 保存时使用数据库事务 (transaction()/commit()/rollback()),确保数据操作的原子性(要么全部成功,要么全部失败回滚)。
  4. 用户友好: 确认删除、操作成功/失败提示。

4.3 运行效果与思考

运行程序,你将看到一个带有表格视图的窗口。你可以:

  • 直接在表格里输入添加新联系人。
  • 选中一行或多行点击“删除”。
  • 修改现有联系人的信息。
  • 点击“保存”将更改写入数据库。
  • 点击“撤销”放弃未保存的更改。
  • 在搜索框输入姓名进行实时过滤。

思考:

  • 扩展分组管理: 如何实现分组的下拉选择?(可以用 QComboBox 作为分组列的委托 QItemDelegateQStyledItemDelegate)。
  • 添加详情视图: 点击联系人时,在旁边的表单中显示/编辑更详细的信息(使用 QDataWidgetMapper 将模型的当前记录映射到表单控件)。
  • 头像支持: 如何在数据库中存储和显示联系人的头像?(提示:BLOB 类型存储二进制图片数据,或用 QFile 存图片文件,数据库存路径)。
  • 导出导入: 如何将通讯录导出为 CSV 文件?如何从 CSV 文件导入?

5. 性能优化与进阶

5.1 批量操作:告别蜗牛速度

想象管家一次只搬一本书 vs 用推车一次搬一箱书。数据库操作也一样,频繁的单条 INSERT 比批量操作慢得多。

// 慢方法 (不推荐!)
QSqlQuery query;
for (int i = 0; i < 1000; ++i) {
    query.prepare("INSERT INTO log (message, timestamp) VALUES (?, ?)");
    query.addBindValue(QString("Log entry %1").arg(i));
    query.addBindValue(QDateTime::currentDateTime());
    query.exec(); // 执行1000次!
}

// 快方法 (推荐!)
QSqlDatabase::database().transaction(); // 开启事务
query.prepare("INSERT INTO log (message, timestamp) VALUES (?, ?)");
for (int i = 0; i < 1000; ++i) {
    query.addBindValue(QString("Log entry %1").arg(i));
    query.addBindValue(QDateTime::currentDateTime());
    query.exec(); // 注意:SQLite 需要保持语句准备状态
    // 对于支持批量绑定的驱动 (如MySQL/PostgreSQL),有更高效的方式
}
QSqlDatabase::database().commit(); // 提交事务

// 使用 QSqlTableModel 批量插入
model->database().transaction();
for (int i = 0; i < 1000; ++i) {
    int row = model->rowCount();
    model->insertRow(row);
    model->setData(model->index(row, messageCol), QString("Log entry %1").arg(i));
    model->setData(model->index(row, timeCol), QDateTime::currentDateTime());
}
if (model->submitAll()) { // 一次性提交所有插入
    model->database().commit();
} else {
    model->database().rollback();
    model->revertAll();
}

优化点:

  • 事务 (transaction()/commit()): 将多个操作包裹在事务中。数据库引擎无需为每条语句单独写日志和同步磁盘,极大提升速度。这是提升批量操作性能最有效的手段之一!
  • 预编译语句 (prepare()): 对于循环执行的相同语句,在循环外 prepare() 一次,在循环内只 addBindValue()exec()
  • 驱动特定的批量操作: 部分数据库驱动 (如 MySQL 的 QPSQLQMYSQL) 支持更高效的批量插入语法 (如 INSERT INTO ... VALUES (...), (...), ...),可以探索使用。

5.2 异步查询:让界面不再卡顿

执行一个耗时很长的数据库查询(例如,分析海量日志)时,如果直接在主线程(GUI线程)执行,界面会“冻结”,用户体验极差。解决方案:异步查询

核心: 使用 QThread + 信号槽 或 Qt Concurrent。

// DatabaseWorker.h (在后台线程工作的对象)
#include <QObject>
#include <QSqlQuery>
#include <QSqlRecord>
#include <QVector>

class DatabaseWorker : public QObject
{
    Q_OBJECT
public:
    explicit DatabaseWorker(QObject *parent = nullptr) : QObject(parent) {}

public slots:
    void fetchBigData() {
        QVector<QSqlRecord> results;
        QSqlQuery query("SELECT * FROM huge_table WHERE condition = ..."); // 耗时查询
        while (query.next()) {
            results.append(query.record()); // 收集结果
        }
        emit dataFetched(results); // 完成后发射信号
    }

signals:
    void dataFetched(const QVector<QSqlRecord> &data);
};

// MainWindow.cpp (主界面)
// ...
void MainWindow::startFetching() {
    // 1. 创建线程和工作者
    QThread *workerThread = new QThread;
    DatabaseWorker *worker = new DatabaseWorker;
    worker->moveToThread(workerThread); // 关键!将工作者移到新线程

    // 2. 连接信号槽
    connect(workerThread, &QThread::started, worker, &DatabaseWorker::fetchBigData);
    connect(worker, &DatabaseWorker::dataFetched, this, &MainWindow::handleFetchedData);
    connect(worker, &DatabaseWorker::dataFetched, workerThread, &QThread::quit); // 任务完成停止线程
    connect(workerThread, &QThread::finished, worker, &QObject::deleteLater); // 清理
    connect(workerThread, &QThread::finished, workerThread, &QObject::deleteLater);

    // 3. 显示加载提示 (避免用户误操作)
    ui->statusBar->showMessage("正在拼命加载数据...");
    ui->fetchButton->setEnabled(false);

    // 4. 启动线程!
    workerThread->start();
}

void MainWindow::handleFetchedData(const QVector<QSqlRecord> &data) {
    // 1. 更新界面状态
    ui->statusBar->showMessage("数据加载完成!", 5000); // 显示5秒
    ui->fetchButton->setEnabled(true);

    // 2. 处理数据 (例如:填充到一个自定义模型或临时容器显示)
    // ... 这里可以安全操作UI ...
}

要点:

  • 线程分离: 耗时的数据库操作必须在非GUI线程中执行。
  • moveToThread: 将执行操作的对象 DatabaseWorker 移动到新创建的 QThread 中。
  • 信号槽通信: 使用信号槽在后台线程和主线程之间传递结果(如 dataFetched)和状态。Qt 的元对象系统确保跨线程信号槽安全。
  • 资源管理: 使用 connect(thread, finished, worker, deleteLater) 确保线程和工作者对象在线程结束时被正确清理。
  • 用户反馈: 在后台操作时,主界面应提供加载提示(进度条、状态栏消息、禁用按钮等)。

5.3 连接池:应对高并发挑战

在服务器应用或多线程客户端中,频繁创建和销毁数据库连接开销巨大。连接池 (QSqlDatabase connection pooling) 预先创建一组连接,线程需要时从池中取用,用完后归还,避免重复开销。

Qt 对连接池的支持:
Qt 本身没有内置完善的连接池实现,但可以利用 QSqlDatabase 的连接名机制和第三方库(如 QtConnectionPool)或自行实现简单池。

基本思想:

// 非常简化的示例,实际生产环境需要加锁、超时处理、连接健康检查等
class SimpleConnectionPool {
public:
    static QSqlDatabase getConnection() {
        QMutexLocker locker(&mutex);
        if (pool.isEmpty()) {
            // 池空,创建新连接 (带唯一连接名)
            int num = nextId++;
            QSqlDatabase db = QSqlDatabase::addDatabase("QPSQL", QString("PooledConnection-%1").arg(num));
            db.setHostName(...);
            db.setDatabaseName(...);
            db.setUserName(...);
            db.setPassword(...);
            if (!db.open()) {
                qWarning() << "Failed to create new DB connection:" << db.lastError();
                return QSqlDatabase(); // 返回无效数据库
            }
            return db;
        } else {
            // 从池中取出一个连接
            return pool.takeFirst();
        }
    }

    static void releaseConnection(QSqlDatabase connection) {
        if (!connection.isValid()) return;
        QMutexLocker locker(&mutex);
        pool.append(connection); // 归还到池中
    }

private:
    static QMutex mutex;
    static QList<QSqlDatabase> pool;
    static int nextId;
};

QMutex SimpleConnectionPool::mutex;
QList<QSqlDatabase> SimpleConnectionPool::pool;
int SimpleConnectionPool::nextId = 1;

// 线程中使用
void WorkerThread::run() {
    QSqlDatabase db = SimpleConnectionPool::getConnection();
    if (!db.isValid()) {
        // 处理错误
        return;
    }

    // 使用 db 执行查询...
    QSqlQuery query(db);
    query.exec("SELECT ...");

    // 使用完毕,归还连接
    SimpleConnectionPool::releaseConnection(db);
}

关键点:

  • 连接名唯一性: QSqlDatabase::addDatabase(driver, connectionName) 使用唯一的连接名区分不同连接。
  • 线程安全: 对连接池的访问(get/release)必须用互斥锁 (QMutex) 保护,因为多个线程会同时操作池。
  • 连接管理: 池需要管理连接的生命周期(创建、销毁)、有效性检查(连接超时断开)、最大最小连接数限制等。实际项目中建议使用成熟的第三方连接池库。

6. 高级话题:ORM 初探

ORM (Object-Relational Mapping,对象关系映射) 将数据库表结构映射到程序中的类和对象。操作对象就相当于操作数据库记录,让代码更面向对象、更简洁。

Qt 中的 ORM 选择:

  • Qt 自身: Qt 没有官方强力的 ORM 框架。QSqlTableModel 等提供了一些类似 ORM 的便利,但功能有限。
  • 第三方库:
    • QxOrm: 功能强大的 Qt ORM 库,支持关系、序列化、反射等。学习曲线稍陡峭。
    • QtRuby: 使用 Ruby 风格的 DSL 操作 Qt 数据库。
    • 其他 C++ ORM: 如 ODB (非 Qt 专属,但可与 Qt 集成)。

QxOrm 极简示例 (感受概念):

#include <qxorm.h>

// 1. 定义 Contact 类 (对应数据库表)
class Contact {
public:
    long id;
    QString name;
    QString phone;
    QString email;
    QString group;
    QString notes;
    // ... 其他成员函数 ...
};
QX_REGISTER_HPP_QX_DLL1(Contact, qx::trait::no_base_class_defined, 1) // 注册到QxOrm

// 2. 在某个初始化函数注册类到数据库 (创建表)
qx::QxSqlDatabase::getSingleton()->setDriverName("QSQLITE");
qx::QxSqlDatabase::getSingleton()->setDatabaseName("addressbook_orm.db");
qx::dao::create_table<Contact>(); // 自动建表

// 3. 使用 ORM 操作
// 插入一个新联系人
Contact newContact;
newContact.name = "赵六";
newContact.phone = "17000170000";
newContact.email = "zhaoliu@orm.com";
qx::dao::insert(newContact); // 插入数据库!自动生成SQL

// 查询所有联系人
QList<QSharedPointer<Contact>> allContacts;
qx::dao::fetch_all(allContacts); // 查询所有记录到列表
for (const auto &contact : allContacts) {
    qDebug() << "ORM Contact:" << contact->name << contact->phone;
}

// 按条件查询 (姓名包含"赵")
QList<QSharedPointer<Contact>> zhaoContacts;
qx_query query("WHERE name LIKE :name");
query.bind(":name", "%赵%");
qx::dao::fetch_by_query(query, zhaoContacts);

// 更新联系人 (假设newContact的id已被ORM填充)
newContact.group = "VIP";
qx::dao::update(newContact); // 更新数据库

// 删除联系人
qx::dao::delete_by_id(newContact); // 根据id删除

ORM 的优缺点:

  • 优点:
    • 代码更面向对象,符合直觉。
    • 减少手写 SQL,降低 SQL 注入风险。
    • 简化数据库操作(增删改查)。
    • 方便实现数据验证、关联关系(一对多,多对多)。
  • 缺点:
    • 学习成本: 需要学习 ORM 框架的 API 和概念。
    • 性能开销: ORM 在对象和 SQL 之间转换有一定开销,复杂查询可能不如手写 SQL 高效。
    • 灵活性受限: 对于极其复杂或需要数据库特定优化的查询,ORM 可能力不从心。
    • 调试复杂度: 生成的 SQL 可能不易直接查看和调试。

建议: 对于中小型项目或对开发效率要求高的场景,ORM 是提高生产力的好工具。对于性能要求极其苛刻或涉及复杂 SQL/数据库特性的场景,仍需结合手写 SQL。

7. 跨平台与部署

Qt 的数据库模块同样继承了其 “一次编写,到处编译” 的跨平台基因。但部署时需要注意数据库文件或服务的平台差异。

7.1 数据库文件路径:别迷路!

  • SQLite (文件数据库):
    • 问题: 程序在不同平台运行时,如何找到数据库文件 addressbook.db
    • 解决方案:
      • 相对路径: db.setDatabaseName("addressbook.db")。文件默认在程序的工作目录(启动程序时的目录)。不稳定,工作目录可能变化。
      • 绝对路径: db.setDatabaseName("C:/Users/小明/Documents/addressbook.db") (Windows)。不跨平台,Linux/Mac 路径不同。
      • 推荐:使用应用数据目录:
        #include <QStandardPaths>
        QString dataDir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
        QDir().mkpath(dataDir); // 确保目录存在
        QString dbPath = dataDir + QDir::separator() + "addressbook.db";
        db.setDatabaseName(dbPath);
        
        QStandardPaths::AppDataLocation 返回平台特定的、适合存储用户应用数据的目录:
        • Windows: C:\Users\<Username>\AppData\Local\<AppName>
        • Linux: /home/<username>/.local/share/<AppName>
        • macOS: /Users/<username>/Library/Application Support/<AppName>

7.2 数据库服务器连接:地址要对

  • MySQL / PostgreSQL 等 (客户端/服务器数据库):
    • 问题: 程序需要知道数据库服务器的 IP/域名、端口、数据库名、用户名、密码。
    • 解决方案:
      • 硬编码: 在代码里写死。极不安全! 密码泄露,且更换服务器需重新编译。
      • 配置文件: 将连接信息(服务器地址、端口、数据库名、用户名、加密后的密码)存储在外部配置文件(如 INI, JSON, XML)或环境变量中。程序启动时读取。
      • 用户输入: 首次运行时让用户输入连接信息并保存(加密存储)。
      • 连接字符串池: 对于需要连接多个数据库的应用,维护一个连接字符串池。
    • 跨平台性: 服务器地址(IP/域名)和端口通常是平台无关的。配置文件的路径同样建议使用 QStandardPaths 定位。

示例 (读取 INI 配置文件):

; config.ini
[Database]
Type=QMYSQL
Host=db.example.com
Port=3306
DatabaseName=my_app_db
UserName=app_user
Password=encrypted_password_here ; 强烈建议存储加密后的密码
#include <QSettings>
#include <QCryptographicHash>

QSettings settings("config.ini", QSettings::IniFormat);
settings.beginGroup("Database");

QString dbType = settings.value("Type", "QSQLITE").toString(); // 默认SQLite
QString host = settings.value("Host", "localhost").toString();
int port = settings.value("Port", 0).toInt(); // 0 表示使用驱动默认端口
QString dbName = settings.value("DatabaseName", "").toString();
QString userName = settings.value("UserName", "").toString();
QString encryptedPwd = settings.value("Password", "").toString();

// !!! 重要:这里需要解密 encryptedPwd 得到真实密码 (解密逻辑省略)
QString realPassword = decryptPassword(encryptedPwd);

QSqlDatabase db = QSqlDatabase::addDatabase(dbType);
if (!host.isEmpty()) db.setHostName(host);
if (port > 0) db.setPort(port);
db.setDatabaseName(dbName);
db.setUserName(userName);
db.setPassword(realPassword);

if (!db.open()) { ... }

安全警告:

  • 绝不在代码或配置文件中存储明文密码!
  • 使用可靠的加密算法(如 AES)加密密码,并将加密密钥安全地存储(例如,使用操作系统提供的密钥存储服务,或让用户在首次运行时输入主密钥)。
  • 考虑使用访问令牌 (Token) 代替密码,并设置合理的过期时间。

7.3 包含数据库驱动:打包别落下

部署应用程序时,确保目标机器上有程序所需的 Qt 数据库驱动插件 (qsqlite.dll, qsqlmysql.dll, qsqlpsql.dll 等)。

  • Windows / Linux / macOS:
    • 将对应的驱动插件文件(位于 Qt 安装目录的 plugins/sqldrivers 子目录下)复制到你的应用程序可执行文件目录下的 sqldrivers 文件夹中(或使用 QApplication::addLibraryPath() 指定插件路径)。
    • 确保应用程序能找到 Qt 核心库和其他依赖库。
  • Android / iOS:
    • .pro 文件中明确指定需要哪些插件。Qt 的构建系统会将其打包进 APK 或 IPA。
    # MyApp.pro
    QT += sql
    # 指定需要 SQLite 驱动 (通常默认包含) 和 MySQL 驱动
    QTPLUGIN += qsqlite qsqlmysql
    
    • 部署到真机时,确保数据库服务器地址能被移动网络访问到(对于 SQLite 则不需要)。

8. Qt 6 数据库新动向

Qt 6 在数据库模块上主要是延续 Qt 5 的成熟功能,并做了一些现代化改进:

  1. 移除过时驱动: 移除了对一些较少使用或过时的数据库驱动的官方支持(如 QODBC 在 Qt 6 的一些版本中状态有变化,需注意检查文档)。
  2. C++17 要求: Qt 6 整体要求 C++17 编译器,可以利用现代 C++ 特性编写更简洁安全的数据库代码(如结构化绑定遍历查询结果)。
  3. CMake 优先: Qt 6 大力推广 CMake 作为构建系统。数据库相关的配置在 CMakeLists.txt 中通过 find_package(Qt6 COMPONENTS Sql)target_link_libraries(myapp PRIVATE Qt6::Sql) 完成。
  4. 潜在的性能优化: 内部实现的持续优化可能带来性能提升。

使用 C++17 结构化绑定遍历查询结果 (Qt 6):

QSqlQuery query("SELECT name, phone, email FROM contacts");
while (query.next()) {
    auto [name, phone, email] = query.value(0).toString(),
                              query.value(1).toString(),
                              query.value(2).toString(); // C++17 结构化绑定
    qDebug() << "Contact:" << name << "| Phone:" << phone << "| Email:" << email;
}

9. 避坑指南:常见问题与解决

  1. QSqlDatabase: QSQLITE driver not loaded
    • 原因: 程序运行时找不到 SQLite 驱动插件。
    • 解决:
      • 部署: 确保 qsqlite.dll (Windows)、libqsqlite.so (Linux) 或 libqsqlite.dylib (macOS) 文件在应用程序的 sqldrivers 目录下(相对于可执行文件),或使用 QCoreApplication::addLibraryPath() 添加正确路径。
      • 开发环境 (Qt Creator): 通常自动配置好。检查项目构建套件 (Kit) 是否选对,确保 QT += sql.pro 文件中。
  2. Database disk image is malformed (SQLite)
    • 原因: SQLite 数据库文件损坏。可能由于程序崩溃、磁盘错误、不安全的并发写入导致。
    • 解决:
      • 备份: 立即备份损坏的数据库文件。
      • 修复: 尝试使用 SQLite 命令行工具 .dump 导出数据,或使用 sqlite3.recover 命令尝试修复,或使用第三方 SQLite 修复工具。
      • 预防:
        • 总是使用事务包裹写操作。
        • 确保只有一个进程或线程同时写入同一个 SQLite 数据库文件 (SQLite 对并发写的支持有限)。
        • 定期备份数据库。
  3. Lost connection to MySQL server during query
    • 原因: 网络中断、MySQL 服务器重启、连接空闲超时 (wait_timeout 设置) 被服务器断开。
    • 解决:
      • 重连机制: 在执行查询前检查连接是否有效 (if (!db.isOpen()) db.open();),如果失效则尝试重新连接。注意处理重连失败。
      • 心跳保活: 对于长连接,定期执行一个简单查询 (如 SELECT 1) 保持连接活跃,防止服务器因超时断开。
      • 调整服务器配置: 适当增加 MySQL 的 wait_timeoutinteractive_timeout 参数(需数据库管理员权限)。
  4. Parameter count mismatch
    • 原因: 使用 prepare() + addBindValue() / bindValue() 时,SQL 语句中的占位符 (?:name) 数量与绑定的值的数量不一致。
    • 解决: 仔细检查 SQL 语句中的占位符数量和调用 addBindValue() 的次数是否严格匹配。使用命名占位符 (:name) 可以提高可读性并降低出错概率。
  5. No query Unable to fetch row
    • 原因: 在使用 QSqlQuery::value() 或遍历结果集 (next()) 之前,没有成功调用 exec() 执行查询,或者 exec() 失败了但没检查错误。
    • 解决:
      • 总是检查 query.exec() 的返回值。
      • 在调用 value()next() 之前,确保 exec() 成功执行。next() 在首次调用前需要 exec() 成功。
  6. 模型/视图不更新
    • 原因: 直接通过 SQL 修改了数据库,但关联的 QSqlTableModel / QSqlQueryModel 不知道数据已变。
    • 解决: 手动调用模型的 select() 方法重新查询数据库刷新数据。如果知道哪些行改变了,可以使用 dataChanged() 信号优化刷新范围。对于 QSqlTableModel,确保 OnManualSubmit 策略下的更改已 submitAll()
  7. 中文乱码
    • 原因: 数据库连接字符集设置不正确,导致 Qt 和数据库传输数据时编码不一致。
    • 解决 (MySQL 示例):
      db = QSqlDatabase::addDatabase("QMYSQL");
      db.setConnectOptions("MYSQL_OPT_SET_CHARSET_NAME=UTF8MB4"); // 关键连接选项
      // 或者 SET NAMES 'utf8mb4' 查询
      QSqlQuery setNamesQuery(db);
      setNamesQuery.exec("SET NAMES 'utf8mb4'"); // 在打开连接后执行
      
      • 确保数据库、表、字段的字符集也是 utf8mb4 (推荐)。
      • 确保源代码文件保存为 UTF-8 编码。

10. 总结:Qt 数据管理之道

恭喜你!已经跟随 Qt 的管家,从最基础的文件存取 (QFile),到建立智能仓库 (数据库 QtSql),再到用炫酷展示架 (Model/View) 高效管理数据,最后还探索了性能优化、部署、ORM 等进阶话题。让我们回顾核心要点:

  1. 文件操作 (QFile, QFileInfo, QDir): 处理本地存储的基石,适合配置、资源、简单数据或作为数据库的补充(存储路径、大文件)。记住检查操作结果和错误!
  2. 数据库连接 (QSqlDatabase): 数据管理的核心。SQLite 是轻量级单文件首选,MySQL/PostgreSQL 适合服务端或复杂应用。安全第一:防注入 (绑定参数)、加密敏感信息、用事务保证原子性。
  3. SQL 执行 (QSqlQuery): 与数据库沟通的语言。prepare() + 绑定参数是黄金法则。务必检查 exec() 结果和 lastError()
  4. 模型/视图 (QSqlTableModel, QSqlQueryModel, QTableView): 管理海量数据和用户交互的利器。理解 Model(数据)、View(显示)、Delegate(渲染/编辑) 的分离思想。掌握 OnManualSubmit 策略和 submitAll()/revertAll()
  5. 性能与健壮性: 批量操作 + 事务提速显著。异步查询 (多线程) 保界面流畅。连接池应对高并发。错误处理不可或缺。
  6. 跨平台部署:QStandardPaths 定位数据库文件和配置。妥善打包数据库驱动插件。
  7. 持续学习: 探索 ORM (如 QxOrm) 提升开发效率。关注 Qt 6 新特性。实践中遇到坑?善用文档、社区和调试器!

Qt 数据管理哲学: 提供丰富层次分明的 API。从底层的文件/字节操作,到中层的 SQL 数据库访问,再到高层的模型/视图绑定。开发者可以根据项目需求灵活选择合适层级的工具,在开发效率、性能和控制力之间找到最佳平衡。

行动起来! 无论是开发一个本地笔记工具、一个需要联网的客户管理系统,还是一个数据分析平台,Qt 强大的文件与数据库能力都是你最坚实的后盾。运用本文所学,开始构建你高效、可靠的数据驱动型应用吧!别忘了在实践中不断尝试、优化和解决新问题。


附录:

结语
感谢您的阅读!期待您的一键三连!欢迎指正!

在这里插入图片描述

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

【Air】

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值