Qt/QML:业务记录单展示、签名确认与 PDF 导出开发

1. 背景与目标

业务记录单在不同客户、不同驻场环境下,往往需要调整标题、字段顺序、表格样式、打印边距、页眉页脚、签名位置等内容。如果全部样式和结构都硬编码在 QML 或 C++ 中,会导致:

  • 每次样式调整都需要重新编译;

  • 驻场人员无法独立修改;

  • 多客户版本维护成本高;

  • 打印效果与屏幕效果难以统一。

因此,系统采用 HTML 模板作为记录单展示与打印载体:

  • QML 负责弹窗、数据传递、签名采集、状态控制、PDF 导出;

  • HTML/CSS/JS 负责记录单布局、字段展示、打印适配;

  • C++/Model 层负责文件路径、模板文件名、签名资源名、文件删除等能力;

  • WebEngineView 负责加载 HTML、执行 JS、导出 PDF。

目标:

  1. 业务记录单样式可通过修改 HTML 完成;

  2. 数据由 QML 动态推送到 HTML;

  3. 支持手写签名、清除签名、确认签名;

  4. 确认后允许导出 PDF;

  5. 导出成功或失败有明确提示;

  6. 代码与真实业务解耦,便于复用。


2. 总体架构

复制代码
┌──────────────────────────────────────────────┐
│                QML 导出对话框                 │
│  RecordExportDialog                          │
│  - 记录数据属性                              │
│  - canExport 状态                            │
│  - 打开时清理旧签名                          │
│  - 推送数据到 HTML                           │
└───────────────┬──────────────────────────────┘
                │
                │ WebEngineView
                ▼
┌──────────────────────────────────────────────┐
│              HTML 模板页面                    │
│  record-template.html                        │
│  - data-field 占位                           │
│  - hydrateTemplate(data)                     │
│  - window.__templateData                     │
│  - 签名图片占位                              │
│  - @media print 打印样式                     │
└───────────────┬──────────────────────────────┘
                │
                │ 文件资源
                ▼
┌──────────────────────────────────────────────┐
│              资源目录                         │
│  - record-template.html                      │
│  - operator-sign.png                         │
│  - object-icon.png                           │
│  - record-export.pdf                         │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│              C++ / Model 层                   │
│  RecordRepository                            │
│  - assetRoot                                 │
│  - templateName                              │
│  - signAssetName                             │
│  - deleteAsset(name)                         │
│  - assetLocalPath(name)                      │
└──────────────────────────────────────────────┘

3. 模块设计

3.1 导出对话框

导出对话框是业务入口,主要职责:

  • 接收外部传入的记录数据;

  • 打开时重置状态;

  • 清理旧签名文件和签名画布;

  • 重新加载 HTML 模板;

  • 将记录数据推送到 HTML;

  • 管理"是否允许导出"的状态;

  • 调用 WebEngineView 导出 PDF;

  • 显示成功或失败提示。

核心状态:

状态 说明
canExport 是否已确认签名,允许导出 PDF
record 当前业务记录数据
assetRoot 模板、签名、PDF 所在资源目录
templateName HTML 模板文件名
signAssetName 签名图片文件名

3.2 HTML 模板渲染

HTML 模板不关心数据来自 QML 还是其他来源,只约定:

  • 使用 data-field 标记需要填充的字段;

  • 提供 hydrateTemplate(data) 函数;

  • 支持 window.__templateData 暂存数据;

  • 使用 DOMContentLoaded 处理加载时序;

  • 使用 @media print 适配打印;

  • 对缺失图片使用 onerror="this.remove()" 容错。

示例字段:

字段 含义
recordId 记录编号
subjectName 对象名称
subjectAge 年龄
category 类别
gender 性别
operatorName 经办人
receivedTime 受理时间
processedTime 处理时间
processMethod 处理方式
remarks 备注

3.3 数据桥接

QML 将数据组装为 JSON,通过 runJavaScript 注入 HTML:

复制代码
function pushData() {
    var payload = {
        recordId: record.recordId,
        subjectName: record.subjectName,
        subjectAge: record.subjectAge,
        category: record.category,
        gender: record.gender,
        operatorName: record.operatorName,
        receivedTime: Qt.formatDateTime(record.receivedTime, "yyyy-MM-dd HH:mm"),
        processedTime: Qt.formatDateTime(record.processedTime, "yyyy-MM-dd HH:mm"),
        processMethod: record.processMethod,
        remarks: record.remarks
    };

    var json = JSON.stringify(payload);

    templateView.runJavaScript(
        "typeof hydrateTemplate === 'function' ? hydrateTemplate(" + json + ") : (window.__templateData = " + json + ")"
    );
}

