1. 背景与目标
业务记录单在不同客户、不同驻场环境下,往往需要调整标题、字段顺序、表格样式、打印边距、页眉页脚、签名位置等内容。如果全部样式和结构都硬编码在 QML 或 C++ 中,会导致:
-
每次样式调整都需要重新编译;
-
驻场人员无法独立修改;
-
多客户版本维护成本高;
-
打印效果与屏幕效果难以统一。
因此,系统采用 HTML 模板作为记录单展示与打印载体:
-
QML 负责弹窗、数据传递、签名采集、状态控制、PDF 导出;
-
HTML/CSS/JS 负责记录单布局、字段展示、打印适配;
-
C++/Model 层负责文件路径、模板文件名、签名资源名、文件删除等能力;
-
WebEngineView 负责加载 HTML、执行 JS、导出 PDF。
目标:
-
业务记录单样式可通过修改 HTML 完成;
-
数据由 QML 动态推送到 HTML;
-
支持手写签名、清除签名、确认签名;
-
确认后允许导出 PDF;
-
导出成功或失败有明确提示;
-
代码与真实业务解耦,便于复用。
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 + ")"
);
}
该方式解决两个问题:
-
HTML 已加载完成时,直接调用
hydrateTemplate; -
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 模板必须满足:
-
提供
hydrateTemplate(data); -
使用
data-field标记字段; -
支持
window.__templateData; -
签名图片文件名与 QML 配置一致;
-
缺失图片使用
onerror移除; -
使用
@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. 测试要点
-
HTML 加载成功、失败、路径错误;
-
数据推送早于 DOMContentLoaded 的场景;
-
多笔画签名、单点签名、连续签名;
-
清除签名后旧签名图片是否消失;
-
确认签名后 HTML 是否显示签名图片;
-
未签名时导出是否被拦截;
-
PDF 导出成功与失败提示;
-
中文路径、空格路径、Windows 路径;
-
打印分页、A4 边距、页眉页脚;
-
模板缺少图片、字段、函数时的容错;
-
多次打开弹窗是否清理旧状态;
-
资源文件不存在时删除操作是否安全返回。
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 模板来适配不同格式要求,而无需重新编译核心代码。