Files
2026-10-16 09:06:03 +08:00

292 lines
11 KiB
C++
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "datacard.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QTimer>
#include <QFontMetrics>
#include <QEvent>
#include "global.h"
#include <QStyle>
namespace {
// 行高的上限。行按 stretch 长到这个高度就停,多出来的空间还给卡片末尾的弹性——
// 否则卡片一高(首页 2×2 那种高卡)就把两三行拉成稀稀拉拉一大片。
// 数值行的最大字号是 50px(行高约 60px),72 给足了余量,想调松紧改这个数。
const int kRowMaxH = 72;
// 字号:数值行标签 32(数值字号随卡片走,见 DataCard::setValueFontPx)
const int kLabelFontPx = 32;
// 故障行单独一号,且**全应用统一**。放不下时不再截断,而是整条从右往左自动滑动
// (见 tickFaultScroll),所以字号可以比数值行小一点也依然读得全。
const int kFaultFontPx = 40;
const int kFaultScrollMs = 30; // 滑动节拍
const int kFaultScrollStep = 3; // 每拍左移像素 -> 约 100px/s
// 故障文字的裁剪容器至少要有一行高,否则没有布局时 QWidget::sizeHint() 是 (-1,-1),
// 这一行会被压成 0 高
const int kFaultBoxMinH = kFaultFontPx + 8;
// 给一行的所有控件设最大高度,从而把整行的高度卡住(QHBoxLayout 的高度取子项最大值)
void capRow(QHBoxLayout* row, int h)
{
for (int i = 0; i < row->count(); ++i)
if (QWidget* w = row->itemAt(i)->widget())
w->setMaximumHeight(h);
}
} // namespace
DataCard::DataCard(const QString& title, QWidget* parent)
: QWidget(parent)
{
setProperty("card", true);
QVBoxLayout* lay = new QVBoxLayout(this);
lay->setContentsMargins(24, 0, 24, 20);
lay->setSpacing(16);
// 标题栏(标题在卡片内居中,下沿一条分割线)
m_title = new QLabel(title, this);
m_title->setFixedHeight(60);
m_title->setAlignment(Qt::AlignCenter);
m_title->setProperty("cardTitle", true);
m_title->setStyleSheet(QString("color:%1;font-size:42px;font-weight:700;border-bottom:1px solid %2;")
.arg(C::TEXT, C::BORDER));
lay->addWidget(m_title);
// 故障行:构造时就建好,永远占着最后一行——有无故障都不改变卡片布局。
ensureFaultRow();
// 末尾弹性:各行长到自己的上限后,剩下的高度全归它。
// 顺序因此固定为 [标题][数值行…][故障行][弹性],addField 插在倒数第二项之前。
lay->addStretch(1);
}
void DataCard::setTitle(const QString& t)
{
m_title->setText(t);
}
int DataCard::addField(const QString& label, const QString& value, bool big)
{
int index = m_values.size();
QHBoxLayout* row = new QHBoxLayout();
row->setSpacing(16);
QLabel* lab = new QLabel(label + ":", this);
lab->setProperty("sub", true);
lab->setStyleSheet(QString("color:%1;font-size:%2px;").arg(C::TEXT_SUB).arg(kLabelFontPx));
// 数值标签只在行内样式里给字号/字重,颜色交给应用级样式表的 [cardValue] / [alert] 规则:
// 控件自身的样式表优先级高于应用级样式表,行内一写 color,告警的红字与暗红闪烁就全被盖掉,
// 属性照样翻转、界面上却看不出任何变化。AlarmBanner 就是这么写的,这里保持一致。
QLabel* val = new QLabel(value, this);
val->setProperty("cardValue", true);
val->setStyleSheet(QString("font-size:%1px;font-weight:700;").arg(big ? 50 : m_valueFontPx));
row->addWidget(lab);
row->addWidget(val, 1);
// 插到故障行之前(末尾两项固定是 [故障行][弹性]);
// stretch = 1 让行在有富余高度时长到 kRowMaxH 为止,再高就不长了
capRow(row, kRowMaxH);
QVBoxLayout* lay = qobject_cast<QVBoxLayout*>(layout());
lay->insertLayout(lay->count() - 2, row, 1);
m_values.append(val);
m_labels.append(label);
return index;
}
void DataCard::setField(int index, const QString& value)
{
if (index < 0 || index >= m_values.size())
return;
m_values[index]->setText(value);
}
void DataCard::setAlarm(int index, bool on)
{
if (index < 0 || index >= m_values.size())
return;
QLabel* val = m_values[index];
if (val->property("alert").toBool() == on)
return; // 每次遥测刷新都会调进来,状态没变就别重算样式
val->setProperty("alert", on);
if (on)
startBlink(val, "blink");
else
stopBlink(val, "blink");
// 置属性不会自动重算样式,不补这一次重算,红字要等下一次 blink 翻转(最多 500ms)才出现
val->style()->unpolish(val);
val->style()->polish(val);
val->update();
}
// 故障行:构造时就建好,永远占着最后一行,高度与位置都不随有无故障变化。
// 不放“故障:”标签——那要吃掉一百多像素,卡片本来就窄;整行宽度都给文字。
// 文字比整行还宽时不截断,改成在裁剪容器里从右往左自动滑动。
void DataCard::ensureFaultRow()
{
if (m_faultRow)
return;
m_faultRow = new QHBoxLayout();
m_faultRow->setSpacing(0);
// 裁剪容器:子控件超出它的范围会被裁掉,正好当滑动窗口用。
// 里面不放布局,标签的宽度和位置由 positionFaultLabel() 手动给。
m_faultBox = new QWidget(this);
m_faultBox->setMinimumHeight(kFaultBoxMinH);
m_faultBox->installEventFilter(this); // 宽度定了才知道要不要滑
// 与字段行同规矩:行内只写字号/字重,颜色交给应用级样式表的 [cardValue] / [alert]
m_faultValue = new QLabel(m_faultBox);
m_faultValue->setProperty("cardValue", true);
m_faultValue->setStyleSheet(QString("font-size:%1px;font-weight:700;").arg(kFaultFontPx));
m_faultRow->addWidget(m_faultBox, 1);
capRow(m_faultRow, kRowMaxH); // 与数值行同一个上限
// 无故障时用**空文字**占位,而不是 hide():隐藏的子控件会让这一项被判成“空”,
// QBoxLayout 连它那份高度一起跳过,卡片照样会跳一下。
QVBoxLayout* lay = qobject_cast<QVBoxLayout*>(layout());
if (lay)
lay->addLayout(m_faultRow, 1); // 固定在数值行之后,高度同样受 kRowMaxH 限制
m_faultTimer = new QTimer(this);
m_faultTimer->setInterval(1000); // 多条故障按 1s 轮流显示
connect(m_faultTimer, &QTimer::timeout, this, &DataCard::rotateFault);
m_scrollTimer = new QTimer(this);
m_scrollTimer->setInterval(kFaultScrollMs);
connect(m_scrollTimer, &QTimer::timeout, this, &DataCard::tickFaultScroll);
}
// 设文字、按字体量出像素宽,再决定停着显示还是滑动
void DataCard::applyFaultText()
{
QFont f = m_faultValue->font();
f.setPixelSize(kFaultFontPx); // QSS 的字号未必已经反映到 QLabel::font() 上
const QFontMetrics fm(f);
m_faultValue->setText(m_faultText);
m_textW = m_faultText.isEmpty() ? 0 : fm.horizontalAdvance(m_faultText) + 2;
m_faultValue->setFixedWidth(m_textW > 0 ? m_textW : 1);
m_offset = 0;
updateFaultScroll();
positionFaultLabel();
}
// 放得下就停着显示;放不下才滑动。首次布局前 m_faultBox->width() 还是默认值,
// 这里会先当成"放得下",等拿到真宽度后由 eventFilter 再判一次。
void DataCard::updateFaultScroll()
{
const bool need = m_textW > 0 && m_faultBox->width() > 0 && m_textW > m_faultBox->width();
if (need) {
if (!m_scrollTimer->isActive()) m_scrollTimer->start();
} else {
m_scrollTimer->stop();
m_offset = 0;
}
}
void DataCard::positionFaultLabel()
{
m_faultValue->resize(m_textW > 0 ? m_textW : 1, m_faultBox->height());
m_faultValue->move(-m_offset, 0); // 负偏移 = 往左移出裁剪容器
}
// 左移一拍;整条完全移出左边界后从头再来
void DataCard::tickFaultScroll()
{
if (m_textW <= 0)
return;
m_offset += kFaultScrollStep;
if (m_offset > m_textW)
m_offset = 0;
positionFaultLabel();
}
bool DataCard::eventFilter(QObject* obj, QEvent* ev)
{
if (obj == m_faultBox && ev->type() == QEvent::Resize) {
// 真宽度下来了,这一刻才判得准要不要滑
updateFaultScroll();
positionFaultLabel();
}
return QWidget::eventFilter(obj, ev);
}
void DataCard::setFaults(const QStringList& faults)
{
// 每次遥测刷新都会调进来。内容没变就直接返回:既省掉重复重算样式,
// 也避免把 m_faultIdx 清零——否则多条故障永远停在第一条上。
if (faults == m_faults)
return;
m_faults = faults;
m_faultIdx = 0;
// 行本身一直占着位(见 ensureFaultRow),这里只切内容:
// 无故障清空文字、有故障填内容并红闪——显隐规则与以前一致。
if (m_faults.isEmpty()) {
m_faultTimer->stop();
stopBlink(m_faultValue, "blink");
m_faultValue->setProperty("alert", false);
m_faultText.clear();
applyFaultText(); // 清空文字:停滑、宽度归 1,这一行仍然占位
return;
}
m_faultText = m_faults.at(0);
applyFaultText();
m_faultValue->setProperty("alert", true);
startBlink(m_faultValue, "blink");
m_faultValue->style()->unpolish(m_faultValue);
m_faultValue->style()->polish(m_faultValue);
m_faultValue->update();
if (m_faults.size() > 1)
m_faultTimer->start();
else
m_faultTimer->stop();
}
void DataCard::rotateFault()
{
if (m_faults.size() < 2)
return;
m_faultIdx = (m_faultIdx + 1) % m_faults.size();
m_faultText = m_faults.at(m_faultIdx);
applyFaultText();
}
void DataCard::setHighlight(bool on)
{
if (on)
setStyleSheet(QString("QWidget[card=\"true\"]{background-color:%1;border:3px solid %2;border-radius:12px;}")
.arg(C::PANEL, C::PRIMARY));
else
setStyleSheet(QString());
}
// 字段行文字从左缘缩进到「中间偏左」:行首弹性 2 : 数值行尾弹性 3
void DataCard::indentRows()
{
QVBoxLayout* lay = qobject_cast<QVBoxLayout*>(layout());
if (!lay)
return;
for (int i = 0; i < lay->count(); ++i) {
QHBoxLayout* row = qobject_cast<QHBoxLayout*>(lay->itemAt(i)->layout());
if (!row || row->count() != 2) // 字段行是 [标签][数值];标题与末尾 stretch 不是子布局
continue;
if (row->itemAt(0)->spacerItem()) // 已缩进过,避免重复插入
continue;
row->insertStretch(0, 2);
row->setStretch(2, 3); // 插入后 [0]=行首弹性 [1]=标签 [2]=数值
}
}