该方式解决两个问题:

  1. HTML 已加载完成时,直接调用 hydrateTemplate;

  2. HTML 尚未加载完成时,将数据暂存到 window.__templateData,等待 DOMContentLoaded 后填充。

3.4 签名采集

签名区域使用 Canvas 与 MouseArea 实现:

  • Canvas 负责绘制;

  • MouseArea 负责采集鼠标或触摸点;

  • strokes 使用二维数组保存每一笔;

  • 每一笔单独 beginPath,避免不同笔画相连;

  • 单点点击时绘制圆点;

  • 提供 clearAll() 清空签名。

数据结构:

复制代码
strokes[笔画索引][点索引] = { x, y }

绘制逻辑:

复制代码
Canvas {
    id: signPad
    property var strokes: []

    onPaint: {
        var ctx = getContext("2d");
        ctx.clearRect(0, 0, width, height);
        ctx.fillStyle = "white";
        ctx.fillRect(0, 0, width, height);

        ctx.lineWidth = 3;
        ctx.strokeStyle = "black";
        ctx.lineCap = "round";
        ctx.lineJoin = "round";

        for (var s = 0; s < strokes.length; ++s) {
            var pts = strokes[s];
            if (!pts || pts.length === 0)
                continue;

            if (pts.length === 1) {
                ctx.beginPath();
                ctx.arc(pts[0].x, pts[0].y, ctx.lineWidth / 2, 0, Math.PI * 2);
                ctx.fillStyle = "black";
                ctx.fill();
                continue;
            }

            ctx.beginPath();
            ctx.moveTo(pts[0].x, pts[0].y);
            for (var i = 1; i < pts.length; ++i)
                ctx.lineTo(pts[i].x, pts[i].y);
            ctx.stroke();
        }
    }

    function clearAll() {
        strokes = [];
        requestPaint();
    }
}

3.5 资源与文件管理

C++/Model 层提供资源目录和文件删除能力,避免 QML 直接处理复杂路径。

建议接口:

复制代码
class RecordRepository : public QAbstractListModel
{
    Q_OBJECT
    Q_PROPERTY(QString assetRoot READ assetRoot WRITE setAssetRoot NOTIFY assetRootChanged)
    Q_PROPERTY(QString templateName READ templateName WRITE setTemplateName NOTIFY templateNameChanged)
    Q_PROPERTY(QString signAssetName READ signAssetName WRITE setSignAssetName NOTIFY signAssetNameChanged)

public:
    Q_INVOKABLE bool deleteAsset(const QString& name);
    Q_INVOKABLE QString assetLocalPath(const QString& name) const;
};

路径建议统一使用 QUrl::fromLocalFile 与 QUrl::toLocalFile,避免 Windows 下 file:///、中文路径、空格转义导致资源找不到。

3.6 PDF 导出

WebEngineView 提供 PDF 导出能力。导出前必须检查 canExport:

复制代码
onClicked: {
    if (!canExport) {
        tipDialog.mainText = qsTr("Please sign first")
        tipDialog.type = MessageType.Warning
        tipDialog.open()
        return
    }

    var pdfPath = RecordRepository.assetLocalPath("record-export.pdf")
    templateView.printToPdf(pdfPath)
}

导出回调:

复制代码
onPdfPrintingFinished: {
    if (success) {
        tipDialog.mainText = qsTr("PDF Export Success.")
        tipDialog.type = MessageType.Success
    } else {
        tipDialog.mainText = qsTr("PDF Export Error. Please try again.")
        tipDialog.type = MessageType.Error
    }
    tipDialog.open()
}

4. 关键流程

4.1 打开初始化

复制代码
打开导出对话框
  ↓
清空签名画布
  ↓
删除旧签名文件
  ↓
canExport = false
  ↓
重新加载 HTML 模板

目的:避免上一次签名、旧 PDF 状态、旧模板数据污染当前操作。

4.2 模板加载与数据填充

复制代码
HTML 加载成功
  ↓
pushData()
  ↓
组装 JSON
  ↓
runJavaScript 调用 hydrateTemplate
  ↓
HTML 更新 data-field 对应内容

如果 HTML 尚未完成解析,则数据暂存到 window.__templateData,由 DOMContentLoaded 事件触发填充。

4.3 签名与确认

复制代码
用户在 Canvas 上书写
  ↓
MouseArea 采集点
  ↓
strokes 追加点
  ↓
requestPaint() 重绘
  ↓
点击 Confirm
  ↓
Canvas.save(签名文件路径)
  ↓
重载 HTML
  ↓
canExport = true

清除签名时:

复制代码
点击 Clear
  ↓
signPad.clearAll()
  ↓
删除签名文件
  ↓
重新加载 HTML
  ↓
canExport = false

4.4 导出 PDF

复制代码
点击 Export PDF
  ↓
检查 canExport
  ↓
否:提示先签名
  ↓
是:生成本地 PDF 路径
  ↓
WebEngineView.printToPdf(pdfPath)
  ↓
onPdfPrintingFinished
  ↓
成功/失败提示

5. 接口契约

5.1 HTML 模板约定

HTML 模板必须满足:

  1. 提供 hydrateTemplate(data);

  2. 使用 data-field 标记字段;

  3. 支持 window.__templateData;

  4. 签名图片文件名与 QML 配置一致;

  5. 缺失图片使用 onerror 移除;

  6. 使用 @media print 控制打印样式;

示例:

复制代码
<script>
function hydrateTemplate(data) {
    if (typeof data === 'string') {
        try { data = JSON.parse(data); } catch (e) { return; }
    }

    document.querySelectorAll('[data-field]').forEach(function (el) {
        var v = data[el.getAttribute('data-field')];
        el.textContent = (v === undefined || v === null) ? '' : v;
    });
}

window.__templateData = null;

window.addEventListener('DOMContentLoaded', function () {
    if (window.__templateData) hydrateTemplate(window.__templateData);
});
</script>

<div class="info-item">
    <span class="label">记录编号:</span>
    <span class="value" data-field="recordId"></span>
</div>

<div class="signature-img-box">
    <img src="operator-sign.png" alt="" οnerrοr="this.remove()">
</div>

5.2 QML/C++ 接口

接口 说明
assetRoot 资源目录,file URL 或本地路径由内部统一
templateName HTML 模板文件名
signAssetName 签名图片文件名
deleteAsset(name) 删除资源文件,不存在时返回成功
assetLocalPath(name) 返回本地文件系统路径,用于保存或导出
printToPdf(path) WebEngineView PDF 导出
onPdfPrintingFinished PDF 导出结果回调

6. 代码示例

6.1 QML 导出对话框

复制代码
Dialog {
    id: exportDialog
    title: qsTr("Export Record")
    modal: true
    width: 1200
    height: 600

    property bool canExport: false
    property var record: ({})

    function pushData() {
        var payload = {
            recordId: record.recordId,
            subjectName: record.subjectName,
            subjectAge: record.subjectAge,
            category: record.category,
            gender: record.gender,
            operatorName: record.operatorName,
            receivedTime: Qt.formatDateTime(record.receivedTime, "yyyy-MM-dd HH:mm"),
            processedTime: Qt.formatDateTime(record.processedTime, "yyyy-MM-dd HH:mm"),
            processMethod: record.processMethod,
            remarks: record.remarks
        };

        var json = JSON.stringify(payload);

        templateView.runJavaScript(
            "typeof hydrateTemplate === 'function' ? hydrateTemplate(" + json + ") : (window.__templateData = " + json + ")"
        );
    }

    onOpened: {
        signPad.clearAll();
        RecordRepository.deleteAsset("operator-sign.png");
        canExport = false;
        templateView.reload();
    }

    contentItem: Item {
        WebEngineView {
            id: templateView
            url: RecordRepository.assetRoot + RecordRepository.templateName

            onLoadingChanged: {
                if (loadRequest.status === WebEngineView.LoadSucceededStatus) {
                    pushData();
                }
            }

            onPdfPrintingFinished: {
                if (success) {
                    tipDialog.mainText = qsTr("PDF Export Success.")
                    tipDialog.type = MessageType.Success
                } else {
                    tipDialog.mainText = qsTr("PDF Export Error.")
                    tipDialog.type = MessageType.Error
                }
                tipDialog.open()
            }
        }

        Canvas {
            id: signPad
            property var strokes: []

            function clearAll() {
                strokes = [];
                requestPaint();
            }
        }

        Button {
            text: qsTr("Confirm")
            onClicked: {
                var filePath = RecordRepository.assetLocalPath("operator-sign.png");
                if (signPad.save(filePath)) {
                    templateView.reload();
                    canExport = true;
                }
            }
        }

        Button {
            text: qsTr("Export PDF")
            onClicked: {
                if (!canExport) {
                    tipDialog.mainText = qsTr("Please sign first")
                    tipDialog.type = MessageType.Warning
                    tipDialog.open()
                    return
                }

                var pdfPath = RecordRepository.assetLocalPath("record-export.pdf");
                templateView.printToPdf(pdfPath)
            }
        }
    }
}

6.2 HTML 模板

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>业务记录单</title>

<script>
function hydrateTemplate(data) {
    if (typeof data === 'string') {
        try { data = JSON.parse(data); } catch (e) { return; }
    }

    document.querySelectorAll('[data-field]').forEach(function (el) {
        var v = data[el.getAttribute('data-field')];
        el.textContent = (v === undefined || v === null) ? '' : v;
    });
}

window.__templateData = null;

window.addEventListener('DOMContentLoaded', function () {
    if (window.__templateData) hydrateTemplate(window.__templateData);
});
</script>

<style>
    body {
        margin: 0;
        padding: 48px 20px;
        background: #eceff2;
        font-family: "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
    }

    .paper {
        max-width: 860px;
        min-height: 1080px;
        margin: 0 auto;
        padding: 56px 64px;
        background: #fff;
        box-shadow: 0 8px 24px rgba(0,0,0,.08);
    }

    .title {
        text-align: center;
        font-size: 28px;
        font-weight: 700;
        letter-spacing: 12px;
    }

    .info-grid {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: 22px 48px;
    }

    .info-item.full {
        grid-column: 1 / -1;
    }

    .value {
        display: inline-block;
        min-width: 160px;
        border-bottom: 1px solid #c9ccd1;
        padding: 0 6px 3px;
    }

    .signature-img-box {
        width: 190px;
        height: 78px;
        border-bottom: 1px solid #2b2b2b;
        display: flex;
        align-items: center;
        justify-content: center;
    }

    @media print {
        @page { margin: 16mm 14mm; }
        body { background: #fff; padding: 0; }
        .paper { box-shadow: none; padding: 0; max-width: 100%; }
        .info-grid { grid-template-columns: 1fr 1fr; }
    }
</style>
</head>
<body>

<div class="paper">
    <h1 class="title">业务记录单</h1>

    <div class="info-grid">
        <div class="info-item">
            <span class="label">记录编号:</span>
            <span class="value" data-field="recordId"></span>
        </div>

        <div class="info-item">
            <span class="label">对象名称:</span>
            <span class="value" data-field="subjectName"></span>
        </div>

        <div class="info-item">
            <span class="label">年龄:</span>
            <span class="value" data-field="subjectAge"></span>
        </div>

        <div class="info-item">
            <span class="label">性别:</span>
            <span class="value" data-field="gender"></span>
        </div>

        <div class="info-item">
            <span class="label">类别:</span>
            <span class="value" data-field="category"></span>
        </div>

        <div class="info-item">
            <span class="label">经办人:</span>
            <span class="value" data-field="operatorName"></span>
        </div>

        <div class="info-item">
            <span class="label">受理时间:</span>
            <span class="value" data-field="receivedTime"></span>
        </div>

        <div class="info-item">
            <span class="label">处理时间:</span>
            <span class="value" data-field="processedTime"></span>
        </div>

        <div class="info-item full">
            <span class="label">处理方式:</span>
            <span class="value" data-field="processMethod"></span>
        </div>

        <div class="info-item full">
            <span class="label">备注:</span>
            <span class="value" data-field="remarks"></span>
        </div>
    </div>

    <div class="footer">
        <span class="signature-label">经办人签名:</span>
        <div class="signature-img-box">
            <img src="operator-sign.png" alt="" οnerrοr="this.remove()">
        </div>
    </div>
</div>

</body>
</html>

6.3 C++ 资源接口示例

复制代码
class RecordRepository : public QAbstractListModel
{
    Q_OBJECT
    Q_PROPERTY(QString assetRoot READ assetRoot WRITE setAssetRoot NOTIFY assetRootChanged)
    Q_PROPERTY(QString templateName READ templateName WRITE setTemplateName NOTIFY templateNameChanged)
    Q_PROPERTY(QString signAssetName READ signAssetName WRITE setSignAssetName NOTIFY signAssetNameChanged)

public:
    Q_INVOKABLE bool deleteAsset(const QString& name);
    Q_INVOKABLE QString assetLocalPath(const QString& name) const;

    QString assetRoot() const;
    void setAssetRoot(const QString& path);

    QString templateName() const;
    void setTemplateName(const QString& name);

    QString signAssetName() const;
    void setSignAssetName(const QString& name);

signals:
    void assetRootChanged();
    void templateNameChanged();
    void signAssetNameChanged();
};

实现要点:

复制代码
QString RecordRepository::assetLocalPath(const QString& name) const
{
    QString dir = QUrl(m_assetRoot).toLocalFile();
    if (dir.isEmpty())
        dir = QCoreApplication::applicationDirPath() + "/WebFiles/";

    return QDir(dir).filePath(name);
}

bool RecordRepository::deleteAsset(const QString& name)
{
    if (name.isEmpty())
        return false;

    QFile file(assetLocalPath(name));
    if (!file.exists())
        return true;

    return file.remove();
}

7. 路径与时序注意事项

7.1 路径处理

建议:

  • 资源目录由 C++ 统一生成;

  • 使用 QUrl::fromLocalFile 生成 URL;

  • 使用 QUrl::toLocalFile 转本地路径;

  • 不在 QML 中大量使用字符串替换处理 file:///;

  • 兼容 Windows、中文路径、空格路径;

  • PDF 输出路径必须是本地文件系统路径,而不是 file:// URL。

7.2 时序处理

HTML 加载和数据推送存在竞争:

  • HTML 可能尚未加载完成;

  • QML 可能已经拿到数据;

  • 直接调用 hydrateTemplate 可能失败。

解决方式:

  • HTML 提供 hydrateTemplate;

  • QML 注入时判断函数是否存在;

  • 不存在则写入 window.__templateData;

  • HTML 在 DOMContentLoaded 中检查并填充。


8. 测试要点

  1. HTML 加载成功、失败、路径错误;

  2. 数据推送早于 DOMContentLoaded 的场景;

  3. 多笔画签名、单点签名、连续签名;

  4. 清除签名后旧签名图片是否消失;

  5. 确认签名后 HTML 是否显示签名图片;

  6. 未签名时导出是否被拦截;

  7. PDF 导出成功与失败提示;

  8. 中文路径、空格路径、Windows 路径;

  9. 打印分页、A4 边距、页眉页脚;

  10. 模板缺少图片、字段、函数时的容错;

  11. 多次打开弹窗是否清理旧状态;

  12. 资源文件不存在时删除操作是否安全返回。


9. 部署与维护

部署目录建议:

复制代码
应用目录/
  WebFiles/
    record-template.html
    operator-sign.png
    object-icon.png
    record-export.pdf

维护原则:

  • 驻场人员只修改 HTML/CSS/JS;

  • QML 不写死字段布局;

  • 字段变更需同步 data-field 与 QML payload;

  • 文件名变更需同步资源属性;

  • PDF 导出后可按需清理临时签名;

  • 模板应保持可独立预览,便于调试。


10. 总结

该方案通过"QML 控制流程 + HTML 负责展示 + Canvas 负责签名 + WebEngineView 负责 PDF 导出"的方式,实现了业务记录单样式与程序逻辑的解耦。

驻场人员可以直接修改 HTML 模板来适配不同格式要求,而无需重新编译核心代码。

相关推荐
傲世仙尊1 小时前
类和对象上-class与struct实例化内存对齐与this指针
c++
C++ 老炮儿的技术栈1 小时前
C++ const 的3个核心作用
服务器·c语言·c++·算法·c
小黄人软件1 小时前
粘贴助手C++版 支持 Windows 7 / 10 / 11 不会出现缺少api-ms-win-core-path-11-1-0.dll
开发语言·c++·windows
有毒的教程1 小时前
Dev-C++ 避免爆栈(Stack Overflow)完整方案
c++·算法·深度优先
程序员老陆1 小时前
从汇编层面理解 C++ 多线程
开发语言·c++·程序设计
IOT-Power2 小时前
模板方法 + 策略模式
开发语言·c++
fpcc11 小时前
c++编程实践—堆和栈越界调试
c++
Quz11 小时前
QML 动画选择:GIF与Lottie 的区别,以及 Qt 6.8 与 6.11 的 Lottie 实现差异
qt
weiabc12 小时前
MSYS2 UCRT64 + g++ C++ 中文输出乱码 完整全过程
c++