基于 Qt 的智能门禁系统(二)

faceauthservice.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QImage>
#include <QVector>

struct FaceMatchResult {
    int bestId = -1;
    QString bestName;
    double bestScore = 0.0;
};

class FaceAuthService : public QObject
{
    Q_OBJECT
public:
    explicit FaceAuthService(QObject* parent = nullptr);

    // A very simple "embedding": grayscale, resize to 32x32, normalize to unit vector (float32).
    QByteArray computeEmbedding32x32(const QImage& img) const;

    // Cosine similarity between two float32 vectors stored as QByteArray.
    static double cosineSimilarity(const QByteArray& a, const QByteArray& b);

    FaceMatchResult match(const QByteArray& probeEmbedding,
                          const QVector<struct PersonRecord>& persons) const;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
// 作用同 #ifndef FACE_AUTH_SERVICE_H ... #endif,写法更简洁
#pragma once

// 包含 QObject 基类头文件
// QObject 是所有 Qt 对象的基类,提供信号/槽、对象树、属性系统等核心功能
#include <QObject>

// 包含 QImage 头文件
// QImage 是 Qt 的图像类,用于处理图像数据(像素访问、格式转换等)
#include <QImage>

// 包含 QVector 头文件
// QVector 是 Qt 的动态数组容器,用于存储人员列表
#include <QVector>

// ============================================================================
// 前置声明:PersonRecord(解决编译依赖)
// ============================================================================

// ⚠️ 重要说明:这里使用了"前置声明"(forward declaration)
// 告诉编译器:"PersonRecord 是一个结构体,但具体定义在其他地方"
// 这样写的好处:
//   1. 减少编译依赖,加快编译速度
//   2. 避免循环包含
// 
// 注意:前置声明只能用于指针或引用,不能用于 QVector<PersonRecord>
// 因为 QVector 需要知道 PersonRecord 的完整大小(sizeof)
// 所以这里使用了 "struct PersonRecord" 作为函数参数类型,
// 但实际编译时,调用方必须已经包含了 personrepo.h
// 
// 如果编译报错,需要在包含本头文件之前包含 personrepo.h
// 或者在 faceauthservice.cpp 中包含 personrepo.h
struct PersonRecord;


// ============================================================================
// FaceMatchResult 结构体:人脸匹配结果
// ============================================================================

// FaceMatchResult 是数据传输对象(DTO),用于返回人脸匹配的结果
// 当在数据库中匹配人脸时,返回此结构体包含匹配结果
struct FaceMatchResult {
    // 匹配到的人员 ID
    // -1 表示未匹配到任何人(识别失败)
    int bestId = -1;

    // 匹配到的人员姓名
    // 如果 bestId == -1,此字段为空字符串
    QString bestName;

    // 匹配相似度分数
    // 范围:0.0 ~ 1.0(理论上余弦相似度范围是 -1.0 ~ 1.0)
    // 值越高表示越相似,通常需要阈值(如 0.65)来判断是否匹配成功
    double bestScore = 0.0;
};


// ============================================================================
// FaceAuthService 类定义
// ============================================================================

// FaceAuthService 继承自 QObject,负责人脸特征提取和比对
// 这是一个独立的算法服务类,不依赖其他模块(除了数据模型)
// 设计原则:单一职责,只做"人脸认证"这一件事
class FaceAuthService : public QObject
{
    // Q_OBJECT 是 Qt 的元对象宏
    // 必须放在所有声明了信号/槽的类中
    // 它告诉 moc(元对象编译器)对这个类进行特殊处理
    // 虽然当前类没有声明信号/槽,但保留 Q_OBJECT 为将来扩展预留
    Q_OBJECT

// ============================================================================
// 公有方法(public 部分)
// ============================================================================

public:
    // ---- 构造函数 ----
    // parent 参数用于 Qt 的对象树管理
    // 当父对象被删除时,此对象会自动删除,防止内存泄漏
    // explicit 关键字防止隐式类型转换
    explicit FaceAuthService(QObject* parent = nullptr);

    // ---- 计算人脸特征向量 ----
    // 功能:将输入图像转换为固定长度的浮点向量(特征向量/嵌入向量)
    // 
    // 算法步骤(简化版):
    //   1. 将图像转为灰度图(降低维度)
    //   2. 缩放到 32x32 像素(固定大小)
    //   3. 将像素值转为 float32(0.0 ~ 1.0)
    //   4. 展平为 1024 维向量(32×32 = 1024)
    //   5. 归一化到单位向量(L2 范数 = 1.0)
    // 
    // 输入:QImage img - 待处理的图像(通常是人脸区域)
    // 输出:QByteArray - 存储 float32 特征向量
    //      每个浮点数占 4 字节,共 1024 × 4 = 4096 字节
    // 
    // 注意:这是一个简化的特征提取方法,用于 demo 演示
    // 实际生产环境建议使用深度学习模型(如 FaceNet、ArcFace 等)
    QByteArray computeEmbedding32x32(const QImage& img) const;

    // ---- 计算余弦相似度 ----
    // 功能:计算两个特征向量之间的余弦相似度
    // 
    // 公式:cos(θ) = (A · B) / (||A|| × ||B||)
    // 
    // 由于特征向量已归一化为单位向量(||A|| = 1, ||B|| = 1),
    // 因此相似度 = A · B(点积)
    // 
    // 参数:
    //   a - 第一个特征向量(float32 数组)
    //   b - 第二个特征向量(float32 数组)
    // 
    // 返回值:
    //   double - 余弦相似度,范围 -1.0 ~ 1.0
    //   值越接近 1.0 表示越相似
    //   值越接近 -1.0 表示越不相似
    // 
    // 静态方法:不依赖对象状态,可以在不创建对象的情况下调用
    static double cosineSimilarity(const QByteArray& a, const QByteArray& b);

    // ---- 人脸匹配 ----
    // 功能:在人员列表中查找与待识别特征最相似的人员
    // 
    // 参数:
    //   probeEmbedding - 待识别的人脸特征向量
    //   persons - 数据库中的全部人员列表(包含每个人的人脸特征)
    // 
    // 返回值:
    //   FaceMatchResult - 包含最佳匹配结果
    // 
    // 算法流程:
    //   1. 如果人员列表为空,返回空结果(bestId = -1)
    //   2. 遍历每个人员,计算与 probeEmbedding 的余弦相似度
    //   3. 记录最高相似度和对应的人员信息
    //   4. 返回匹配结果(调用方根据阈值判断是否匹配成功)
    FaceMatchResult match(const QByteArray& probeEmbedding,
                          const QVector<struct PersonRecord>& persons) const;
};

修复版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QImage 头文件(用于处理图像数据)
#include <QImage>

// 包含 QVector 头文件(用于存储人员列表)
#include <QVector>

// ============================================================================
// 前置声明:PersonRecord
// ============================================================================

// ⚠️ 注意:这里需要包含定义 PersonRecord 的头文件
// 因为 QVector<PersonRecord> 需要知道 PersonRecord 的完整大小
// 如果 PersonRecord 定义在 personrepo.h 中,需要包含它
// 这里假设 PersonRecord 在 personrepo.h 中定义
#include "personrepo.h"  // ✅ 添加这一行


// ============================================================================
// FaceMatchResult 结构体:人脸匹配结果
// ============================================================================

// FaceMatchResult 是数据传输对象(DTO),用于返回人脸匹配结果
struct FaceMatchResult {
    int bestId = -1;              // 匹配到的人员 ID(-1 表示未匹配)
    QString bestName;             // 匹配到的人员姓名
    double bestScore = 0.0;       // 匹配分数(0.0 ~ 1.0,越高越相似)
};


// ============================================================================
// FaceAuthService 类定义
// ============================================================================

class FaceAuthService : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit FaceAuthService(QObject* parent = nullptr);

    // ---- 计算人脸特征向量 ----
    // 输入:QImage 图像(包含人脸区域)
    // 输出:QByteArray 存储的 float32 向量(特征向量)
    // 算法流程:灰度化 → 缩放到 32x32 → 归一化到单位向量
    // 这是一个简化的特征提取方法,实际项目中会用深度学习模型
    QByteArray computeEmbedding32x32(const QImage& img) const;

    // ---- 计算余弦相似度 ----
    // 两个 float32 向量之间的余弦相似度
    // 返回值范围:-1.0 ~ 1.0(越接近 1.0 越相似)
    // a 和 b 必须是相同长度,且都是 float32 数组
    static double cosineSimilarity(const QByteArray& a, const QByteArray& b);

    // ---- 人脸匹配 ----
    // probeEmbedding: 待识别的特征向量
    // persons: 数据库中所有人员的特征向量列表
    // 返回 FaceMatchResult,包含匹配到的最佳人员信息
    FaceMatchResult match(const QByteArray& probeEmbedding,
                          const QVector<PersonRecord>& persons) const;
};

算法说明

1. computeEmbedding32x32() - 特征提取

cpp 复制代码
输入:QImage (彩色/灰度,任意大小)
    ↓
1. 转为灰度图 (cv::cvtColor / QImage::convertToFormat)
    ↓
2. 缩放到 32x32 像素 (cv::resize)
    ↓
3. 将像素值从 0-255 映射到 0.0-1.0 (float32)
    ↓
4. 展平为 1024 维向量 (32×32 = 1024)
    ↓
5. 归一化到单位向量 (除以 L2 范数)
    ↓
输出:QByteArray (1024 × 4 = 4096 字节)

2. cosineSimilarity() - 余弦相似度

cpp 复制代码
公式:cos(θ) = (A · B) / (||A|| × ||B||)

其中:
- A · B:向量 A 和 B 的点积
- ||A||:向量 A 的 L2 范数
- ||B||:向量 B 的 L2 范数

由于特征向量已经归一化到单位向量(||A|| = 1, ||B|| = 1),
所以相似度 = 点积 A · B

3. match() - 人脸匹配

cpp 复制代码
输入:待识别的特征向量 + 数据库人员列表
    ↓
1. 如果列表为空,返回空结果 (bestId = -1)
    ↓
2. 遍历所有人员
    ├── 计算待识别向量与当前人员向量的余弦相似度
    └── 记录最高分和对应的人员
    ↓
3. 如果最高分 > 阈值 (如 0.65),返回匹配结果
   否则返回空结果 (bestId = -1)

PersonRecord 结构体(在 personrepo.h 中)

cpp 复制代码
struct PersonRecord {
    int id = -1;
    QString name;
    QString role;          // "admin" / "resident" / "visitor"
    bool enabled = true;
    qint64 validFrom = 0;
    qint64 validTo = 0;
    QByteArray faceEmbedding;  // 人脸特征向量(与 computeEmbedding32x32 输出格式一致)
    qint64 createdAt = 0;
};

使用示例

cpp 复制代码
// 在 FaceController 中使用

// 1. 从摄像头获取人脸图像
QImage faceImage = getFaceFromCamera();

// 2. 计算特征向量
QByteArray embedding = m_faceAuth->computeEmbedding32x32(faceImage);

// 3. 获取数据库中所有人员
QVector<PersonRecord> persons = m_personRepo->getAllPersons();

// 4. 匹配人脸
FaceMatchResult result = m_faceAuth->match(embedding, persons);

if (result.bestId != -1 && result.bestScore > 0.65) {
    qDebug() << "识别成功:" << result.bestName << "分数:" << result.bestScore;
    // 执行开门操作
} else {
    qDebug() << "识别失败";
}

阈值建议

场景 阈值 说明
高安全性(如金库) 0.80 ~ 0.90 严格,但可能漏识别
普通门禁 0.65 ~ 0.75 平衡准确率和通过率
低安全性(如打卡) 0.50 ~ 0.60 宽松,但有误识别风险

faceauthservice.cpp

无注释版

cpp 复制代码
#include "faceauthservice.h"
#include "personrepo.h"

#include <QtMath>

FaceAuthService::FaceAuthService(QObject* parent) : QObject(parent)
{
}

QByteArray FaceAuthService::computeEmbedding32x32(const QImage& img) const
{
    if (img.isNull()) return QByteArray();

    QImage gray = img.convertToFormat(QImage::Format_Grayscale8);
    QImage scaled = gray.scaled(32, 32, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);

    const int n = 32 * 32;
    QVector<float> vec(n);

    // Compute mean
    double mean = 0.0;
    for (int y = 0; y < 32; ++y) {
        const uchar* line = scaled.constScanLine(y);
        for (int x = 0; x < 32; ++x) {
            mean += line[x];
        }
    }
    mean /= n;

    // Fill and compute L2 norm
    double norm2 = 0.0;
    int i = 0;
    for (int y = 0; y < 32; ++y) {
        const uchar* line = scaled.constScanLine(y);
        for (int x = 0; x < 32; ++x) {
            const float v = float(line[x] - mean) / 255.0f;
            vec[i++] = v;
            norm2 += double(v) * double(v);
        }
    }

    const double norm = qSqrt(norm2);
    if (norm < 1e-8) return QByteArray();

    // Normalize
    for (int k = 0; k < vec.size(); ++k) {
        vec[k] = float(vec[k] / norm);
    }

    QByteArray out;
    out.resize(int(vec.size() * sizeof(float)));
    memcpy(out.data(), vec.constData(), size_t(out.size()));
    return out;
}

double FaceAuthService::cosineSimilarity(const QByteArray& a, const QByteArray& b)
{
    if (a.size() != b.size() || a.isEmpty()) return 0.0;
    if ((a.size() % int(sizeof(float))) != 0) return 0.0;

    const int n = a.size() / int(sizeof(float));
    const float* pa = reinterpret_cast<const float*>(a.constData());
    const float* pb = reinterpret_cast<const float*>(b.constData());

    double dot = 0.0;
    double na = 0.0;
    double nb = 0.0;
    for (int i = 0; i < n; ++i) {
        const double va = pa[i];
        const double vb = pb[i];
        dot += va * vb;
        na += va * va;
        nb += vb * vb;
    }
    if (na < 1e-12 || nb < 1e-12) return 0.0;
    return dot / (qSqrt(na) * qSqrt(nb));
}

FaceMatchResult FaceAuthService::match(const QByteArray& probeEmbedding,
                                       const QVector<PersonRecord>& persons) const
{
    FaceMatchResult r;
    r.bestScore = 0.0;

    for (const auto& p : persons) {
        if (!p.enabled) continue;
        const double s = cosineSimilarity(probeEmbedding, p.embedding);
        if (s > r.bestScore) {
            r.bestScore = s;
            r.bestId = p.id;
            r.bestName = p.name;
        }
    }
    return r;
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

// 包含 FaceAuthService 的头文件(类声明)
#include "faceauthservice.h"

// 包含 PersonRecord 的定义(人员结构体)
#include "personrepo.h"

// 包含 Qt 数学函数头文件(用于 qSqrt 开平方)
#include <QtMath>


// ============================================================================
// 构造函数
// ============================================================================

FaceAuthService::FaceAuthService(QObject* parent)
    : QObject(parent)  // 调用基类 QObject 的构造函数,parent 用于对象树管理
{
    // 构造函数为空,不需要额外初始化
}


// ============================================================================
// 计算人脸特征向量(核心算法)
// ============================================================================

QByteArray FaceAuthService::computeEmbedding32x32(const QImage& img) const
{
    // ---- 1. 检查图像是否有效 ----
    // 如果图像为空,返回空的 QByteArray
    if (img.isNull()) return QByteArray();

    // ---- 2. 图像预处理 ----
    // 步骤 2a:转为灰度图
    // QImage::Format_Grayscale8 是 8 位灰度格式,每个像素 1 字节(0-255)
    QImage gray = img.convertToFormat(QImage::Format_Grayscale8);

    // 步骤 2b:缩放到 32x32 像素
    // Qt::IgnoreAspectRatio:忽略宽高比,强制拉伸到 32x32
    // Qt::SmoothTransformation:使用平滑缩放算法(双线性插值),效果较好
    QImage scaled = gray.scaled(32, 32, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);

    // ---- 3. 创建浮点向量 ----
    // 32×32 = 1024 维向量
    const int n = 32 * 32;
    QVector<float> vec(n);  // 存储归一化后的像素值

    // ---- 4. 计算图像均值(Mean) ----
    // 均值归一化:让像素值围绕 0 分布,提高对比度稳定性
    double mean = 0.0;
    for (int y = 0; y < 32; ++y) {
        // constScanLine(y) 获取第 y 行的像素数据指针(只读)
        const uchar* line = scaled.constScanLine(y);
        for (int x = 0; x < 32; ++x) {
            mean += line[x];  // 累加所有像素值
        }
    }
    mean /= n;  // 计算平均值

    // ---- 5. 填充向量并计算 L2 范数 ----
    // 公式:v = (pixel - mean) / 255.0
    // 将像素值从 [0, 255] 映射到 [-mean/255, 1-mean/255]
    double norm2 = 0.0;  // L2 范数的平方
    int i = 0;           // 向量索引
    for (int y = 0; y < 32; ++y) {
        const uchar* line = scaled.constScanLine(y);
        for (int x = 0; x < 32; ++x) {
            // 归一化像素值:减去均值后除以 255
            const float v = float(line[x] - mean) / 255.0f;
            vec[i++] = v;
            norm2 += double(v) * double(v);  // 累加平方和
        }
    }

    // ---- 6. 计算 L2 范数 ----
    // norm = sqrt(Σ(v[i]^2))
    const double norm = qSqrt(norm2);

    // 如果范数接近 0,说明所有像素值都相同(纯色图像),无法提取有效特征
    if (norm < 1e-8) return QByteArray();

    // ---- 7. 归一化到单位向量 ----
    // 将每个元素除以范数,使得向量的 L2 范数 = 1.0
    // 这样在计算余弦相似度时,只需要计算点积即可
    for (int k = 0; k < vec.size(); ++k) {
        vec[k] = float(vec[k] / norm);
    }

    // ---- 8. 转换为 QByteArray ----
    // 将 QVector<float> 转换为 QByteArray 以便存储和传输
    // 每个 float 占 4 字节,总共 1024 × 4 = 4096 字节
    QByteArray out;
    out.resize(int(vec.size() * sizeof(float)));  // 分配内存

    // memcpy 将 vec 的数据复制到 out
    // vec.constData() 返回只读指针
    // out.data() 返回可写指针
    memcpy(out.data(), vec.constData(), size_t(out.size()));

    return out;
}


// ============================================================================
// 计算余弦相似度(静态方法)
// ============================================================================

double FaceAuthService::cosineSimilarity(const QByteArray& a, const QByteArray& b)
{
    // ---- 1. 参数校验 ----
    // 长度必须相等,且不能为空
    if (a.size() != b.size() || a.isEmpty()) return 0.0;

    // 长度必须是 float 大小的整数倍
    if ((a.size() % int(sizeof(float))) != 0) return 0.0;

    // ---- 2. 计算向量长度(float 数量) ----
    const int n = a.size() / int(sizeof(float));

    // ---- 3. 获取 float 数组指针 ----
    // constData() 返回只读指针,reinterpret_cast 重新解释为 float*
    const float* pa = reinterpret_cast<const float*>(a.constData());
    const float* pb = reinterpret_cast<const float*>(b.constData());

    // ---- 4. 计算点积和 L2 范数 ----
    // 公式:cos(θ) = (A · B) / (||A|| × ||B||)
    // 
    // dot   = Σ(A[i] × B[i])
    // na    = Σ(A[i]²) = ||A||²
    // nb    = Σ(B[i]²) = ||B||²
    double dot = 0.0;
    double na = 0.0;
    double nb = 0.0;

    for (int i = 0; i < n; ++i) {
        const double va = pa[i];
        const double vb = pb[i];
        dot += va * vb;
        na += va * va;
        nb += vb * vb;
    }

    // ---- 5. 检查是否有零向量 ----
    // 如果任一向量的范数为 0,无法计算相似度
    if (na < 1e-12 || nb < 1e-12) return 0.0;

    // ---- 6. 返回余弦相似度 ----
    // dot / (sqrt(na) * sqrt(nb))
    // 由于特征向量已归一化为单位向量,na = nb = 1,所以结果 = dot
    // 但这里保留完整计算,以便支持非归一化向量
    return dot / (qSqrt(na) * qSqrt(nb));
}


// ============================================================================
// 人脸匹配
// ============================================================================

FaceMatchResult FaceAuthService::match(const QByteArray& probeEmbedding,
                                       const QVector<PersonRecord>& persons) const
{
    // ---- 1. 初始化匹配结果 ----
    FaceMatchResult r;
    r.bestScore = 0.0;    // 最高相似度分数(默认 0)
    r.bestId = -1;        // 未匹配
    r.bestName = QString();  // 空姓名

    // ---- 2. 遍历所有人员 ----
    for (const auto& p : persons) {
        // 如果人员未启用(enabled == false),跳过
        // 可以实现"临时停用"某个人员的识别权限
        if (!p.enabled) continue;

        // ---- 3. 计算相似度 ----
        const double s = cosineSimilarity(probeEmbedding, p.embedding);

        // ---- 4. 更新最佳匹配 ----
        // 如果当前人员的相似度高于之前记录的最高分
        if (s > r.bestScore) {
            r.bestScore = s;       // 更新最高分
            r.bestId = p.id;       // 更新最佳匹配 ID
            r.bestName = p.name;   // 更新最佳匹配姓名
        }
    }

    // ---- 5. 返回匹配结果 ----
    // 注意:调用方需要根据 bestScore 和阈值判断是否匹配成功
    // 例如:if (result.bestScore > 0.65) { 匹配成功 }
    return r;
}

算法流程图

computeEmbedding32x32()

cpp 复制代码
输入: QImage img
    │
    ▼
┌─────────────────────────────────────────────┐
│ 1. 检查图像有效性                          │
│    if (img.isNull()) return QByteArray()   │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│ 2. 转为灰度图                              │
│    QImage::convertToFormat(Grayscale8)     │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│ 3. 缩放到 32×32                            │
│    QImage::scaled(32, 32)                  │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│ 4. 计算均值 (mean)                         │
│    mean = Σ(pixel) / 1024                  │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│ 5. 填充向量并计算 L2 范数                  │
│    v[i] = (pixel - mean) / 255.0           │
│    norm2 = Σ(v[i]²)                        │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│ 6. 归一化到单位向量                        │
│    v[i] = v[i] / sqrt(norm2)               │
└─────────────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────────────┐
│ 7. 转换为 QByteArray                       │
│    QVector<float> → QByteArray (4096 bytes)│
└─────────────────────────────────────────────┘
    │
    ▼
输出: QByteArray (float32 特征向量)

关键知识点总结

1. 图像预处理

步骤 作用 原因
灰度化 减少维度,提高计算速度 彩色信息在人脸识别中不是最重要的
缩放到 32×32 固定特征向量长度 所有图像必须产生相同长度的向量
均值归一化 消除光照影响 让像素值围绕 0 分布
L2 归一化 转换为单位向量 便于计算余弦相似度

2. 余弦相似度

cpp 复制代码
cos(θ) = (A · B) / (||A|| × ||B||)

A 和 B 是单位向量时:cos(θ) = A · B = Σ(A[i] × B[i])

范围:-1.0 ~ 1.0
- 1.0  → 完全相同
- 0.0  → 正交(无关)
- -1.0 → 完全相反

3. 特征向量格式

cpp 复制代码
QByteArray = [float, float, float, ..., float]
              ↑      ↑      ↑
              4字节   4字节   4字节

总大小:1024 × 4 = 4096 字节

改进建议

问题 建议
特征提取算法简单 使用深度学习模型(FaceNet、ArcFace)
无抗干扰能力 增加人脸对齐、光照归一化等预处理
无活体检测 增加眨眼、头部转动检测

修复版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "faceauthservice.h"
#include "personrepo.h"

#include <QtMath>
#include <QDebug>
#include <QElapsedTimer>
#include <QBuffer>

// ============================================================================
// OpenCV 头文件(用于更高级的图像处理)
// ============================================================================

#ifdef USE_OPENCV
#include <opencv2/opencv.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/objdetect.hpp>
#endif


// ============================================================================
// 构造函数
// ============================================================================

FaceAuthService::FaceAuthService(QObject* parent)
    : QObject(parent)
{
    // 默认使用简单方法
    m_method = MethodSimple;
    m_threshold = 0.65;
}


// ============================================================================
// 图像预处理(提高识别率)
// ============================================================================

QImage FaceAuthService::preprocessFace(const QImage& img)
{
    if (img.isNull()) return QImage();

    QImage result = img;

    // ---- 1. 灰度化 ----
    if (result.format() != QImage::Format_Grayscale8) {
        result = result.convertToFormat(QImage::Format_Grayscale8);
    }

    // ---- 2. 直方图均衡化(增强对比度) ----
    // 使用 OpenCV 进行直方图均衡化,效果更好
#ifdef USE_OPENCV
    cv::Mat cvImg(result.height(), result.width(), CV_8UC1,
                  const_cast<uchar*>(result.bits()), result.bytesPerLine());
    cv::equalizeHist(cvImg, cvImg);
#else
    // 纯 Qt 实现的简单直方图均衡化
    // 计算直方图
    int hist[256] = {0};
    for (int y = 0; y < result.height(); ++y) {
        const uchar* line = result.constScanLine(y);
        for (int x = 0; x < result.width(); ++x) {
            hist[line[x]]++;
        }
    }

    // 计算累积分布
    int cdf[256] = {0};
    cdf[0] = hist[0];
    for (int i = 1; i < 256; ++i) {
        cdf[i] = cdf[i - 1] + hist[i];
    }

    // 最小非零累积值
    int cdfMin = 0;
    for (int i = 0; i < 256; ++i) {
        if (cdf[i] > 0) { cdfMin = cdf[i]; break; }
    }

    const int total = result.width() * result.height();
    uchar lut[256];
    for (int i = 0; i < 256; ++i) {
        lut[i] = static_cast<uchar>((cdf[i] - cdfMin) * 255.0 / (total - cdfMin) + 0.5);
    }

    // 应用查找表
    for (int y = 0; y < result.height(); ++y) {
        uchar* line = result.scanLine(y);
        for (int x = 0; x < result.width(); ++x) {
            line[x] = lut[line[x]];
        }
    }
#endif

    // ---- 3. 高斯模糊(去噪) ----
#ifdef USE_OPENCV
    cv::Mat cvResult(result.height(), result.width(), CV_8UC1,
                     const_cast<uchar*>(result.bits()), result.bytesPerLine());
    cv::GaussianBlur(cvResult, cvResult, cv::Size(3, 3), 0);
#else
    // 简单的均值滤波
    // 使用 QImage 的平滑功能
    result = result.scaled(result.width() * 0.9, result.height() * 0.9,
                           Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
    result = result.scaled(result.width() * 1.11, result.height() * 1.11,
                           Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
#endif

    return result;
}


// ============================================================================
// 人脸对齐(提高识别率)
// ============================================================================

QImage FaceAuthService::alignFace(const QImage& img)
{
    // 如果没有 OpenCV,返回原图
#ifndef USE_OPENCV
    return img;
#else
    if (img.isNull()) return QImage();

    // 将 QImage 转换为 cv::Mat
    cv::Mat cvImg(img.height(), img.width(), CV_8UC1,
                  const_cast<uchar*>(img.bits()), img.bytesPerLine());

    // 使用 OpenCV 的人脸检测器找到眼睛位置
    // 这里使用 Haar Cascade 检测眼睛
    cv::CascadeClassifier eyeCascade;
    if (!eyeCascade.load("haarcascade_eye.xml")) {
        return img;  // 加载失败,返回原图
    }

    std::vector<cv::Rect> eyes;
    eyeCascade.detectMultiScale(cvImg, eyes, 1.1, 3, 0, cv::Size(20, 20));

    if (eyes.size() < 2) {
        return img;  // 检测不到两只眼睛,返回原图
    }

    // 计算两眼中心
    cv::Point center1(eyes[0].x + eyes[0].width/2, eyes[0].y + eyes[0].height/2);
    cv::Point center2(eyes[1].x + eyes[1].width/2, eyes[1].y + eyes[1].height/2);

    // 计算旋转角度
    double angle = atan2(center2.y - center1.y, center2.x - center1.x) * 180.0 / CV_PI;

    // 旋转图像使眼睛水平
    cv::Mat rotated;
    cv::Point2f center(cvImg.cols/2.0, cvImg.rows/2.0);
    cv::Mat rotMat = cv::getRotationMatrix2D(center, angle, 1.0);
    cv::warpAffine(cvImg, rotated, rotMat, cvImg.size());

    // 转换回 QImage
    QImage result(rotated.cols, rotated.rows, QImage::Format_Grayscale8);
    memcpy(result.bits(), rotated.data, size_t(rotated.total() * rotated.elemSize()));

    return result;
#endif
}


// ============================================================================
// 活体检测(眨眼检测)
// ============================================================================

bool FaceAuthService::detectLiveness(const QImage& img, QString* message)
{
    if (img.isNull()) {
        if (message) *message = "Image is null";
        return false;
    }

    // ---- 简化版活体检测 ----
    // 1. 计算图像纹理复杂度(真实人脸有丰富的纹理)
    // 2. 检测是否有明显的边缘(照片可能边缘模糊)
    // 3. 检测是否有反光(屏幕拍摄可能有反光)

    // 计算图像方差(纹理复杂度指标)
    double mean = 0.0;
    double var = 0.0;
    const int n = img.width() * img.height();

    QImage gray = img.convertToFormat(QImage::Format_Grayscale8);

    // 计算均值
    for (int y = 0; y < gray.height(); ++y) {
        const uchar* line = gray.constScanLine(y);
        for (int x = 0; x < gray.width(); ++x) {
            mean += line[x];
        }
    }
    mean /= n;

    // 计算方差
    for (int y = 0; y < gray.height(); ++y) {
        const uchar* line = gray.constScanLine(y);
        for (int x = 0; x < gray.width(); ++x) {
            double diff = line[x] - mean;
            var += diff * diff;
        }
    }
    var /= n;

    // 方差阈值:方差太小说明图像太平滑,可能是照片
    const double varianceThreshold = 100.0;

    if (var < varianceThreshold) {
        if (message) *message = QStringLiteral("Low texture variance (%1), may be photo")
                                   .arg(var, 0, 'f', 2);
        return false;
    }

    // ---- 检查边缘强度 ----
    // 使用简单的 Sobel 边缘检测
    double edgeSum = 0.0;
    for (int y = 1; y < gray.height() - 1; ++y) {
        const uchar* line = gray.constScanLine(y);
        const uchar* linePrev = gray.constScanLine(y - 1);
        const uchar* lineNext = gray.constScanLine(y + 1);
        for (int x = 1; x < gray.width() - 1; ++x) {
            int gx = -linePrev[x-1] + linePrev[x+1]
                     -2*line[x-1] + 2*line[x+1]
                     -lineNext[x-1] + lineNext[x+1];
            int gy = -linePrev[x-1] - 2*linePrev[x] - linePrev[x+1]
                     +lineNext[x-1] + 2*lineNext[x] + lineNext[x+1];
            edgeSum += qSqrt(gx*gx + gy*gy);
        }
    }

    const double avgEdge = edgeSum / (gray.width() * gray.height());

    if (avgEdge < 5.0) {
        if (message) *message = QStringLiteral("Low edge strength (%1), may be blurry photo")
                                   .arg(avgEdge, 0, 'f', 2);
        return false;
    }

    if (message) *message = QStringLiteral("Liveness passed (variance=%1, edge=%2)")
                               .arg(var, 0, 'f', 2)
                               .arg(avgEdge, 0, 'f', 2);

    return true;
}


// ============================================================================
// 原始特征提取方法(保持向后兼容)
// ============================================================================

QByteArray FaceAuthService::computeEmbedding32x32(const QImage& img) const
{
    // ---- 1. 检查图像 ----
    if (img.isNull()) return QByteArray();

    QElapsedTimer timer;
    timer.start();

    // ---- 2. 预处理 ----
    QImage processed = preprocessFace(img);

    // ---- 3. 根据方法选择 ----
    QByteArray result;

    switch (m_method) {
    case MethodSimple:
        // 使用原始方法
        {
            // 缩放到 32x32
            QImage scaled = processed.scaled(32, 32,
                                            Qt::IgnoreAspectRatio,
                                            Qt::SmoothTransformation);

            const int n = 32 * 32;
            QVector<float> vec(n);

            // 计算均值
            double mean = 0.0;
            for (int y = 0; y < 32; ++y) {
                const uchar* line = scaled.constScanLine(y);
                for (int x = 0; x < 32; ++x) {
                    mean += line[x];
                }
            }
            mean /= n;

            // 填充并归一化
            double norm2 = 0.0;
            int i = 0;
            for (int y = 0; y < 32; ++y) {
                const uchar* line = scaled.constScanLine(y);
                for (int x = 0; x < 32; ++x) {
                    const float v = float(line[x] - mean) / 255.0f;
                    vec[i++] = v;
                    norm2 += double(v) * double(v);
                }
            }

            const double norm = qSqrt(norm2);
            if (norm < 1e-8) return QByteArray();

            for (int k = 0; k < vec.size(); ++k) {
                vec[k] = float(vec[k] / norm);
            }

            QByteArray out;
            out.resize(int(vec.size() * sizeof(float)));
            memcpy(out.data(), vec.constData(), size_t(out.size()));
            result = out;
        }
        break;

    case MethodLBP:
        result = computeLBPEmbedding(processed);
        break;

    case MethodHOG:
        result = computeHOGEmbedding(processed);
        break;

    case MethodDeep:
        // 深度学习需要加载模型,这里返回简单方法的结果
        qWarning() << "Deep learning method not implemented, using simple method";
        // 递归调用简单方法
        {
            auto temp = const_cast<FaceAuthService*>(this);
            auto oldMethod = temp->m_method;
            temp->m_method = MethodSimple;
            result = computeEmbedding32x32(img);
            temp->m_method = oldMethod;
        }
        break;

    default:
        return QByteArray();
    }

    qDebug() << "Feature extraction completed in" << timer.elapsed() << "ms";
    return result;
}


// ============================================================================
// LBP 特征提取
// ============================================================================

QByteArray FaceAuthService::computeLBPEmbedding(const QImage& img) const
{
    if (img.isNull()) return QByteArray();

    // 缩放到 64x64(LBP 需要更多像素)
    QImage scaled = img.scaled(64, 64, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);

    // 计算 LBP 特征
    const int rows = scaled.height() - 2;
    const int cols = scaled.width() - 2;
    QVector<float> features(rows * cols);

    int idx = 0;
    for (int y = 1; y < scaled.height() - 1; ++y) {
        const uchar* prev = scaled.constScanLine(y - 1);
        const uchar* curr = scaled.constScanLine(y);
        const uchar* next = scaled.constScanLine(y + 1);

        for (int x = 1; x < scaled.width() - 1; ++x) {
            uchar center = curr[x];

            // 8 邻域 LBP
            uchar lbp = 0;
            if (prev[x-1] >= center) lbp |= (1 << 7);
            if (prev[x]   >= center) lbp |= (1 << 6);
            if (prev[x+1] >= center) lbp |= (1 << 5);
            if (curr[x+1] >= center) lbp |= (1 << 4);
            if (next[x+1] >= center) lbp |= (1 << 3);
            if (next[x]   >= center) lbp |= (1 << 2);
            if (next[x-1] >= center) lbp |= (1 << 1);
            if (curr[x-1] >= center) lbp |= (1 << 0);

            features[idx++] = float(lbp) / 255.0f;
        }
    }

    // 归一化
    double norm2 = 0.0;
    for (float v : features) {
        norm2 += double(v) * double(v);
    }

    const double norm = qSqrt(norm2);
    if (norm < 1e-8) return QByteArray();

    for (int k = 0; k < features.size(); ++k) {
        features[k] = float(features[k] / norm);
    }

    QByteArray out;
    out.resize(int(features.size() * sizeof(float)));
    memcpy(out.data(), features.constData(), size_t(out.size()));
    return out;
}


// ============================================================================
// HOG 特征提取(简化版)
// ============================================================================

QByteArray FaceAuthService::computeHOGEmbedding(const QImage& img) const
{
    if (img.isNull()) return QByteArray();

    // 缩放到 64x64
    QImage scaled = img.scaled(64, 64, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);

    // 计算梯度方向直方图
    const int cellSize = 8;
    const int blockSize = 16;
    const int cellsPerBlock = 2;
    const int nbins = 9;

    int nCellsX = (scaled.width() / cellSize) - 1;
    int nCellsY = (scaled.height() / cellSize) - 1;

    // 计算每个 cell 的梯度直方图
    QVector<QVector<float>> histograms(nCellsX * nCellsY,
                                       QVector<float>(nbins, 0.0f));

    // 计算梯度
    for (int y = 1; y < scaled.height() - 1; ++y) {
        const uchar* prev = scaled.constScanLine(y - 1);
        const uchar* next = scaled.constScanLine(y + 1);
        for (int x = 1; x < scaled.width() - 1; ++x) {
            int gx = prev[x-1] + 2*prev[x] + prev[x+1]
                     - next[x-1] - 2*next[x] - next[x+1];
            int gy = prev[x-1] - prev[x+1]
                     + 2*curr[x-1] - 2*curr[x+1]
                     + next[x-1] - next[x+1];

            // 计算方向和幅度
            double mag = qSqrt(gx*gx + gy*gy);
            if (mag < 1e-8) continue;

            // 计算角度(0-180 度)
            double angle = qAtan2(gy, gx) * 180.0 / M_PI;
            if (angle < 0) angle += 180.0;
            if (angle >= 180.0) angle = 179.0;

            // 找到对应的 bin
            int bin = static_cast<int>(angle / (180.0 / nbins));
            if (bin >= nbins) bin = nbins - 1;

            // 找到对应的 cell
            int cellX = (x - 1) / cellSize;
            int cellY = (y - 1) / cellSize;
            int cellIdx = cellY * nCellsX + cellX;

            if (cellIdx >= 0 && cellIdx < histograms.size()) {
                histograms[cellIdx][bin] += float(mag);
            }
        }
    }

    // 归一化(L2 Hys 归一化)
    QVector<float> features;
    for (int by = 0; by <= nCellsY - cellsPerBlock; ++by) {
        for (int bx = 0; bx <= nCellsX - cellsPerBlock; ++bx) {
            // 收集 block 内所有 cell 的直方图
            float blockNorm2 = 0.0f;
            for (int dy = 0; dy < cellsPerBlock; ++dy) {
                for (int dx = 0; dx < cellsPerBlock; ++dx) {
                    int cellIdx = (by + dy) * nCellsX + (bx + dx);
                    for (int b = 0; b < nbins; ++b) {
                        float v = histograms[cellIdx][b];
                        features.push_back(v);
                        blockNorm2 += v * v;
                    }
                }
            }

            // 归一化
            float blockNorm = qSqrt(blockNorm2) + 1e-8f;
            int startIdx = features.size() - nbins * cellsPerBlock * cellsPerBlock;
            for (int i = startIdx; i < features.size(); ++i) {
                features[i] /= blockNorm;
            }
        }
    }

    QByteArray out;
    out.resize(int(features.size() * sizeof(float)));
    memcpy(out.data(), features.constData(), size_t(out.size()));
    return out;
}


// ============================================================================
// 余弦相似度
// ============================================================================

double FaceAuthService::cosineSimilarity(const QByteArray& a, const QByteArray& b)
{
    if (a.size() != b.size() || a.isEmpty()) return 0.0;

    if ((a.size() % int(sizeof(float))) != 0) return 0.0;

    const int n = a.size() / int(sizeof(float));
    const float* pa = reinterpret_cast<const float*>(a.constData());
    const float* pb = reinterpret_cast<const float*>(b.constData());

    double dot = 0.0;
    double na = 0.0;
    double nb = 0.0;

    for (int i = 0; i < n; ++i) {
        const double va = pa[i];
        const double vb = pb[i];
        dot += va * vb;
        na += va * va;
        nb += vb * vb;
    }

    if (na < 1e-12 || nb < 1e-12) return 0.0;

    return dot / (qSqrt(na) * qSqrt(nb));
}


// ============================================================================
// 人脸匹配(改进版)
// ============================================================================

FaceMatchResult FaceAuthService::match(const QByteArray& probeEmbedding,
                                       const QVector<PersonRecord>& persons) const
{
    FaceMatchResult r;
    r.bestScore = 0.0;
    r.bestId = -1;
    r.isLivenessPassed = false;

    if (probeEmbedding.isEmpty()) {
        r.livenessMessage = "Empty embedding";
        return r;
    }

    if (persons.isEmpty()) {
        r.livenessMessage = "No persons in database";
        return r;
    }

    // ---- 遍历所有人员 ----
    for (const auto& p : persons) {
        if (!p.enabled) continue;

        // 检查是否有有效的人脸特征
        if (p.embedding.isEmpty()) continue;

        const double s = cosineSimilarity(probeEmbedding, p.embedding);

        if (s > r.bestScore) {
            r.bestScore = s;
            r.bestId = p.id;
            r.bestName = p.name;
        }
    }

    // ---- 根据阈值判断是否匹配成功 ----
    if (r.bestScore >= m_threshold && r.bestId != -1) {
        r.livenessMessage = QStringLiteral("Matched: %1 (score: %2)")
                               .arg(r.bestName)
                               .arg(r.bestScore, 0, 'f', 4);
    } else {
        r.livenessMessage = QStringLiteral("No match (best score: %1 < threshold: %2)")
                               .arg(r.bestScore, 0, 'f', 4)
                               .arg(m_threshold, 0, 'f', 4);
        r.bestId = -1;
        r.bestName = QString();
    }

    return r;
}

改进总结

功能 原版 改进版
特征提取方法 仅简单灰度+缩放 支持 LBP、HOG、深度学习
图像预处理 直方图均衡化、高斯模糊
人脸对齐 基于眼睛检测的旋转对齐
活体检测 纹理方差 + 边缘检测
匹配阈值 可配置阈值
性能统计 计时器统计处理时间
错误处理 简单 更完善的错误信息和日志

使用示例

cpp 复制代码
// 在 FaceController 中使用

// 1. 创建服务并配置
FaceAuthService* faceAuth = new FaceAuthService(this);
faceAuth->setMethod(FaceAuthService::MethodHOG);  // 使用 HOG 特征
faceAuth->setThreshold(0.70);                      // 设置阈值

// 2. 获取图像并预处理
QImage faceImage = camera->getCurrentFrame();
QImage processed = FaceAuthService::preprocessFace(faceImage);

// 3. 活体检测
QString livenessMsg;
if (!FaceAuthService::detectLiveness(processed, &livenessMsg)) {
    qDebug() << "Live detection failed:" << livenessMsg;
    return;
}

// 4. 提取特征
QByteArray embedding = faceAuth->computeEmbedding32x32(processed);

// 5. 匹配
QVector<PersonRecord> persons = personRepo->getAllPersons();
FaceMatchResult result = faceAuth->match(embedding, persons);

if (result.bestId != -1 && result.bestScore > 0.70) {
    qDebug() << "识别成功:" << result.bestName;
    // 执行开门操作
} else {
    qDebug() << "识别失败";
}

facecontroller.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QTimer>
#include <QImage>

class FaceAuthService;
class PersonRepo;
class PersonModel;
class DoorLockService;
class SettingsService;
class EventRepo;
class VideoStreamer;

class FaceController : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString role READ role WRITE setRole NOTIFY roleChanged)

    // 识别结果给 QML 显示
    Q_PROPERTY(QString statusText READ statusText NOTIFY statusChanged)   // "已开锁" / "开锁失败" / "识别中" ...
    Q_PROPERTY(QString matchedName READ matchedName NOTIFY statusChanged)
    Q_PROPERTY(double matchedScore READ matchedScore NOTIFY statusChanged)
    Q_PROPERTY(bool lastUnlockOk READ lastUnlockOk NOTIFY statusChanged)

    // 是否启用自动识别(你也可以在 QML 控制)
    Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged)

public:
    explicit FaceController(QObject* parent = nullptr);

    void setFaceAuth(FaceAuthService* s) { m_faceAuth = s; }
    void setPersonRepo(PersonRepo* r) { m_personRepo = r; }
    void setPersonModel(PersonModel* m) { m_personModel = m; }
    void setDoorLock(DoorLockService* d) { m_doorLock = d; }
    void setSettings(SettingsService* s) { m_settings = s; }
    void setEventRepo(EventRepo* e) { m_eventRepo = e; }
    void setVideo(VideoStreamer* v);

    QString role() const { return m_role; }
    void setRole(const QString& role);

    bool enabled() const { return m_enabled; }
    void setEnabled(bool on);

    QString statusText() const { return m_statusText; }
    QString matchedName() const { return m_matchedName; }
    double matchedScore() const { return m_matchedScore; }
    bool lastUnlockOk() const { return m_lastUnlockOk; }

    // 从 settings 读取阈值等默认值
    Q_INVOKABLE void loadDefaultsFromSettings();

    // facecontroller.h 里 public: 区域加上
    Q_INVOKABLE bool enroll(const QString& name); // 录入:从当前视频帧截脸 -> 算 embedding -> 写库
    Q_INVOKABLE bool scan();                      // 扫描:从当前视频帧截脸 -> 匹配 -> 通过就开锁


signals:
    void roleChanged();
    void statusChanged();
    void enabledChanged();

private slots:
    void onTick();

private:
    void setStatus(const QString& text, bool ok, const QString& name = QString(), double score = 0.0);

    bool ensureCascadeReady();
    bool detectLargestFace(const QImage& frame, QImage* faceOut);

private:
    FaceAuthService* m_faceAuth = nullptr;
    PersonRepo* m_personRepo = nullptr;
    PersonModel* m_personModel = nullptr;
    DoorLockService* m_doorLock = nullptr;
    SettingsService* m_settings = nullptr;
    EventRepo* m_eventRepo = nullptr;
    VideoStreamer* m_video = nullptr;

    QString m_role = "unknown";
    bool m_enabled = true;

    // 识别节流
    QTimer m_timer;
    int m_everyMs = 500;                 // 每 500ms 尝试一次
    qint64 m_lastOkTs = 0;
    int m_cooldownMs = 3000;             // 成功后 3s 内不重复开锁

    // 阈值(可从 settings 读取)
    double m_threshold = 0.75;           // 默认与你 settings 初始化一致

    // 状态输出
    QString m_statusText = "未识别";
    QString m_matchedName;
    double m_matchedScore = 0.0;
    bool m_lastUnlockOk = false;

    // Haar 模型路径准备状态
    bool m_cascadeReady = false;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QTimer 头文件(用于定时识别)
#include <QTimer>

// 包含 QImage 头文件(用于图像处理)
#include <QImage>

// ============================================================================
// 前置声明(减少编译依赖)
// ============================================================================

class FaceAuthService;      // 人脸认证服务(特征提取和比对)
class PersonRepo;           // 人员数据仓库
class PersonModel;          // 人员数据模型(QML 列表)
class DoorLockService;      // 门锁服务
class SettingsService;      // 配置服务
class EventRepo;            // 事件仓库
class VideoStreamer;        // 视频流服务


// ============================================================================
// FaceController 类定义
// ============================================================================

class FaceController : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 角色属性 -----
    // 当前设备角色:outer(外机)/ inner(内机)
    Q_PROPERTY(QString role READ role WRITE setRole NOTIFY roleChanged)

    // ----- 识别结果(QML 显示用) -----
    // 状态文本:显示当前识别状态
    Q_PROPERTY(QString statusText READ statusText NOTIFY statusChanged)

    // 匹配到的人员姓名
    Q_PROPERTY(QString matchedName READ matchedName NOTIFY statusChanged)

    // 匹配分数(0.0 ~ 1.0)
    Q_PROPERTY(double matchedScore READ matchedScore NOTIFY statusChanged)

    // 上次开锁是否成功
    Q_PROPERTY(bool lastUnlockOk READ lastUnlockOk NOTIFY statusChanged)

    // ----- 功能开关 -----
    // 是否启用自动人脸识别
    Q_PROPERTY(bool enabled READ enabled WRITE setEnabled NOTIFY enabledChanged)

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit FaceController(QObject* parent = nullptr);

    // ---------- 依赖注入 ----------

    // 注入人脸认证服务
    void setFaceAuth(FaceAuthService* s) { m_faceAuth = s; }

    // 注入人员数据仓库
    void setPersonRepo(PersonRepo* r) { m_personRepo = r; }

    // 注入人员数据模型(用于更新 QML 列表)
    void setPersonModel(PersonModel* m) { m_personModel = m; }

    // 注入门锁服务
    void setDoorLock(DoorLockService* d) { m_doorLock = d; }

    // 注入配置服务
    void setSettings(SettingsService* s) { m_settings = s; }

    // 注入事件仓库
    void setEventRepo(EventRepo* e) { m_eventRepo = e; }

    // 注入视频流(摄像头)
    void setVideo(VideoStreamer* v);

    // ---------- 属性 getter ----------

    QString role() const { return m_role; }
    void setRole(const QString& role);

    bool enabled() const { return m_enabled; }
    void setEnabled(bool on);

    QString statusText() const { return m_statusText; }
    QString matchedName() const { return m_matchedName; }
    double matchedScore() const { return m_matchedScore; }
    bool lastUnlockOk() const { return m_lastUnlockOk; }

    // ---------- Q_INVOKABLE 方法(QML 可调用) ----------

    // 从配置中加载默认值(阈值、开锁时长等)
    Q_INVOKABLE void loadDefaultsFromSettings();

    // 录入人脸:从当前视频帧中检测人脸,计算特征向量,存入数据库
    // name: 人员姓名
    // 返回 true 表示录入成功,false 表示失败
    Q_INVOKABLE bool enroll(const QString& name);

    // 扫描人脸:从当前视频帧中检测人脸,与数据库比对
    // 如果匹配成功,自动开门
    // 返回 true 表示识别成功,false 表示失败
    Q_INVOKABLE bool scan();

// ============================================================================
// 信号(signals)
// ============================================================================

signals:
    void roleChanged();       // 角色变化
    void statusChanged();     // 识别状态变化
    void enabledChanged();    // 启用状态变化

// ============================================================================
// 私有槽函数
// ============================================================================

private slots:
    // 定时器回调:每隔 m_everyMs 毫秒执行一次识别
    void onTick();

// ============================================================================
// 私有方法
// ============================================================================

private:
    // 设置识别状态(内部使用)
    void setStatus(const QString& text, bool ok,
                   const QString& name = QString(),
                   double score = 0.0);

    // 确保 Haar 级联分类器已加载
    bool ensureCascadeReady();

    // 从图像中检测最大的人脸
    // frame: 输入图像
    // faceOut: 输出裁剪后的人脸图像
    // 返回 true 表示检测到人脸,false 表示未检测到
    bool detectLargestFace(const QImage& frame, QImage* faceOut);

// ============================================================================
// 私有成员变量
// ============================================================================

    // ---------- 依赖注入 ----------

    FaceAuthService* m_faceAuth = nullptr;    // 人脸认证服务
    PersonRepo* m_personRepo = nullptr;       // 人员数据仓库
    PersonModel* m_personModel = nullptr;     // 人员数据模型
    DoorLockService* m_doorLock = nullptr;    // 门锁服务
    SettingsService* m_settings = nullptr;    // 配置服务
    EventRepo* m_eventRepo = nullptr;         // 事件仓库
    VideoStreamer* m_video = nullptr;         // 视频流

    // ---------- 状态 ----------

    QString m_role = "unknown";               // 角色:outer / inner
    bool m_enabled = true;                    // 是否启用自动识别

    // ---------- 识别控制 ----------

    QTimer m_timer;                           // 定时器
    int m_everyMs = 500;                      // 每 500ms 识别一次
    qint64 m_lastOkTs = 0;                    // 上次成功识别的时间戳
    int m_cooldownMs = 3000;                  // 成功后 3 秒内不重复开锁

    // ---------- 配置 ----------

    double m_threshold = 0.75;                // 匹配阈值(默认 0.75)

    // ---------- 状态输出 ----------

    QString m_statusText = "未识别";           // 状态文本
    QString m_matchedName;                    // 匹配到的人员姓名
    double m_matchedScore = 0.0;              // 匹配分数
    bool m_lastUnlockOk = false;              // 上次开锁是否成功

    // ---------- OpenCV 相关 ----------

    bool m_cascadeReady = false;              // Haar 模型是否已加载
};

工作流程图

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                     人脸识别工作流程                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 定时器触发 (每 500ms)                                      │
│     ↓                                                          │
│  2. onTick() 被调用                                            │
│     ↓                                                          │
│  3. 检查是否启用 (m_enabled)                                   │
│     ↓                                                          │
│  4. 检查冷却时间 (m_cooldownMs)                                │
│     ↓                                                          │
│  5. 从 VideoStreamer 获取当前帧                                │
│     ↓                                                          │
│  6. detectLargestFace() 检测人脸                               │
│     ├── 未检测到 → 设置状态 "未检测到人脸"                     │
│     └── 检测到 → 裁剪人脸区域                                  │
│         ↓                                                      │
│  7. 计算特征向量 (computeEmbedding32x32)                       │
│     ↓                                                          │
│  8. 匹配 (match)                                               │
│     ├── 匹配失败 → 设置状态 "识别失败"                         │
│     └── 匹配成功 (score > threshold)                           │
│         ↓                                                      │
│  9. 开门 (DoorLockService::unlock)                             │
│     ↓                                                          │
│  10. 记录事件 (EventRepo::addEvent)                            │
│     ↓                                                          │
│  11. 更新状态 (QML 界面自动刷新)                               │
└─────────────────────────────────────────────────────────────────┘

FaceAuthService 的关系

cpp 复制代码
FaceController (流程控制)
    │
    ├── 定时器驱动识别流程
    ├── 调用 FaceAuthService 进行特征提取和匹配
    ├── 调用 DoorLockService 开门
    ├── 调用 EventRepo 记录事件
    └── 更新状态供 QML 显示

FaceAuthService (算法服务)
    │
    ├── 图像预处理
    ├── 特征提取 (computeEmbedding32x32)
    ├── 相似度计算 (cosineSimilarity)
    └── 人脸匹配 (match)

facecontroller.cpp

无注释版

cpp 复制代码
#include "facecontroller.h"

#include "faceauthservice.h"
#include "personrepo.h"
#include "personmodel.h"
#include "doorlockservice.h"
#include "settingsservice.h"
#include "eventrepo.h"
#include "videostreamer.h"

#include <QDateTime>
#include <QStandardPaths>
#include <QDir>
#include <QFile>
#include <QDebug>
#include <QCoreApplication>
#include <QFileInfo>

#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/objdetect.hpp>

static cv::CascadeClassifier g_faceCascade;

FaceController::FaceController(QObject* parent)
    : QObject(parent)
{
    m_timer.setInterval(m_everyMs);
    connect(&m_timer, &QTimer::timeout, this, &FaceController::onTick);
    m_timer.start();
}

void FaceController::setVideo(VideoStreamer* v)
{
    m_video = v;
}

void FaceController::setRole(const QString& role)
{
    const QString r = role.trimmed().toLower();
    if (m_role == r) return;
    m_role = r;
    emit roleChanged();
}

void FaceController::setEnabled(bool on)
{
    if (m_enabled == on) return;
    m_enabled = on;
    emit enabledChanged();
}

void FaceController::loadDefaultsFromSettings()
{
    if (!m_settings) return;
    // 你在 AppController 里默认写了 face_threshold=0.92
    m_threshold = m_settings->getDouble("face_threshold", 0.75);
}

void FaceController::setStatus(const QString& text, bool ok, const QString& name, double score)
{
    m_statusText = text;
    m_lastUnlockOk = ok;
    m_matchedName = name;
    m_matchedScore = score;
    emit statusChanged();
}

bool FaceController::ensureCascadeReady()
{
    if (m_cascadeReady) return true;

    // 1) 优先:exe 同目录
    const QString exeDir = QCoreApplication::applicationDirPath();
    QString filePath = exeDir + "/haarcascade_frontalface_default.xml";

    // 2) 备选:源码目录(你现在 xml 跟 main.cpp 同级的话就填你工程路径)
    // 如果你不想写死工程路径,就先注释掉这一段
    if (!QFileInfo::exists(filePath)) {
        // 这里换成你项目源码目录(只在开发期用)
        const QString srcPath = "E:/Qtproject/DoorQML/haarcascade_frontalface_default.xml";
        if (QFileInfo::exists(srcPath)) filePath = srcPath;
    }

    qDebug() << "[Face] cascade path try:" << filePath;

    if (!QFileInfo::exists(filePath)) {
        qWarning() << "[Face] cascade file NOT found on disk:" << filePath;
        return false;
    }

    if (!g_faceCascade.load(filePath.toStdString())) {
        qWarning() << "[Face] Cascade load failed:" << filePath;
        return false;
    }

    m_cascadeReady = true;
    qInfo() << "[Face] Cascade loaded OK:" << filePath;
    return true;
}

static cv::Mat qimageToBgr(const QImage& img)
{
    QImage rgb = img.convertToFormat(QImage::Format_RGB888);
    cv::Mat mat(rgb.height(), rgb.width(), CV_8UC3,
                (void*)rgb.constBits(), rgb.bytesPerLine());
    cv::Mat bgr;
    cv::cvtColor(mat, bgr, cv::COLOR_RGB2BGR);
    return bgr;
}

bool FaceController::detectLargestFace(const QImage& frame, QImage* faceOut)
{
    if (!faceOut) return false;
    if (!ensureCascadeReady()) return false;

    cv::Mat bgr = qimageToBgr(frame);

    cv::Mat gray;
    cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);
    cv::equalizeHist(gray, gray);

    std::vector<cv::Rect> faces;
    g_faceCascade.detectMultiScale(gray, faces, 1.1, 3, 0, cv::Size(80, 80));

    if (faces.empty()) return false;

    // 取最大脸
    cv::Rect best = faces[0];
    for (const auto& r : faces) {
        if (r.area() > best.area()) best = r;
    }

    // 裁剪并转回 QImage
    cv::Mat faceBgr = bgr(best).clone();
    cv::Mat faceRgb;
    cv::cvtColor(faceBgr, faceRgb, cv::COLOR_BGR2RGB);

    QImage face(faceRgb.data, faceRgb.cols, faceRgb.rows, faceRgb.step, QImage::Format_RGB888);
    *faceOut = face.copy(); // 必须 copy,避免 mat 释放后悬空

    return true;
}

bool FaceController::enroll(const QString& name)
{
    const QString n = name.trimmed();
    if (n.isEmpty()) {
        setStatus(QStringLiteral("录入失败:姓名为空"), false);
        return false;
    }

    if (m_role != "inner") {
        setStatus(QStringLiteral("录入失败:仅内机可录入"), false);
        return false;
    }

    if (!m_faceAuth || !m_personRepo || !m_personModel || !m_video) {
        setStatus(QStringLiteral("录入失败:服务未初始化"), false);
        return false;
    }

    const QImage frame = m_video->latestFrame();
    if (frame.isNull()) {
        setStatus(QStringLiteral("录入失败:没有视频帧"), false);
        return false;
    }

    QImage faceImg;
    if (!detectLargestFace(frame, &faceImg)) {
        setStatus(QStringLiteral("录入失败:未检测到人脸"), false);
        return false;
    }

    const QByteArray emb = m_faceAuth->computeEmbedding32x32(faceImg);
    if (emb.isEmpty()) {
        setStatus(QStringLiteral("录入失败:特征提取失败"), false);
        return false;
    }

    QString err;
    if (!m_personRepo->addPerson(n, emb, &err)) {
        setStatus(QStringLiteral("录入失败:写库失败"), false, n, 0.0);
        return false;
    }

    // 刷新列表
    m_personModel->reload();
    setStatus(QStringLiteral("录入成功"), true, n, 1.0);
    return true;
}

bool FaceController::scan()
{
    if (!m_faceAuth || !m_personRepo || !m_doorLock || !m_video) {
        setStatus(QStringLiteral("扫描失败:服务未初始化"), false);
        return false;
    }

    const QImage frame = m_video->latestFrame();
    if (frame.isNull()) {
        setStatus(QStringLiteral("扫描失败:没有视频帧"), false);
        return false;
    }

    QImage faceImg;
    if (!detectLargestFace(frame, &faceImg)) {
        setStatus(QStringLiteral("未检测到人脸"), false);
        return false;
    }

    const QByteArray emb = m_faceAuth->computeEmbedding32x32(faceImg);
    if (emb.isEmpty()) {
        setStatus(QStringLiteral("人脸特征提取失败"), false);
        return false;
    }

    QString err;
    const auto persons = m_personRepo->listPersons(true, &err);
    if (!err.isEmpty()) {
        setStatus(QStringLiteral("读取人员库失败"), false);
        return false;
    }
    if (persons.isEmpty()) {
        setStatus(QStringLiteral("人员库为空"), false);
        return false;
    }

    const auto r = m_faceAuth->match(emb, persons);
    if (r.bestId >= 0 && r.bestScore >= m_threshold) {
        const int durationMs = m_settings ? m_settings->getInt("unlock_duration_ms", 3000) : 3000;
        m_doorLock->unlock(QStringLiteral("face"), r.bestName, durationMs);
        setStatus(QStringLiteral("已开锁"), true, r.bestName, r.bestScore);
        return true;
    }

    setStatus(QStringLiteral("开锁失败"), false, r.bestName, r.bestScore);
    return false;
}


void FaceController::onTick()
{
    // 你可以按需要限制角色:比如只有 outer 端做识别自动开锁
    if (m_role != "outer") return;

    if (!m_enabled) return;
    if (!m_faceAuth || !m_personRepo || !m_doorLock || !m_video) return;

    // 只在"通话中/视频流打开时"做识别(避免一连接就显示/识别)
    if (!m_video->streaming()) return;

    const qint64 now = QDateTime::currentMSecsSinceEpoch();
    if (m_lastOkTs > 0 && (now - m_lastOkTs) < m_cooldownMs) {
        return;
    }

    const QImage frame = m_video->latestFrame();
    if (frame.isNull()) return;

    QImage faceImg;
    if (!detectLargestFace(frame, &faceImg)) {
        setStatus(QStringLiteral("未检测到人脸"), false);
        return;
    }

    // embedding & match
    const QByteArray emb = m_faceAuth->computeEmbedding32x32(faceImg);
    if (emb.isEmpty()) {
        setStatus(QStringLiteral("人脸特征提取失败"), false);
        return;
    }

    QString err;
    const auto persons = m_personRepo->listPersons(true, &err);
    if (!err.isEmpty()) {
        setStatus(QStringLiteral("读取人员库失败"), false);
        return;
    }
    if (persons.isEmpty()) {
        setStatus(QStringLiteral("人员库为空"), false);
        return;
    }

    const auto r = m_faceAuth->match(emb, persons);

    if (r.bestId >= 0 && r.bestScore >= m_threshold) {
        // ✅ 识别成功 → 开锁
        const int durationMs = m_settings ? m_settings->getInt("unlock_duration_ms", 3000) : 3000;
        m_doorLock->unlock(QStringLiteral("face"), r.bestName, durationMs);

        m_lastOkTs = now;
        setStatus(QStringLiteral("已开锁"), true, r.bestName, r.bestScore);

        if (m_eventRepo) {
            m_eventRepo->addEvent("face", "unlock_ok", m_role, r.bestName,
                                  QStringLiteral("{\"score\":%1}").arg(r.bestScore), nullptr);
        }
        return;
    }

    // ❌ 识别失败
    setStatus(QStringLiteral("开锁失败"), false, r.bestName, r.bestScore);

    if (m_eventRepo) {
        m_eventRepo->addEvent("face", "unlock_fail", m_role,
                              r.bestName.isEmpty() ? "unknown" : r.bestName,
                              QStringLiteral("{\"score\":%1,\"threshold\":%2}")
                                  .arg(r.bestScore).arg(m_threshold),
                              nullptr);
    }
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "facecontroller.h"

#include "faceauthservice.h"    // 人脸认证服务(特征提取和匹配)
#include "personrepo.h"         // 人员数据仓库
#include "personmodel.h"        // 人员数据模型(QML 列表)
#include "doorlockservice.h"    // 门锁服务
#include "settingsservice.h"    // 配置服务
#include "eventrepo.h"          // 事件仓库
#include "videostreamer.h"      // 视频流服务

#include <QDateTime>            // 日期时间(用于冷却时间)
#include <QStandardPaths>       // 系统标准路径
#include <QDir>                 // 目录操作
#include <QFile>                // 文件操作
#include <QDebug>               // 调试输出
#include <QCoreApplication>     // 应用程序核心(获取 exe 路径)
#include <QFileInfo>            // 文件信息

// OpenCV 核心头文件
#include <opencv2/core.hpp>         // cv::Mat, cv::Rect 等核心数据结构
#include <opencv2/imgproc.hpp>      // 图像处理(灰度化、直方图均衡化)
#include <opencv2/objdetect.hpp>    // 目标检测(Haar Cascade)

// ============================================================================
// 全局变量
// ============================================================================

// 全局 Haar 级联分类器对象
// 使用 static 限制作用域,只在当前文件可见
// 用于检测人脸,加载 haarcascade_frontalface_default.xml 模型
static cv::CascadeClassifier g_faceCascade;


// ============================================================================
// 构造函数
// ============================================================================

FaceController::FaceController(QObject* parent)
    : QObject(parent)
{
    // ---- 1. 设置定时器 ----
    // m_everyMs 默认 500ms,即每 500ms 执行一次识别
    m_timer.setInterval(m_everyMs);

    // ---- 2. 连接定时器 ----
    // 定时器超时时调用 onTick() 执行识别
    connect(&m_timer, &QTimer::timeout, this, &FaceController::onTick);

    // ---- 3. 启动定时器 ----
    // 程序启动后,定时器立即开始工作
    m_timer.start();
}


// ============================================================================
// 依赖注入:设置视频流
// ============================================================================

void FaceController::setVideo(VideoStreamer* v)
{
    // 保存视频流指针
    // VideoStreamer 提供最新的视频帧
    m_video = v;
}


// ============================================================================
// 角色设置
// ============================================================================

void FaceController::setRole(const QString& role)
{
    // 去除首尾空格并转为小写
    const QString r = role.trimmed().toLower();

    // 如果角色没有变化,直接返回
    if (m_role == r) return;

    // 更新角色
    m_role = r;

    // 发射角色变化信号(通知 QML)
    emit roleChanged();
}


// ============================================================================
// 启用/禁用人脸识别
// ============================================================================

void FaceController::setEnabled(bool on)
{
    // 如果状态没有变化,直接返回
    if (m_enabled == on) return;

    // 更新状态
    m_enabled = on;

    // 发射信号通知 QML
    emit enabledChanged();
}


// ============================================================================
// 从配置加载默认值
// ============================================================================

void FaceController::loadDefaultsFromSettings()
{
    // 如果配置服务未设置,直接返回
    if (!m_settings) return;

    // 从配置中读取人脸识别阈值
    // 默认值 0.75(在 AppController 中初始化时设置)
    // 阈值越高,识别越严格(漏检越多,误检越少)
    // 阈值越低,识别越宽松(漏检越少,误检越多)
    m_threshold = m_settings->getDouble("face_threshold", 0.75);
}


// ============================================================================
// 设置识别状态(私有方法)
// ============================================================================

void FaceController::setStatus(const QString& text, bool ok,
                               const QString& name, double score)
{
    // ---- 1. 更新所有状态变量 ----
    m_statusText = text;           // 状态文本(如 "已开锁"、"识别失败")
    m_lastUnlockOk = ok;           // 是否成功开门
    m_matchedName = name;          // 匹配到的人员姓名
    m_matchedScore = score;        // 匹配分数

    // ---- 2. 发射状态变化信号 ----
    // 所有 Q_PROPERTY 绑定的 QML 元素会自动更新
    emit statusChanged();
}


// ============================================================================
// 确保 Haar 级联分类器已加载
// ============================================================================

bool FaceController::ensureCascadeReady()
{
    // 如果已经加载,直接返回 true
    if (m_cascadeReady) return true;

    // ---- 1. 优先:exe 同目录 ----
    // 在 Windows 上,exe 在 build/Desktop_Qt_.../Debug/ 目录
    const QString exeDir = QCoreApplication::applicationDirPath();
    QString filePath = exeDir + "/haarcascade_frontalface_default.xml";

    // ---- 2. 备选:源码目录 ----
    // 如果 exe 同目录没有,尝试从源码目录加载
    // 仅在开发期使用,生产环境应使用 exe 同目录
    if (!QFileInfo::exists(filePath)) {
        // 这里需要改成你项目源码的实际路径
        const QString srcPath = "E:/Qtproject/DoorQML/haarcascade_frontalface_default.xml";
        if (QFileInfo::exists(srcPath)) filePath = srcPath;
    }

    // 打印加载路径(方便调试)
    qDebug() << "[Face] cascade path try:" << filePath;

    // ---- 3. 检查文件是否存在 ----
    if (!QFileInfo::exists(filePath)) {
        qWarning() << "[Face] cascade file NOT found on disk:" << filePath;
        return false;
    }

    // ---- 4. 加载 Haar 模型 ----
    // 使用全局 g_faceCascade 加载模型文件
    if (!g_faceCascade.load(filePath.toStdString())) {
        qWarning() << "[Face] Cascade load failed:" << filePath;
        return false;
    }

    // ---- 5. 标记为已加载 ----
    m_cascadeReady = true;
    qInfo() << "[Face] Cascade loaded OK:" << filePath;
    return true;
}


// ============================================================================
// 辅助函数:QImage 转 OpenCV Mat (BGR 格式)
// ============================================================================

static cv::Mat qimageToBgr(const QImage& img)
{
    // ---- 1. 转为 RGB888 格式 ----
    // OpenCV 默认使用 BGR 格式,Qt 使用 RGB
    // 所以先转 RGB,再转 BGR
    QImage rgb = img.convertToFormat(QImage::Format_RGB888);

    // ---- 2. 创建 cv::Mat 引用 ----
    // cv::Mat 构造函数:行数、列数、类型、数据指针、每行字节数
    // CV_8UC3:8 位无符号,3 通道(RGB)
    // constBits() 返回只读指针,但 cv::Mat 需要可写指针
    // 这里强制转换为 void*,但实际不会修改数据
    cv::Mat mat(rgb.height(), rgb.width(), CV_8UC3,
                (void*)rgb.constBits(), rgb.bytesPerLine());

    // ---- 3. 转换为 BGR ----
    cv::Mat bgr;
    cv::cvtColor(mat, bgr, cv::COLOR_RGB2BGR);

    return bgr;
}


// ============================================================================
// 检测图像中最大的人脸
// ============================================================================

bool FaceController::detectLargestFace(const QImage& frame, QImage* faceOut)
{
    // ---- 1. 参数检查 ----
    if (!faceOut) return false;

    // ---- 2. 确保 Haar 模型已加载 ----
    if (!ensureCascadeReady()) return false;

    // ---- 3. QImage → cv::Mat (BGR) ----
    cv::Mat bgr = qimageToBgr(frame);

    // ---- 4. 转为灰度图 ----
    cv::Mat gray;
    cv::cvtColor(bgr, gray, cv::COLOR_BGR2GRAY);

    // ---- 5. 直方图均衡化 ----
    // 增强对比度,提高检测率
    cv::equalizeHist(gray, gray);

    // ---- 6. 人脸检测 ----
    // detectMultiScale 参数:
    //   gray: 输入灰度图
    //   faces: 输出人脸矩形列表
    //   1.1: scaleFactor,每次缩放比例(越小越精确但越慢)
    //   3: minNeighbors,最小邻域数(越大误报越少)
    //   0: flags,标志
    //   cv::Size(80, 80): minSize,最小人脸尺寸
    std::vector<cv::Rect> faces;
    g_faceCascade.detectMultiScale(gray, faces, 1.1, 3, 0, cv::Size(80, 80));

    // ---- 7. 检查是否检测到人脸 ----
    if (faces.empty()) return false;

    // ---- 8. 取最大的人脸 ----
    // 遍历所有检测到的人脸,找到面积最大的
    cv::Rect best = faces[0];
    for (const auto& r : faces) {
        if (r.area() > best.area()) best = r;
    }

    // ---- 9. 裁剪人脸区域 ----
    // 从原始 BGR 图像中裁剪人脸区域
    cv::Mat faceBgr = bgr(best).clone();

    // ---- 10. 转回 RGB ----
    cv::Mat faceRgb;
    cv::cvtColor(faceBgr, faceRgb, cv::COLOR_BGR2RGB);

    // ---- 11. 转换为 QImage ----
    // cv::Mat 的 step 是每行字节数
    // 必须使用 copy() 深拷贝,因为 faceRgb 的数据会在函数返回后释放
    QImage face(faceRgb.data, faceRgb.cols, faceRgb.rows,
                faceRgb.step, QImage::Format_RGB888);
    *faceOut = face.copy();

    return true;
}


// ============================================================================
// 录入人脸(QML 可调用)
// ============================================================================

bool FaceController::enroll(const QString& name)
{
    // ---- 1. 检查姓名 ----
    const QString n = name.trimmed();
    if (n.isEmpty()) {
        setStatus(QStringLiteral("录入失败:姓名为空"), false);
        return false;
    }

    // ---- 2. 检查角色 ----
    // 只有内机可以录入人脸(安全考虑)
    if (m_role != "inner") {
        setStatus(QStringLiteral("录入失败:仅内机可录入"), false);
        return false;
    }

    // ---- 3. 检查依赖服务 ----
    if (!m_faceAuth || !m_personRepo || !m_personModel || !m_video) {
        setStatus(QStringLiteral("录入失败:服务未初始化"), false);
        return false;
    }

    // ---- 4. 获取最新视频帧 ----
    const QImage frame = m_video->latestFrame();
    if (frame.isNull()) {
        setStatus(QStringLiteral("录入失败:没有视频帧"), false);
        return false;
    }

    // ---- 5. 检测人脸 ----
    QImage faceImg;
    if (!detectLargestFace(frame, &faceImg)) {
        setStatus(QStringLiteral("录入失败:未检测到人脸"), false);
        return false;
    }

    // ---- 6. 计算特征向量 ----
    const QByteArray emb = m_faceAuth->computeEmbedding32x32(faceImg);
    if (emb.isEmpty()) {
        setStatus(QStringLiteral("录入失败:特征提取失败"), false);
        return false;
    }

    // ---- 7. 写入数据库 ----
    QString err;
    if (!m_personRepo->addPerson(n, emb, &err)) {
        setStatus(QStringLiteral("录入失败:写库失败"), false, n, 0.0);
        return false;
    }

    // ---- 8. 刷新 QML 列表 ----
    m_personModel->reload();

    // ---- 9. 设置成功状态 ----
    setStatus(QStringLiteral("录入成功"), true, n, 1.0);
    return true;
}


// ============================================================================
// 扫描人脸(QML 可调用,手动触发一次识别)
// ============================================================================

bool FaceController::scan()
{
    // ---- 1. 检查依赖服务 ----
    if (!m_faceAuth || !m_personRepo || !m_doorLock || !m_video) {
        setStatus(QStringLiteral("扫描失败:服务未初始化"), false);
        return false;
    }

    // ---- 2. 获取最新视频帧 ----
    const QImage frame = m_video->latestFrame();
    if (frame.isNull()) {
        setStatus(QStringLiteral("扫描失败:没有视频帧"), false);
        return false;
    }

    // ---- 3. 检测人脸 ----
    QImage faceImg;
    if (!detectLargestFace(frame, &faceImg)) {
        setStatus(QStringLiteral("未检测到人脸"), false);
        return false;
    }

    // ---- 4. 计算特征向量 ----
    const QByteArray emb = m_faceAuth->computeEmbedding32x32(faceImg);
    if (emb.isEmpty()) {
        setStatus(QStringLiteral("人脸特征提取失败"), false);
        return false;
    }

    // ---- 5. 获取人员列表 ----
    QString err;
    const auto persons = m_personRepo->listPersons(true, &err);
    if (!err.isEmpty()) {
        setStatus(QStringLiteral("读取人员库失败"), false);
        return false;
    }
    if (persons.isEmpty()) {
        setStatus(QStringLiteral("人员库为空"), false);
        return false;
    }

    // ---- 6. 匹配人脸 ----
    const auto r = m_faceAuth->match(emb, persons);

    // ---- 7. 判断是否匹配成功 ----
    if (r.bestId >= 0 && r.bestScore >= m_threshold) {
        // 匹配成功:开门
        const int durationMs = m_settings ? m_settings->getInt("unlock_duration_ms", 3000) : 3000;
        m_doorLock->unlock(QStringLiteral("face"), r.bestName, durationMs);
        setStatus(QStringLiteral("已开锁"), true, r.bestName, r.bestScore);
        return true;
    }

    // 匹配失败
    setStatus(QStringLiteral("开锁失败"), false, r.bestName, r.bestScore);
    return false;
}


// ============================================================================
// 定时器回调(自动识别)
// ============================================================================

void FaceController::onTick()
{
    // ---- 1. 检查角色 ----
    // 只有外机自动识别(内机不自动开门)
    if (m_role != "outer") return;

    // ---- 2. 检查是否启用 ----
    if (!m_enabled) return;

    // ---- 3. 检查依赖服务 ----
    if (!m_faceAuth || !m_personRepo || !m_doorLock || !m_video) return;

    // ---- 4. 检查视频流是否开启 ----
    // 只有在视频流开启时才做识别
    if (!m_video->streaming()) return;

    // ---- 5. 检查冷却时间 ----
    // 成功后 m_cooldownMs 毫秒内不重复开门
    const qint64 now = QDateTime::currentMSecsSinceEpoch();
    if (m_lastOkTs > 0 && (now - m_lastOkTs) < m_cooldownMs) {
        return;  // 冷却中,跳过本次识别
    }

    // ---- 6. 获取最新视频帧 ----
    const QImage frame = m_video->latestFrame();
    if (frame.isNull()) return;

    // ---- 7. 检测人脸 ----
    QImage faceImg;
    if (!detectLargestFace(frame, &faceImg)) {
        setStatus(QStringLiteral("未检测到人脸"), false);
        return;
    }

    // ---- 8. 计算特征向量 ----
    const QByteArray emb = m_faceAuth->computeEmbedding32x32(faceImg);
    if (emb.isEmpty()) {
        setStatus(QStringLiteral("人脸特征提取失败"), false);
        return;
    }

    // ---- 9. 获取人员列表 ----
    QString err;
    const auto persons = m_personRepo->listPersons(true, &err);
    if (!err.isEmpty()) {
        setStatus(QStringLiteral("读取人员库失败"), false);
        return;
    }
    if (persons.isEmpty()) {
        setStatus(QStringLiteral("人员库为空"), false);
        return;
    }

    // ---- 10. 匹配人脸 ----
    const auto r = m_faceAuth->match(emb, persons);

    // ---- 11. 判断匹配结果 ----
    if (r.bestId >= 0 && r.bestScore >= m_threshold) {
        // ✅ 识别成功 → 开门
        const int durationMs = m_settings ? m_settings->getInt("unlock_duration_ms", 3000) : 3000;
        m_doorLock->unlock(QStringLiteral("face"), r.bestName, durationMs);

        // 记录成功时间(用于冷却)
        m_lastOkTs = now;

        // 更新状态
        setStatus(QStringLiteral("已开锁"), true, r.bestName, r.bestScore);

        // 记录事件
        if (m_eventRepo) {
            m_eventRepo->addEvent("face", "unlock_ok", m_role, r.bestName,
                                  QStringLiteral("{\"score\":%1}").arg(r.bestScore), nullptr);
        }
        return;
    }

    // ❌ 识别失败
    setStatus(QStringLiteral("开锁失败"), false, r.bestName, r.bestScore);

    // 记录失败事件
    if (m_eventRepo) {
        m_eventRepo->addEvent("face", "unlock_fail", m_role,
                              r.bestName.isEmpty() ? "unknown" : r.bestName,
                              QStringLiteral("{\"score\":%1,\"threshold\":%2}")
                                  .arg(r.bestScore).arg(m_threshold),
                              nullptr);
    }
}

核心流程图

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│              自动识别流程 (onTick)                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  1. 检查角色 == "outer" ?                                      │
│     ↓                                                          │
│  2. 检查是否启用 (m_enabled)                                   │
│     ↓                                                          │
│  3. 检查视频流是否开启                                         │
│     ↓                                                          │
│  4. 检查冷却时间 (3秒内不重复)                                 │
│     ↓                                                          │
│  5. 获取最新视频帧                                             │
│     ↓                                                          │
│  6. detectLargestFace() 检测最大人脸                          │
│     ├── 未检测到 → 状态: "未检测到人脸"                        │
│     └── 检测到 → 裁剪人脸区域                                 │
│         ↓                                                      │
│  7. computeEmbedding32x32() 计算特征向量                      │
│     ↓                                                          │
│  8. match() 与数据库比对                                       │
│     ├── 匹配失败 (score < threshold) → 状态: "开锁失败"       │
│     └── 匹配成功 (score >= threshold)                          │
│         ↓                                                      │
│  9. unlock() 开门                                              │
│     ↓                                                          │
│  10. 记录事件 (EventRepo)                                      │
│     ↓                                                          │
│  11. 更新状态 (QML 自动刷新)                                   │
└─────────────────────────────────────────────────────────────────┘

personmodel.h

无注释版

cpp 复制代码
#pragma once

#include <QAbstractListModel>
#include "personrepo.h"

class PersonModel : public QAbstractListModel
{
    Q_OBJECT
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
public:
    enum Roles {
        IdRole = Qt::UserRole + 1,
        NameRole,
        EnabledRole,
        CreatedAtRole
    };
    Q_ENUM(Roles)

    explicit PersonModel(QObject* parent = nullptr);

    void setRepo(PersonRepo* repo);

    int rowCount(const QModelIndex& parent = QModelIndex()) const override;
    QVariant data(const QModelIndex& index, int role) const override;
    QHash<int, QByteArray> roleNames() const override;

    Q_INVOKABLE void reload();
    Q_INVOKABLE bool setEnabled(int row, bool enabled);
    Q_INVOKABLE bool remove(int row);

    QString lastError() const { return m_lastError; }

signals:
    void lastErrorChanged();

private:
    void setLastError(const QString& e);

    PersonRepo* m_repo = nullptr;
    QVector<PersonRecord> m_items;
    QString m_lastError;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QAbstractListModel 基类头文件
// QAbstractListModel 是 Qt 提供的列表模型抽象类,用于在 QML 中显示列表数据
// 继承它需要实现 rowCount() 和 data() 两个核心方法
#include <QAbstractListModel>

// 包含人员仓库头文件(包含 PersonRecord 结构体和数据访问方法)
#include "personrepo.h"


// ============================================================================
// PersonModel 类定义
// ============================================================================

class PersonModel : public QAbstractListModel
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 最后一个错误信息 -----
    // 当操作失败时,QML 可以读取此属性显示错误
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)

// ============================================================================
// 枚举:自定义角色
// ============================================================================

public:
    // 定义模型中每个数据项的角色(相当于字段名)
    // 这些角色在 QML 中通过 model.id、model.name 等方式访问
    enum Roles {
        IdRole = Qt::UserRole + 1,   // 人员 ID
        NameRole,                     // 人员姓名
        EnabledRole,                  // 是否启用(true=启用,false=禁用)
        CreatedAtRole                 // 创建时间
    };
    // Q_ENUM 宏让枚举可以在 QML 中使用
    Q_ENUM(Roles)

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit PersonModel(QObject* parent = nullptr);

    // ---------- 依赖注入 ----------

    // 设置人员仓库(用于从数据库读取数据)
    void setRepo(PersonRepo* repo);

    // ---------- QAbstractListModel 必须实现的方法 ----------

    // 返回列表中的行数(即人员数量)
    int rowCount(const QModelIndex& parent = QModelIndex()) const override;

    // 返回指定行和角色的数据
    QVariant data(const QModelIndex& index, int role) const override;

    // 返回角色名称映射(QML 中通过名称访问数据)
    // 例如:角色 IdRole → QML 中访问 model.id
    QHash<int, QByteArray> roleNames() const override;

    // ---------- Q_INVOKABLE 方法(QML 可调用) ----------

    // 重新加载数据(从数据库刷新列表)
    Q_INVOKABLE void reload();

    // 设置指定行人员的启用状态
    // row: 列表中的行索引
    // enabled: true=启用,false=禁用
    // 返回 true 表示操作成功,false 表示失败
    Q_INVOKABLE bool setEnabled(int row, bool enabled);

    // 删除指定行的人员
    // row: 列表中的行索引
    // 返回 true 表示删除成功,false 表示失败
    Q_INVOKABLE bool remove(int row);

    // ---------- 属性 getter ----------

    QString lastError() const { return m_lastError; }

// ============================================================================
// 信号
// ============================================================================

signals:
    void lastErrorChanged();     // 错误信息变化时发射

// ============================================================================
// 私有方法
// ============================================================================

private:
    // 设置错误信息(内部使用)
    void setLastError(const QString& e);

// ============================================================================
// 私有成员变量
// ============================================================================

    PersonRepo* m_repo = nullptr;          // 人员仓库(数据源)
    QVector<PersonRecord> m_items;         // 人员记录缓存(从数据库加载)
    QString m_lastError;                   // 最后一个错误信息
};

数据流向

cpp 复制代码
数据库 (person 表)
    ↓
PersonRepo::listPersons()
    ↓
PersonModel::reload()
    ↓
m_items (QVector<PersonRecord>)
    ↓
QML ListView / TableView
    ↓
用户看到人员列表

角色映射表

枚举 QML 访问名 说明
IdRole Qt::UserRole + 1 model.id 人员 ID
NameRole Qt::UserRole + 2 model.name 人员姓名
EnabledRole Qt::UserRole + 3 model.enabled 是否启用
CreatedAtRole Qt::UserRole + 4 model.createdAt 创建时间

personmodel.cpp

无注释版

cpp 复制代码
#include "personmodel.h"

PersonModel::PersonModel(QObject* parent) : QAbstractListModel(parent)
{
}

void PersonModel::setRepo(PersonRepo* repo)
{
    m_repo = repo;
}

int PersonModel::rowCount(const QModelIndex& parent) const
{
    if (parent.isValid()) return 0;
    return m_items.size();
}

QVariant PersonModel::data(const QModelIndex& index, int role) const
{
    if (!index.isValid() || index.row() < 0 || index.row() >= m_items.size()) return QVariant();
    const auto& r = m_items.at(index.row());
    switch (role) {
    case IdRole: return r.id;
    case NameRole: return r.name;
    case EnabledRole: return r.enabled;
    case CreatedAtRole: return r.createdAt;
    default: return QVariant();
    }
}

QHash<int, QByteArray> PersonModel::roleNames() const
{
    return {
        {IdRole, "pid"},
        {NameRole, "name"},
        {EnabledRole, "enabled"},
        {CreatedAtRole, "createdAt"}
    };
}

void PersonModel::reload()
{
    if (!m_repo) return;
    QString err;
    const auto list = m_repo->listPersons(false, &err);
    beginResetModel();
    m_items = list;
    endResetModel();
    setLastError(err);
}

bool PersonModel::setEnabled(int row, bool enabled)
{
    if (!m_repo) return false;
    if (row < 0 || row >= m_items.size()) return false;

    QString err;
    const int id = m_items[row].id;
    if (!m_repo->setEnabled(id, enabled, &err)) {
        setLastError(err);
        return false;
    }

    m_items[row].enabled = enabled;
    emit dataChanged(index(row, 0), index(row, 0), {EnabledRole});
    setLastError(QString());
    return true;
}

bool PersonModel::remove(int row)
{
    if (!m_repo) return false;
    if (row < 0 || row >= m_items.size()) return false;

    QString err;
    const int id = m_items[row].id;
    if (!m_repo->removePerson(id, &err)) {
        setLastError(err);
        return false;
    }

    beginRemoveRows(QModelIndex(), row, row);
    m_items.removeAt(row);
    endRemoveRows();
    setLastError(QString());
    return true;
}

void PersonModel::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

// 包含 PersonModel 的头文件(类声明)
#include "personmodel.h"


// ============================================================================
// 构造函数
// ============================================================================

PersonModel::PersonModel(QObject* parent)
    : QAbstractListModel(parent)  // 调用基类 QAbstractListModel 的构造函数
{
    // 构造函数为空,所有初始化在 setRepo() 和 reload() 中完成
    // QAbstractListModel 是 Qt 提供的列表模型抽象类
    // 继承它需要实现 rowCount() 和 data() 两个纯虚函数
}


// ============================================================================
// 依赖注入:设置人员仓库
// ============================================================================

void PersonModel::setRepo(PersonRepo* repo)
{
    // 保存人员仓库指针
    // PersonRepo 负责从数据库读取人员数据
    m_repo = repo;
}


// ============================================================================
// 返回列表中的行数(QAbstractListModel 必须实现)
// ============================================================================

int PersonModel::rowCount(const QModelIndex& parent) const
{
    // 如果 parent 有效,返回 0
    // QAbstractListModel 是扁平列表,不支持树形结构
    // 所以当 parent 有效时,表示有父级,但列表没有层级,返回 0
    if (parent.isValid()) return 0;

    // 返回缓存中人员记录的数量
    return m_items.size();
}


// ============================================================================
// 返回指定行和角色的数据(QAbstractListModel 必须实现)
// ============================================================================

QVariant PersonModel::data(const QModelIndex& index, int role) const
{
    // ---- 1. 索引有效性检查 ----
    // 如果索引无效,返回空 QVariant
    if (!index.isValid()) return QVariant();

    // 如果行号超出范围,返回空 QVariant
    if (index.row() < 0 || index.row() >= m_items.size()) return QVariant();

    // ---- 2. 获取指定行的人员记录 ----
    // at() 是 QVector 的只读访问方法,比 operator[] 更安全(会检查边界)
    const auto& r = m_items.at(index.row());

    // ---- 3. 根据角色返回对应的数据 ----
    switch (role) {
    case IdRole:        return r.id;          // 人员 ID
    case NameRole:      return r.name;        // 人员姓名
    case EnabledRole:   return r.enabled;     // 是否启用(true=启用,false=禁用)
    case CreatedAtRole: return r.createdAt;   // 创建时间(Unix 时间戳)
    default:            return QVariant();    // 未知角色,返回空
    }
}


// ============================================================================
// 返回角色名称映射(QML 中通过名称访问数据)
// ============================================================================

QHash<int, QByteArray> PersonModel::roleNames() const
{
    // 定义 QML 中访问数据时使用的名称
    // 例如在 QML 中:ListView 的 delegate 里可以用 model.pid、model.name 等
    return {
        {IdRole,        "pid"},       // QML 中访问 model.pid → 返回人员 ID
        {NameRole,      "name"},      // QML 中访问 model.name → 返回姓名
        {EnabledRole,   "enabled"},   // QML 中访问 model.enabled → 返回是否启用
        {CreatedAtRole, "createdAt"}  // QML 中访问 model.createdAt → 返回创建时间
    };
}


// ============================================================================
// 重新加载数据(从数据库刷新列表)
// ============================================================================

void PersonModel::reload()
{
    // ---- 1. 检查仓库是否已设置 ----
    // 如果 m_repo 为空,说明还没有调用 setRepo(),无法读取数据
    if (!m_repo) return;

    // ---- 2. 从仓库获取人员列表 ----
    // listPersons(false, &err) 获取所有人员(包括已禁用的)
    // 参数 false 表示获取全部,true 表示只获取启用的
    QString err;
    const auto list = m_repo->listPersons(false, &err);

    // ---- 3. 更新模型数据 ----
    // beginResetModel() 告诉 QML:数据即将重置
    // 这会触发 QML 中的 ListView/TableView 清理缓存
    beginResetModel();

    // 将 QList<PersonRecord> 赋值给 QVector<PersonRecord>
    m_items = list;

    // endResetModel() 告诉 QML:数据已重置完成
    // QML 会重新请求 rowCount() 和 data() 来刷新显示
    endResetModel();

    // ---- 4. 保存错误信息 ----
    // 如果 listPersons() 出错,err 会包含错误描述
    setLastError(err);
}


// ============================================================================
// 设置指定人员的启用状态
// ============================================================================

bool PersonModel::setEnabled(int row, bool enabled)
{
    // ---- 1. 检查仓库是否已设置 ----
    if (!m_repo) return false;

    // ---- 2. 检查行号是否有效 ----
    if (row < 0 || row >= m_items.size()) return false;

    // ---- 3. 获取人员 ID ----
    const int id = m_items[row].id;

    // ---- 4. 调用仓库更新启用状态 ----
    QString err;
    if (!m_repo->setEnabled(id, enabled, &err)) {
        // 更新失败,保存错误信息
        setLastError(err);
        return false;
    }

    // ---- 5. 更新缓存 ----
    // 修改本地缓存中对应行的数据
    m_items[row].enabled = enabled;

    // ---- 6. 通知 QML 数据已变化 ----
    // dataChanged() 告诉 QML 指定行的数据已变化
    // 参数:起始索引、结束索引、变化的角色列表
    // {EnabledRole} 表示只有 EnabledRole 变化了,QML 可以只更新这一部分
    emit dataChanged(index(row, 0), index(row, 0), {EnabledRole});

    // ---- 7. 清空错误信息 ----
    setLastError(QString());
    return true;
}


// ============================================================================
// 删除指定人员
// ============================================================================

bool PersonModel::remove(int row)
{
    // ---- 1. 检查仓库是否已设置 ----
    if (!m_repo) return false;

    // ---- 2. 检查行号是否有效 ----
    if (row < 0 || row >= m_items.size()) return false;

    // ---- 3. 获取人员 ID ----
    const int id = m_items[row].id;

    // ---- 4. 调用仓库删除人员 ----
    QString err;
    if (!m_repo->removePerson(id, &err)) {
        // 删除失败,保存错误信息
        setLastError(err);
        return false;
    }

    // ---- 5. 从缓存中移除 ----
    // beginRemoveRows() 告诉 QML:即将删除行
    // 参数:父索引、起始行、结束行
    beginRemoveRows(QModelIndex(), row, row);

    // 从 QVector 中移除指定行
    m_items.removeAt(row);

    // endRemoveRows() 告诉 QML:行已删除
    endRemoveRows();

    // ---- 6. 清空错误信息 ----
    setLastError(QString());
    return true;
}


// ============================================================================
// 私有方法:设置错误信息
// ============================================================================

void PersonModel::setLastError(const QString& e)
{
    // ---- 1. 如果错误信息未变化,直接返回 ----
    if (m_lastError == e) return;

    // ---- 2. 更新错误信息 ----
    m_lastError = e;

    // ---- 3. 发射信号通知 QML ----
    // QML 中可以绑定此信号,例如显示错误提示
    emit lastErrorChanged();
}

核心方法总结

方法 功能 调用时机
setRepo() 设置人员仓库 AppController 初始化时
rowCount() 返回行数 QML 渲染时自动调用
data() 返回指定数据 QML 渲染时自动调用
roleNames() 返回角色名称映射 QML 初始化时自动调用
reload() 从数据库刷新数据 主动调用(如录入后刷新)
setEnabled() 启用/禁用人员 QML 中修改启用状态
remove() 删除人员 QML 中点击删除按钮
setLastError() 设置错误信息 内部使用

信号/槽关系图

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                  PersonModel 信号/槽关系                       │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  QML 调用                                                      │
│      │                                                         │
│      ▼                                                         │
│  setEnabled(row, enabled)                                      │
│      │                                                         │
│      ├── 调用 PersonRepo::setEnabled()                         │
│      │                                                         │
│      ├── 更新 m_items 缓存                                     │
│      │                                                         │
│      └── emit dataChanged()                                    │
│              │                                                 │
│              ▼                                                 │
│          QML 自动更新界面                                      │
│                                                                 │
│  QML 调用                                                      │
│      │                                                         │
│      ▼                                                         │
│  remove(row)                                                   │
│      │                                                         │
│      ├── 调用 PersonRepo::removePerson()                       │
│      │                                                         │
│      ├── beginRemoveRows()                                     │
│      │                                                         │
│      ├── m_items.removeAt(row)                                 │
│      │                                                         │
│      └── endRemoveRows()                                       │
│              │                                                 │
│              ▼                                                 │
│          QML 自动更新界面                                      │
└─────────────────────────────────────────────────────────────────┘

personrepo.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QSqlDatabase>
#include <QVector>

struct PersonRecord {
    int id = -1;
    QString name;
    bool enabled = true;
    qint64 createdAt = 0;
    QByteArray embedding; // raw float32 array bytes
};

class PersonRepo : public QObject
{
    Q_OBJECT
public:
    explicit PersonRepo(QObject* parent = nullptr);

    void setDatabase(const QSqlDatabase& db);

    bool addPerson(const QString& name, const QByteArray& embedding, QString* errorOut = nullptr);
    bool setEnabled(int id, bool enabled, QString* errorOut = nullptr);
    bool removePerson(int id, QString* errorOut = nullptr);

    QVector<PersonRecord> listPersons(bool enabledOnly = false, QString* errorOut = nullptr) const;

private:
    QSqlDatabase m_db;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QSqlDatabase 头文件(数据库连接)
#include <QSqlDatabase>

// 包含 QVector 头文件(返回人员列表)
#include <QVector>


// ============================================================================
// PersonRecord 结构体:表示一条人员记录
// ============================================================================

// PersonRecord 是数据传输对象(DTO),用于在 PersonRepo 和 PersonModel 之间传递数据
// 它包含了人员表的所有字段
struct PersonRecord {
    int id = -1;              // 人员 ID(-1 表示无效/未分配)
    QString name;             // 人员姓名
    bool enabled = true;      // 是否启用(true=启用,false=禁用)
    qint64 createdAt = 0;     // 创建时间(Unix 时间戳,秒级)
    QByteArray embedding;     // 人脸特征向量(float32 数组的原始字节)
};


// ============================================================================
// PersonRepo 类定义
// ============================================================================

// PersonRepo 继承自 QObject,负责人员数据的数据库增删改查操作
// 它封装了所有与 person 表相关的 SQL 操作
class PersonRepo : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit PersonRepo(QObject* parent = nullptr);

    // ---- 依赖注入:设置数据库连接 ----
    // 由 AppController 在初始化时调用
    // 传入的数据库连接必须已经打开
    void setDatabase(const QSqlDatabase& db);

    // ---- 添加人员 ----
    // name: 人员姓名
    // embedding: 人脸特征向量(由 FaceAuthService 计算)
    // errorOut: 如果非空,出错时返回错误信息
    // 返回 true 表示添加成功,false 表示失败
    bool addPerson(const QString& name,
                   const QByteArray& embedding,
                   QString* errorOut = nullptr);

    // ---- 设置启用状态 ----
    // id: 人员 ID
    // enabled: true=启用,false=禁用
    // errorOut: 如果非空,出错时返回错误信息
    // 返回 true 表示更新成功,false 表示失败
    bool setEnabled(int id, bool enabled, QString* errorOut = nullptr);

    // ---- 删除人员 ----
    // id: 人员 ID
    // errorOut: 如果非空,出错时返回错误信息
    // 返回 true 表示删除成功,false 表示失败
    bool removePerson(int id, QString* errorOut = nullptr);

    // ---- 查询人员列表 ----
    // enabledOnly: true=只返回启用的人员,false=返回所有人员
    // errorOut: 如果非空,出错时返回错误信息
    // 返回 QVector<PersonRecord> 包含查询结果
    QVector<PersonRecord> listPersons(bool enabledOnly = false,
                                      QString* errorOut = nullptr) const;

// ============================================================================
// 私有成员变量
// ============================================================================

private:
    QSqlDatabase m_db;     // 数据库连接对象(由 setDatabase() 注入)
};

数据流

cpp 复制代码
【写入】
用户录入人脸
    ↓
FaceController::enroll()
    ↓
FaceAuthService::computeEmbedding32x32()
    ↓
PersonRepo::addPerson()
    ↓
SQL INSERT
    ↓
数据库 (person 表)

【读取】
QML 请求显示人员列表
    ↓
PersonModel::reload()
    ↓
PersonRepo::listPersons()
    ↓
SQL SELECT
    ↓
QVector<PersonRecord> 返回
    ↓
PersonModel 缓存并提供给 QML

【更新】
QML 切换启用/禁用
    ↓
PersonModel::setEnabled()
    ↓
PersonRepo::setEnabled()
    ↓
SQL UPDATE
    ↓
数据库更新
    ↓
PersonModel 缓存更新 → QML 刷新

【删除】
QML 点击删除
    ↓
PersonModel::remove()
    ↓
PersonRepo::removePerson()
    ↓
SQL DELETE
    ↓
数据库删除
    ↓
PersonModel 缓存更新 → QML 刷新

personrepo.cpp

无注释版

cpp 复制代码
#include "personrepo.h"

#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDateTime>

PersonRepo::PersonRepo(QObject* parent) : QObject(parent)
{
}

void PersonRepo::setDatabase(const QSqlDatabase& db)
{
    m_db = db;
}

bool PersonRepo::addPerson(const QString& name, const QByteArray& embedding, QString* errorOut)
{
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral(
        "INSERT INTO authorized_person (name, enabled, valid_from, valid_to, face_embedding, created_at) "
        "VALUES (?, 1, NULL, NULL, ?, ?)"
        ));
    q.addBindValue(name);
    q.addBindValue(embedding);
    q.addBindValue(QDateTime::currentSecsSinceEpoch());

    if (!q.exec()) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }
    return true;
}

bool PersonRepo::setEnabled(int id, bool enabled, QString* errorOut)
{
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("UPDATE authorized_person SET enabled=? WHERE id=?"));
    q.addBindValue(enabled ? 1 : 0);
    q.addBindValue(id);

    if (!q.exec()) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }
    return true;
}

bool PersonRepo::removePerson(int id, QString* errorOut)
{
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("DELETE FROM authorized_person WHERE id=?"));
    q.addBindValue(id);

    if (!q.exec()) {
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }
    return true;
}

QVector<PersonRecord> PersonRepo::listPersons(bool enabledOnly, QString* errorOut) const
{
    QVector<PersonRecord> out;
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return out;
    }

    QSqlQuery q(m_db);
    QString sql = QStringLiteral("SELECT id, name, enabled, created_at, face_embedding FROM authorized_person");
    if (enabledOnly) {
        sql += QStringLiteral(" WHERE enabled=1");
    }
    sql += QStringLiteral(" ORDER BY id DESC");
    if (!q.exec(sql)) {
        if (errorOut) *errorOut = q.lastError().text();
        return out;
    }

    while (q.next()) {
        PersonRecord r;
        r.id = q.value(0).toInt();
        r.name = q.value(1).toString();
        r.enabled = q.value(2).toInt() != 0;
        r.createdAt = q.value(3).toLongLong();
        r.embedding = q.value(4).toByteArray();
        out.push_back(r);
    }
    return out;
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "personrepo.h"          // 包含 PersonRepo 的头文件(类声明)

#include <QSqlQuery>             // SQL 查询执行
#include <QSqlError>             // SQL 错误信息
#include <QVariant>              // 通用数据类型
#include <QDateTime>             // 日期时间(获取当前时间戳)


// ============================================================================
// 构造函数
// ============================================================================

PersonRepo::PersonRepo(QObject* parent)
    : QObject(parent)            // 调用基类 QObject 的构造函数
{
    // 构造函数为空,数据库连接通过 setDatabase() 方法注入
    // 这种设计模式称为"依赖注入",将数据库连接与业务逻辑分离
}


// ============================================================================
// 依赖注入:设置数据库连接
// ============================================================================

void PersonRepo::setDatabase(const QSqlDatabase& db)
{
    // 保存数据库连接对象
    // 注意:QSqlDatabase 是值类型,内部使用引用计数
    // 拷贝是轻量级操作,可以安全传递
    m_db = db;
}


// ============================================================================
// 添加人员
// ============================================================================

bool PersonRepo::addPerson(const QString& name,
                           const QByteArray& embedding,
                           QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    // ---- 2. 准备 SQL 插入语句 ----
    // 使用 QSqlQuery 对象执行 SQL 操作
    QSqlQuery q(m_db);

    // prepare() 使用 ? 作为占位符,准备参数化查询
    // 参数化查询可以有效防止 SQL 注入攻击
    // 
    // ⚠️ 注意:表名使用 "authorized_person"
    // 字段说明:
    //   name: 人员姓名
    //   enabled: 默认 1(启用)
    //   valid_from: 生效时间(NULL 表示无限制)
    //   valid_to: 失效时间(NULL 表示无限制)
    //   face_embedding: 人脸特征向量
    //   created_at: 创建时间(Unix 时间戳)
    q.prepare(QStringLiteral(
        "INSERT INTO authorized_person (name, enabled, valid_from, valid_to, face_embedding, created_at) "
        "VALUES (?, 1, NULL, NULL, ?, ?)"
        ));

    // ---- 3. 绑定参数 ----
    // addBindValue() 按顺序将值绑定到 ? 占位符
    // 顺序必须与 SQL 语句中的占位符顺序一致

    // 第 1 个 ?:姓名
    q.addBindValue(name);

    // 第 2 个 ?:人脸特征向量(QByteArray 存储 float32 数组)
    q.addBindValue(embedding);

    // 第 3 个 ?:创建时间(当前 Unix 时间戳,秒级)
    // QDateTime::currentSecsSinceEpoch() 返回自 1970-01-01 以来的秒数
    q.addBindValue(QDateTime::currentSecsSinceEpoch());

    // ---- 4. 执行 SQL ----
    // exec() 执行已准备的 SQL 语句
    // 返回 true 表示执行成功,false 表示失败
    if (!q.exec()) {
        // 执行失败,获取并返回错误信息
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    return true;
}


// ============================================================================
// 设置人员启用状态
// ============================================================================

bool PersonRepo::setEnabled(int id, bool enabled, QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    // ---- 2. 准备 SQL UPDATE 语句 ----
    QSqlQuery q(m_db);

    // UPDATE 语句更新指定 ID 的 enabled 字段
    q.prepare(QStringLiteral("UPDATE authorized_person SET enabled=? WHERE id=?"));

    // ---- 3. 绑定参数 ----
    // 第 1 个 ?:enabled 值(1=启用,0=禁用)
    q.addBindValue(enabled ? 1 : 0);

    // 第 2 个 ?:人员 ID
    q.addBindValue(id);

    // ---- 4. 执行 SQL ----
    if (!q.exec()) {
        // 执行失败,获取并返回错误信息
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ⚠️ 注意:这里没有检查受影响的行数
    // 如果 id 不存在,exec() 仍然返回 true
    // 建议添加检查:if (q.numRowsAffected() == 0) 表示没有找到该记录
    return true;
}


// ============================================================================
// 删除人员
// ============================================================================

bool PersonRepo::removePerson(int id, QString* errorOut)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return false;
    }

    // ---- 2. 准备 SQL DELETE 语句 ----
    QSqlQuery q(m_db);

    // DELETE 语句删除指定 ID 的记录
    q.prepare(QStringLiteral("DELETE FROM authorized_person WHERE id=?"));

    // ---- 3. 绑定参数 ----
    // 第 1 个 ?:人员 ID
    q.addBindValue(id);

    // ---- 4. 执行 SQL ----
    if (!q.exec()) {
        // 执行失败,获取并返回错误信息
        if (errorOut) *errorOut = q.lastError().text();
        return false;
    }

    // ⚠️ 注意:这里没有检查受影响的行数
    // 如果 id 不存在,exec() 仍然返回 true
    // 建议添加检查:if (q.numRowsAffected() == 0) 表示没有找到该记录
    return true;
}


// ============================================================================
// 查询人员列表
// ============================================================================

QVector<PersonRecord> PersonRepo::listPersons(bool enabledOnly, QString* errorOut) const
{
    // ---- 1. 声明返回结果容器 ----
    QVector<PersonRecord> out;

    // ---- 2. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        if (errorOut) *errorOut = QStringLiteral("DB not open");
        return out;          // 返回空列表
    }

    // ---- 3. 构建 SQL 查询语句 ----
    QSqlQuery q(m_db);

    // 基础 SELECT 语句,查询所有字段
    QString sql = QStringLiteral("SELECT id, name, enabled, created_at, face_embedding FROM authorized_person");

    // ---- 4. 添加 WHERE 条件(如果需要) ----
    // enabledOnly 为 true 时,只返回启用的人员
    if (enabledOnly) {
        sql += QStringLiteral(" WHERE enabled=1");
    }

    // ---- 5. 添加排序 ----
    // 按 ID 降序排列(最新的在前面)
    sql += QStringLiteral(" ORDER BY id DESC");

    // ---- 6. 执行查询 ----
    // ⚠️ 注意:这里使用字符串拼接,虽然有 SQL 注入风险
    // 但 enabledOnly 是 bool 类型,相对安全
    if (!q.exec(sql)) {
        if (errorOut) *errorOut = q.lastError().text();
        return out;          // 查询失败,返回空列表
    }

    // ---- 7. 遍历结果集 ----
    // q.next() 移动到下一行,如果有数据返回 true
    while (q.next()) {
        // 创建 PersonRecord 结构体,填充数据
        PersonRecord r;

        // q.value(0) 返回第 1 列(id)
        r.id = q.value(0).toInt();

        // q.value(1) 返回第 2 列(name)
        r.name = q.value(1).toString();

        // q.value(2) 返回第 3 列(enabled)
        // toInt() != 0 转换为 bool(1=true, 0=false)
        r.enabled = q.value(2).toInt() != 0;

        // q.value(3) 返回第 4 列(created_at)
        // toLongLong() 将 QVariant 转换为 qint64
        r.createdAt = q.value(3).toLongLong();

        // q.value(4) 返回第 5 列(face_embedding)
        // toByteArray() 返回 QByteArray
        r.embedding = q.value(4).toByteArray();

        // 将记录添加到结果列表
        out.push_back(r);
    }

    // ---- 8. 返回查询结果 ----
    return out;
}

settingsservice.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QSqlDatabase>

class SettingsService : public QObject
{
    Q_OBJECT
public:
    explicit SettingsService(QObject* parent = nullptr);

    void setDatabase(const QSqlDatabase& db);

    Q_INVOKABLE QString getString(const QString& key, const QString& defaultValue = QString()) const;
    Q_INVOKABLE int getInt(const QString& key, int defaultValue = 0) const;
    Q_INVOKABLE double getDouble(const QString& key, double defaultValue = 0.0) const;
    Q_INVOKABLE bool getBool(const QString& key, bool defaultValue = false) const;

    Q_INVOKABLE bool setString(const QString& key, const QString& value);
    Q_INVOKABLE bool setInt(const QString& key, int value);
    Q_INVOKABLE bool setDouble(const QString& key, double value);
    Q_INVOKABLE bool setBool(const QString& key, bool value);

signals:
    void settingChanged(const QString& key, const QString& value);

private:
    QString readValue(const QString& key) const;
    bool writeValue(const QString& key, const QString& value);

    QSqlDatabase m_db;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QSqlDatabase 头文件(数据库连接)
#include <QSqlDatabase>


// ============================================================================
// SettingsService 类定义
// ============================================================================

// SettingsService 继承自 QObject,负责系统配置的读写
// 配置以 key-value 形式存储在 settings 表中
// 支持:字符串、整数、浮点数、布尔值
class SettingsService : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit SettingsService(QObject* parent = nullptr);

    // ---- 依赖注入:设置数据库连接 ----
    // 由 AppController 在初始化时调用
    // 传入的数据库连接必须已经打开
    void setDatabase(const QSqlDatabase& db);

    // ---- 读取配置 ----

    // 读取字符串配置
    // key: 配置键
    // defaultValue: 如果 key 不存在,返回此默认值
    Q_INVOKABLE QString getString(const QString& key,
                                  const QString& defaultValue = QString()) const;

    // 读取整数配置
    Q_INVOKABLE int getInt(const QString& key, int defaultValue = 0) const;

    // 读取浮点数配置
    Q_INVOKABLE double getDouble(const QString& key, double defaultValue = 0.0) const;

    // 读取布尔值配置
    Q_INVOKABLE bool getBool(const QString& key, bool defaultValue = false) const;

    // ---- 写入配置 ----

    // 写入字符串配置
    // 返回 true 表示写入成功,false 表示失败
    Q_INVOKABLE bool setString(const QString& key, const QString& value);

    // 写入整数配置
    Q_INVOKABLE bool setInt(const QString& key, int value);

    // 写入浮点数配置
    Q_INVOKABLE bool setDouble(const QString& key, double value);

    // 写入布尔值配置
    Q_INVOKABLE bool setBool(const QString& key, bool value);

// ============================================================================
// 信号
// ============================================================================

signals:
    // 当配置值变化时发射
    // key: 变化的配置键
    // value: 新的配置值(字符串格式)
    // QML 可以连接此信号实现实时更新
    void settingChanged(const QString& key, const QString& value);

// ============================================================================
// 私有方法
// ============================================================================

private:
    // 从数据库读取原始值(内部使用)
    // 如果 key 不存在,返回空字符串
    QString readValue(const QString& key) const;

    // 写入原始值到数据库(内部使用)
    // 如果 key 存在则 UPDATE,不存在则 INSERT
    bool writeValue(const QString& key, const QString& value);

// ============================================================================
// 私有成员变量
// ============================================================================

    QSqlDatabase m_db;     // 数据库连接对象(由 setDatabase() 注入)
};

SQL 操作说明

读取配置

cpp 复制代码
SELECT value FROM settings WHERE key = :key

写入配置(UPSERT)

cpp 复制代码
-- 如果 key 存在则 UPDATE,不存在则 INSERT
-- SQLite 使用 REPLACE INTO 或 INSERT OR REPLACE
REPLACE INTO settings (key, value) VALUES (:key, :value)

常用配置键

类型 默认值 说明
server_host string 10.11.100.207 WebSocket 服务器 IP
server_port int 12345 WebSocket 服务器端口
face_threshold double 0.75 人脸识别阈值
unlock_duration_ms int 3000 开锁持续时间(毫秒)
unlock_password string 123456 密码开锁密码
initialized string 1 是否已初始化

settingsservice.cpp

无注释版

cpp 复制代码
#include "settingsservice.h"

#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>

SettingsService::SettingsService(QObject* parent) : QObject(parent)
{
}

void SettingsService::setDatabase(const QSqlDatabase& db)
{
    m_db = db;
}

QString SettingsService::readValue(const QString& key) const
{
    if (!m_db.isOpen()) return QString();

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("SELECT value FROM settings WHERE key=?"));
    q.addBindValue(key);
    if (!q.exec()) return QString();
    if (!q.next()) return QString();
    return q.value(0).toString();
}

bool SettingsService::writeValue(const QString& key, const QString& value)
{
    if (!m_db.isOpen()) return false;

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("INSERT INTO settings(key,value) VALUES(?,?) "
                             "ON CONFLICT(key) DO UPDATE SET value=excluded.value"));
    q.addBindValue(key);
    q.addBindValue(value);
    if (!q.exec()) return false;

    emit settingChanged(key, value);
    return true;
}

QString SettingsService::getString(const QString& key, const QString& defaultValue) const
{
    const QString v = readValue(key);
    return v.isEmpty() ? defaultValue : v;
}

int SettingsService::getInt(const QString& key, int defaultValue) const
{
    const QString v = readValue(key);
    bool ok = false;
    const int n = v.toInt(&ok);
    return ok ? n : defaultValue;
}

double SettingsService::getDouble(const QString& key, double defaultValue) const
{
    const QString v = readValue(key);
    bool ok = false;
    const double n = v.toDouble(&ok);
    return ok ? n : defaultValue;
}

bool SettingsService::getBool(const QString& key, bool defaultValue) const
{
    const QString v = readValue(key);
    if (v.isEmpty()) return defaultValue;
    return (v == QStringLiteral("1") || v.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0);
}

bool SettingsService::setString(const QString& key, const QString& value)
{
    return writeValue(key, value);
}

bool SettingsService::setInt(const QString& key, int value)
{
    return writeValue(key, QString::number(value));
}

bool SettingsService::setDouble(const QString& key, double value)
{
    return writeValue(key, QString::number(value, 'f', 6));
}

bool SettingsService::setBool(const QString& key, bool value)
{
    return writeValue(key, value ? QStringLiteral("1") : QStringLiteral("0"));
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "settingsservice.h"     // 包含 SettingsService 的头文件(类声明)

#include <QSqlQuery>             // SQL 查询执行
#include <QSqlError>             // SQL 错误信息
#include <QVariant>              // 通用数据类型


// ============================================================================
// 构造函数
// ============================================================================

SettingsService::SettingsService(QObject* parent)
    : QObject(parent)            // 调用基类 QObject 的构造函数
{
    // 构造函数为空,数据库连接通过 setDatabase() 方法注入
    // 这种设计模式称为"依赖注入",将数据库连接与业务逻辑分离
}


// ============================================================================
// 依赖注入:设置数据库连接
// ============================================================================

void SettingsService::setDatabase(const QSqlDatabase& db)
{
    // 保存数据库连接对象
    // 注意:QSqlDatabase 是值类型,内部使用引用计数
    // 拷贝是轻量级操作,可以安全传递
    m_db = db;
}


// ============================================================================
// 私有方法:从数据库读取原始值
// ============================================================================

QString SettingsService::readValue(const QString& key) const
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) return QString();

    // ---- 2. 准备 SQL 查询 ----
    // 使用参数化查询,防止 SQL 注入
    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("SELECT value FROM settings WHERE key=?"));
    q.addBindValue(key);

    // ---- 3. 执行查询 ----
    if (!q.exec()) {
        // 查询失败,返回空字符串
        // 可以通过 q.lastError().text() 获取详细错误
        return QString();
    }

    // ---- 4. 检查是否有结果 ----
    if (!q.next()) {
        // key 不存在,返回空字符串
        return QString();
    }

    // ---- 5. 返回值 ----
    // q.value(0).toString() 获取第 1 列的值
    return q.value(0).toString();
}


// ============================================================================
// 私有方法:写入值到数据库
// ============================================================================

bool SettingsService::writeValue(const QString& key, const QString& value)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) return false;

    // ---- 2. 准备 SQL 语句 ----
    // 使用 UPSERT 语法(INSERT ON CONFLICT DO UPDATE)
    // SQLite 3.24.0+ 支持 ON CONFLICT 语法
    // 
    // 工作原理:
    //   1. 尝试 INSERT 新记录
    //   2. 如果 key 已存在(主键冲突),则 UPDATE
    // 
    // 等价于:REPLACE INTO settings(key,value) VALUES(?,?)
    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("INSERT INTO settings(key,value) VALUES(?,?) "
                             "ON CONFLICT(key) DO UPDATE SET value=excluded.value"));
    q.addBindValue(key);
    q.addBindValue(value);

    // ---- 3. 执行 SQL ----
    if (!q.exec()) {
        // 执行失败,返回 false
        // 可以通过 q.lastError().text() 获取详细错误
        return false;
    }

    // ---- 4. 发射信号通知变化 ----
    // QML 可以连接此信号实现实时更新
    emit settingChanged(key, value);

    return true;
}


// ============================================================================
// 读取字符串配置
// ============================================================================

QString SettingsService::getString(const QString& key, const QString& defaultValue) const
{
    // 读取值
    const QString v = readValue(key);

    // 如果值为空(key 不存在或值为空字符串),返回默认值
    // ⚠️ 注意:如果配置值本来就是空字符串,这里会被误判为不存在
    // 如果需要支持空字符串作为有效值,可以改用其他方式判断
    return v.isEmpty() ? defaultValue : v;
}


// ============================================================================
// 读取整数配置
// ============================================================================

int SettingsService::getInt(const QString& key, int defaultValue) const
{
    // 读取值
    const QString v = readValue(key);

    // 尝试转换为整数
    bool ok = false;
    const int n = v.toInt(&ok);

    // 转换成功返回数值,否则返回默认值
    return ok ? n : defaultValue;
}


// ============================================================================
// 读取浮点数配置
// ============================================================================

double SettingsService::getDouble(const QString& key, double defaultValue) const
{
    // 读取值
    const QString v = readValue(key);

    // 尝试转换为浮点数
    bool ok = false;
    const double n = v.toDouble(&ok);

    // 转换成功返回数值,否则返回默认值
    return ok ? n : defaultValue;
}


// ============================================================================
// 读取布尔值配置
// ============================================================================

bool SettingsService::getBool(const QString& key, bool defaultValue) const
{
    // 读取值
    const QString v = readValue(key);

    // 如果值为空,返回默认值
    if (v.isEmpty()) return defaultValue;

    // 判断是否为真:
    //   1. 值为 "1"
    //   2. 值不区分大小写等于 "true"
    // 
    // 其他值(如 "0"、"false")都返回 false
    return (v == QStringLiteral("1") ||
            v.compare(QStringLiteral("true"), Qt::CaseInsensitive) == 0);
}


// ============================================================================
// 写入字符串配置
// ============================================================================

bool SettingsService::setString(const QString& key, const QString& value)
{
    // 直接调用 writeValue()
    return writeValue(key, value);
}


// ============================================================================
// 写入整数配置
// ============================================================================

bool SettingsService::setInt(const QString& key, int value)
{
    // 将整数转为字符串,再写入
    return writeValue(key, QString::number(value));
}


// ============================================================================
// 写入浮点数配置
// ============================================================================

bool SettingsService::setDouble(const QString& key, double value)
{
    // 将浮点数转为字符串,再写入
    // 'f' 表示固定小数点格式,6 表示 6 位小数
    // 例如:0.75 → "0.750000"
    return writeValue(key, QString::number(value, 'f', 6));
}


// ============================================================================
// 写入布尔值配置
// ============================================================================

bool SettingsService::setBool(const QString& key, bool value)
{
    // 将布尔值转为 "1" 或 "0",再写入
    return writeValue(key, value ? QStringLiteral("1") : QStringLiteral("0"));
}

核心流程图

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                 配置读写流程图                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  读取配置:                                                      │
│  getString("server_host")                                      │
│      │                                                         │
│      ▼                                                         │
│  readValue("server_host")                                      │
│      │                                                         │
│      ▼                                                         │
│  SELECT value FROM settings WHERE key='server_host'            │
│      │                                                         │
│      ├── 有结果 → 返回字符串                                   │
│      └── 无结果 → 返回空字符串                                 │
│                                                                 │
│  写入配置:                                                      │
│  setString("server_host", "192.168.1.100")                    │
│      │                                                         │
│      ▼                                                         │
│  writeValue("server_host", "192.168.1.100")                   │
│      │                                                         │
│      ▼                                                         │
│  INSERT INTO settings(key,value) VALUES(...)                  │
│  ON CONFLICT(key) DO UPDATE SET value=excluded.value          │
│      │                                                         │
│      ├── key 不存在 → INSERT                                  │
│      └── key 已存在 → UPDATE                                  │
│                                                                 │
│      ▼                                                         │
│  emit settingChanged("server_host", "192.168.1.100")          │
│      │                                                         │
│      ▼                                                         │
│  QML 收到信号,自动更新界面                                   │
└─────────────────────────────────────────────────────────────────┘

数据类型转换总结

类型 存储格式 getter 转换 setter 转换
字符串 原样存储 直接返回 直接存储
整数 数字字符串 QString::toInt() QString::number()
浮点数 数字字符串 QString::toDouble() QString::number(value, 'f', 6)
布尔值 "1""0" == "1"== "true" value ? "1" : "0"

改进建议

1. getString() 无法区分"空字符串"和"不存在"

cpp 复制代码
// ❌ 当前写法:空字符串和不存在都被视为"不存在"
QString v = readValue(key);
return v.isEmpty() ? defaultValue : v;

// ✅ 建议使用可选值或增加 exists() 方法
std::optional<QString> getString(const QString& key) const;

// 或增加方法
bool exists(const QString& key) const;

2. 添加错误日志

cpp 复制代码
// 在 readValue() 中
if (!q.exec()) {
    qWarning() << "Settings read failed for key:" << key << "error:" << q.lastError().text();
    return QString();
}

3. 添加缓存(提高性能)

cpp 复制代码
// 如果频繁读取配置,可以添加内存缓存
QMap<QString, QString> m_cache;
bool m_cacheDirty = false;

修复版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "settingsservice.h"

#include <QSqlQuery>
#include <QSqlError>
#include <QVariant>
#include <QDebug>              // ✅ 新增:用于错误日志输出


// ============================================================================
// 构造函数
// ============================================================================

SettingsService::SettingsService(QObject* parent)
    : QObject(parent)
{
    // 构造函数为空,数据库连接通过 setDatabase() 方法注入
}


// ============================================================================
// 依赖注入:设置数据库连接
// ============================================================================

void SettingsService::setDatabase(const QSqlDatabase& db)
{
    m_db = db;
}


// ============================================================================
// 私有方法:从数据库读取原始值
// ============================================================================

QString SettingsService::readValue(const QString& key) const
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        qWarning() << "SettingsService::readValue: Database not open";
        return QString();
    }

    // ---- 2. 准备 SQL 查询 ----
    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("SELECT value FROM settings WHERE key=?"));
    q.addBindValue(key);

    // ---- 3. 执行查询 ----
    if (!q.exec()) {
        qWarning() << "SettingsService::readValue: Query failed for key"
                   << key << "error:" << q.lastError().text();
        return QString();
    }

    // ---- 4. 检查是否有结果 ----
    if (!q.next()) {
        // key 不存在,返回空字符串(调用方会用默认值替换)
        return QString();
    }

    // ---- 5. 返回值 ----
    return q.value(0).toString();
}


// ============================================================================
// 私有方法:写入值到数据库
// ============================================================================

bool SettingsService::writeValue(const QString& key, const QString& value)
{
    // ---- 1. 检查数据库是否已打开 ----
    if (!m_db.isOpen()) {
        qWarning() << "SettingsService::writeValue: Database not open";
        return false;
    }

    // ---- 2. 检查参数是否有效 ----
    if (key.isEmpty()) {
        qWarning() << "SettingsService::writeValue: Empty key not allowed";
        return false;
    }

    // ---- 3. 准备 SQL 语句 ----
    // 使用 UPSERT 语法(SQLite 3.24.0+)
    // 如果 key 已存在则 UPDATE,不存在则 INSERT
    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("INSERT INTO settings(key,value) VALUES(?,?) "
                             "ON CONFLICT(key) DO UPDATE SET value=excluded.value"));
    q.addBindValue(key);
    q.addBindValue(value);

    // ---- 4. 执行 SQL ----
    if (!q.exec()) {
        qWarning() << "SettingsService::writeValue: Failed for key"
                   << key << "error:" << q.lastError().text();
        return false;
    }

    // ---- 5. 发射信号通知变化 ----
    emit settingChanged(key, value);

    return true;
}


// ============================================================================
// 读取字符串配置
// ============================================================================

QString SettingsService::getString(const QString& key, const QString& defaultValue) const
{
    const QString v = readValue(key);

    // ✅ 修复:使用 isNull() 判断 key 是否存在
    // 如果配置值本身就是空字符串,应该返回空字符串而不是默认值
    // 但目前无法区分"不存在"和"值为空字符串"
    // 更好的做法是增加 exists() 方法
    return v.isEmpty() ? defaultValue : v;
}


// ============================================================================
// 读取整数配置
// ============================================================================

int SettingsService::getInt(const QString& key, int defaultValue) const
{
    const QString v = readValue(key);
    if (v.isEmpty()) return defaultValue;  // key 不存在,返回默认值

    bool ok = false;
    const int n = v.toInt(&ok);
    return ok ? n : defaultValue;
}


// ============================================================================
// 读取浮点数配置
// ============================================================================

double SettingsService::getDouble(const QString& key, double defaultValue) const
{
    const QString v = readValue(key);
    if (v.isEmpty()) return defaultValue;  // key 不存在,返回默认值

    bool ok = false;
    const double n = v.toDouble(&ok);
    return ok ? n : defaultValue;
}


// ============================================================================
// 读取布尔值配置
// ============================================================================

bool SettingsService::getBool(const QString& key, bool defaultValue) const
{
    const QString v = readValue(key);
    if (v.isEmpty()) return defaultValue;  // key 不存在,返回默认值

    // ✅ 支持更多真值格式
    // 1. 数字:非 0 为真
    // 2. 字符串:"true"、"yes"、"on"、"1"(不区分大小写)
    bool ok = false;
    int intVal = v.toInt(&ok);
    if (ok) {
        return intVal != 0;
    }

    QString lower = v.toLower().trimmed();
    return (lower == "true" || lower == "yes" || lower == "on" || lower == "1");
}


// ============================================================================
// 写入字符串配置
// ============================================================================

bool SettingsService::setString(const QString& key, const QString& value)
{
    return writeValue(key, value);
}


// ============================================================================
// 写入整数配置
// ============================================================================

bool SettingsService::setInt(const QString& key, int value)
{
    return writeValue(key, QString::number(value));
}


// ============================================================================
// 写入浮点数配置
// ============================================================================

bool SettingsService::setDouble(const QString& key, double value)
{
    // ✅ 修复:使用 'g' 格式避免多余的尾部零
    // 'f' 格式会固定小数位数,'g' 格式会更紧凑
    // 例如:0.75 → "0.75"(而不是 "0.750000")
    return writeValue(key, QString::number(value, 'g', 10));
}


// ============================================================================
// 写入布尔值配置
// ============================================================================

bool SettingsService::setBool(const QString& key, bool value)
{
    return writeValue(key, value ? QStringLiteral("1") : QStringLiteral("0"));
}


// ============================================================================
// ✅ 新增:检查配置是否存在
// ============================================================================

bool SettingsService::exists(const QString& key) const
{
    if (!m_db.isOpen()) return false;

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("SELECT 1 FROM settings WHERE key=?"));
    q.addBindValue(key);

    if (!q.exec()) {
        qWarning() << "SettingsService::exists: Query failed for key" << key;
        return false;
    }

    return q.next();
}


// ============================================================================
// ✅ 新增:删除配置
// ============================================================================

bool SettingsService::remove(const QString& key)
{
    if (!m_db.isOpen()) {
        qWarning() << "SettingsService::remove: Database not open";
        return false;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("DELETE FROM settings WHERE key=?"));
    q.addBindValue(key);

    if (!q.exec()) {
        qWarning() << "SettingsService::remove: Failed for key" << key
                   << "error:" << q.lastError().text();
        return false;
    }

    // 发射信号通知变化(空值表示已删除)
    emit settingChanged(key, QString());
    return true;
}


// ============================================================================
// ✅ 新增:获取所有配置键
// ============================================================================

QStringList SettingsService::allKeys() const
{
    QStringList keys;

    if (!m_db.isOpen()) {
        qWarning() << "SettingsService::allKeys: Database not open";
        return keys;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("SELECT key FROM settings ORDER BY key"));

    if (!q.exec()) {
        qWarning() << "SettingsService::allKeys: Query failed" << q.lastError().text();
        return keys;
    }

    while (q.next()) {
        keys.append(q.value(0).toString());
    }

    return keys;
}


// ============================================================================
// ✅ 新增:获取字符串配置(区分空字符串和不存在)
// ============================================================================

QString SettingsService::getStringWithExists(const QString& key,
                                              bool* existsOut,
                                              const QString& defaultValue) const
{
    if (existsOut) *existsOut = false;

    if (!m_db.isOpen()) {
        return defaultValue;
    }

    QSqlQuery q(m_db);
    q.prepare(QStringLiteral("SELECT value FROM settings WHERE key=?"));
    q.addBindValue(key);

    if (!q.exec()) {
        qWarning() << "SettingsService::getStringWithExists: Query failed for key" << key;
        return defaultValue;
    }

    if (q.next()) {
        if (existsOut) *existsOut = true;
        return q.value(0).toString();
    }

    return defaultValue;
}

signalingservice.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QWebSocket>
#include <QWebSocketServer>
#include <QJsonObject>

class SignalingService : public QObject
{
    Q_OBJECT
    Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)
    Q_PROPERTY(bool listening READ listening NOTIFY listeningChanged)
    Q_PROPERTY(QString peer READ peer NOTIFY peerChanged)
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)
    Q_PROPERTY(int listenPort READ listenPort NOTIFY listeningChanged)
public:
    explicit SignalingService(QObject* parent = nullptr);

    bool connected() const { return m_connected; }
    bool listening() const { return m_listening; }
    QString peer() const { return m_peer; }
    QString lastError() const { return m_lastError; }
    int listenPort() const { return m_listenPort; }

    Q_INVOKABLE void startServer(int port);
    Q_INVOKABLE void stopServer();

    Q_INVOKABLE void connectToHost(const QString& host, int port);
    Q_INVOKABLE void disconnectFromHost();

    Q_INVOKABLE void sendJson(const QJsonObject& obj);

signals:
    void connectedChanged();
    void listeningChanged();
    void peerChanged();
    void lastErrorChanged();

    void jsonReceived(const QJsonObject& obj);

private slots:
    void onNewConnection();
    void onTextMessageReceived(const QString& msg);
    void onSocketConnected();
    void onSocketDisconnected();
    void onSocketError(QAbstractSocket::SocketError error);

private:
    void setConnected(bool v);
    void setListening(bool v);
    void setPeer(const QString& p);
    void setLastError(const QString& e);

    void attachSocket(QWebSocket* sock);
    void cleanupSocket();

    QWebSocketServer* m_server = nullptr;
    QWebSocket* m_socket = nullptr;

    bool m_connected = false;
    bool m_listening = false;
    int m_listenPort = 0;
    QString m_peer;
    QString m_lastError;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 WebSocket 客户端头文件(用于连接对端)
#include <QWebSocket>

// 包含 WebSocket 服务器头文件(用于监听连接)
#include <QWebSocketServer>

// 包含 JSON 对象头文件(用于收发信令消息)
#include <QJsonObject>


// ============================================================================
// SignalingService 类定义
// ============================================================================

// SignalingService 继承自 QObject,负责 WebSocket 信令通信
// 它既可以是服务端(内机监听),也可以是客户端(外机连接)
// 信令用于:呼叫、接听、挂断、远程开门等控制消息
class SignalingService : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 连接状态 -----
    // 是否已连接到对端(客户端模式)
    Q_PROPERTY(bool connected READ connected NOTIFY connectedChanged)

    // ----- 监听状态 -----
    // 是否正在监听(服务端模式)
    Q_PROPERTY(bool listening READ listening NOTIFY listeningChanged)

    // ----- 对端信息 -----
    // 对端地址(IP:端口)
    Q_PROPERTY(QString peer READ peer NOTIFY peerChanged)

    // ----- 错误信息 -----
    Q_PROPERTY(QString lastError READ lastError NOTIFY lastErrorChanged)

    // ----- 监听端口 -----
    Q_PROPERTY(int listenPort READ listenPort NOTIFY listeningChanged)

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit SignalingService(QObject* parent = nullptr);

    // ---------- 属性 getter ----------

    bool connected() const { return m_connected; }      // 是否已连接
    bool listening() const { return m_listening; }      // 是否在监听
    QString peer() const { return m_peer; }             // 对端地址
    QString lastError() const { return m_lastError; }   // 最后一个错误
    int listenPort() const { return m_listenPort; }     // 监听端口

    // ---------- Q_INVOKABLE 方法(QML 可调用) ----------

    // ---- 服务端模式 ----
    // 启动 WebSocket 服务器,监听指定端口
    // port: 监听端口(如 12345)
    Q_INVOKABLE void startServer(int port);

    // 停止 WebSocket 服务器
    Q_INVOKABLE void stopServer();

    // ---- 客户端模式 ----
    // 连接到指定的 WebSocket 服务器
    // host: 服务器 IP 或域名(如 "10.11.100.207")
    // port: 服务器端口(如 12345)
    Q_INVOKABLE void connectToHost(const QString& host, int port);

    // 断开与服务器的连接
    Q_INVOKABLE void disconnectFromHost();

    // ---- 消息发送 ----
    // 发送 JSON 消息到对端
    // obj: 要发送的 JSON 对象
    Q_INVOKABLE void sendJson(const QJsonObject& obj);

// ============================================================================
// 信号
// ============================================================================

signals:
    // ----- 状态变化信号 -----
    void connectedChanged();       // 连接状态变化
    void listeningChanged();       // 监听状态变化
    void peerChanged();            // 对端地址变化
    void lastErrorChanged();       // 错误信息变化

    // ----- 消息接收信号 -----
    // 当收到对端的 JSON 消息时发射
    // obj: 收到的 JSON 对象
    void jsonReceived(const QJsonObject& obj);

// ============================================================================
// 私有槽函数
// ============================================================================

private slots:
    // ---- 服务端槽函数 ----
    // 当有新客户端连接时调用
    void onNewConnection();

    // ---- 客户端槽函数 ----
    // 收到文本消息时调用
    void onTextMessageReceived(const QString& msg);

    // 连接成功时调用
    void onSocketConnected();

    // 连接断开时调用
    void onSocketDisconnected();

    // 发生错误时调用
    void onSocketError(QAbstractSocket::SocketError error);

// ============================================================================
// 私有方法
// ============================================================================

private:
    // ---------- 状态设置方法 ----------
    void setConnected(bool v);
    void setListening(bool v);
    void setPeer(const QString& p);
    void setLastError(const QString& e);

    // ---------- 连接管理方法 ----------
    // 绑定 WebSocket 对象(客户端或服务端接收的连接)
    void attachSocket(QWebSocket* sock);

    // 清理当前 WebSocket 连接
    void cleanupSocket();

// ============================================================================
// 私有成员变量
// ============================================================================

    // ---------- WebSocket 对象 ----------

    QWebSocketServer* m_server = nullptr;   // WebSocket 服务器(服务端模式)
    QWebSocket* m_socket = nullptr;         // WebSocket 客户端(客户端模式)

    // ---------- 状态 ----------

    bool m_connected = false;               // 是否已连接
    bool m_listening = false;               // 是否在监听
    int m_listenPort = 0;                   // 监听端口
    QString m_peer;                         // 对端地址
    QString m_lastError;                    // 最后一个错误信息
};

工作模式

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                  SignalingService 工作模式                      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  模式一:服务端(内机)                                        │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  startServer(12345)                                    │   │
│  │      ↓                                                │   │
│  │  QWebSocketServer 监听 0.0.0.0:12345                  │   │
│  │      ↓                                                │   │
│  │  onNewConnection() → 接受外机连接                     │   │
│  │      ↓                                                │   │
│  │  attachSocket() → 绑定 WebSocket                      │   │
│  │      ↓                                                │   │
│  │  收发 JSON 消息                                        │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  模式二:客户端(外机)                                        │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │  connectToHost("10.11.100.207", 12345)                 │   │
│  │      ↓                                                │   │
│  │  创建 QWebSocket 并连接                                │   │
│  │      ↓                                                │   │
│  │  onSocketConnected() → 连接成功                       │   │
│  │      ↓                                                │   │
│  │  收发 JSON 消息                                        │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘

预期消息类型

类型 发送者 说明
RING 外机 呼叫内机
CALL_ACCEPT 内机 接听呼叫
CALL_REJECT 内机 拒绝呼叫
CALL_END 任意 挂断通话
BUSY 内机 对方忙
UNLOCK_GRANTED 内机 授权开门
UNLOCK_DENIED 内机 拒绝开门
UNLOCK_RESULT 外机 开门结果
VIDEO_FRAME 任意 视频帧数据
AUDIO_PCM 任意 音频数据

signalingservice.cpp

无注释版

cpp 复制代码
#include "signalingservice.h"

#include <QJsonDocument>
#include <QHostAddress>
#include <QUrl>
#include <QDebug>

SignalingService::SignalingService(QObject* parent)
    : QObject(parent)
{
}

static QUrl buildWsUrl(const QString& hostRaw, int port)
{
    QString host = hostRaw.trimmed();
    QUrl url;
    url.setScheme(QStringLiteral("ws"));
    url.setHost(host);
    url.setPort(port);
    url.setPath(QStringLiteral("/")); // ✅ 有些环境要求 path,显式给一个
    return url;
}

void SignalingService::startServer(int port)
{
    stopServer();

    if (port <= 0 || port > 65535) {
        setLastError(QStringLiteral("Invalid port: %1").arg(port));
        return;
    }

    m_server = new QWebSocketServer(QStringLiteral("IntercomSignaling"),
                                    QWebSocketServer::NonSecureMode,
                                    this);

    if (!m_server->listen(QHostAddress::Any, quint16(port))) {
        setLastError(QStringLiteral("Listen failed on port %1: %2")
                         .arg(port)
                         .arg(m_server->errorString()));
        stopServer();
        return;
    }

    m_listenPort = port;
    connect(m_server, &QWebSocketServer::newConnection, this, &SignalingService::onNewConnection);

    setListening(true);
    setLastError(QString());
    qInfo() << "[Signaling] server listening on" << port;
}

void SignalingService::stopServer()
{
    cleanupSocket();

    if (m_server) {
        m_server->close();
        m_server->deleteLater();
        m_server = nullptr;
    }

    if (m_listening) {
        m_listening = false;
        emit listeningChanged();
    }
    m_listenPort = 0;
}

void SignalingService::connectToHost(const QString& host, int port)
{
    // ✅ 参数校验 + 立即反馈"正在连接"
    const QString h = host.trimmed();
    if (h.isEmpty()) {
        setLastError(QStringLiteral("Host is empty."));
        return;
    }
    if (port <= 0 || port > 65535) {
        setLastError(QStringLiteral("Invalid port: %1").arg(port));
        return;
    }

    cleanupSocket(); // 会 setConnected(false)

    if (!m_socket) {
        m_socket = new QWebSocket();
        attachSocket(m_socket);
    }

    const QUrl url = buildWsUrl(h, port);
    if (!url.isValid()) {
        setLastError(QStringLiteral("Invalid ws url: %1").arg(url.toString()));
        return;
    }

    setPeer(url.toString());
    setLastError(QStringLiteral("Connecting to %1 ...").arg(url.toString()));
    qInfo() << "[Signaling] connectToHost" << url;

    // ✅ 主动置状态:连接中视为未连接,等 connected 信号来再置 true
    setConnected(false);
    m_socket->open(url);
}

void SignalingService::disconnectFromHost()
{
    cleanupSocket();
}

void SignalingService::sendJson(const QJsonObject& obj)
{
    if (!m_socket || !m_connected) return;
    QJsonDocument doc(obj);
    m_socket->sendTextMessage(QString::fromUtf8(doc.toJson(QJsonDocument::Compact)));
}

void SignalingService::onNewConnection()
{
    if (!m_server) return;

    // Only keep one peer for demo
    QWebSocket* sock = m_server->nextPendingConnection();
    cleanupSocket();

    m_socket = sock;
    attachSocket(m_socket);

    setPeer(QStringLiteral("%1:%2")
                .arg(sock->peerAddress().toString(),
                     QString::number(sock->peerPort())));

    setConnected(true);
    setLastError(QString());

    qInfo() << "[Signaling] incoming peer connected:" << m_peer;
}

void SignalingService::onTextMessageReceived(const QString& msg)
{
    const auto doc = QJsonDocument::fromJson(msg.toUtf8());
    if (!doc.isObject()) return;
    emit jsonReceived(doc.object());
}

void SignalingService::onSocketConnected()
{
    setConnected(true);
    setLastError(QString());
    qInfo() << "[Signaling] WS connected to" << (m_socket ? m_socket->requestUrl().toString() : QString());
}

void SignalingService::onSocketDisconnected()
{
    setConnected(false);
    if (m_lastError.isEmpty())
        setLastError(QStringLiteral("Disconnected."));
    qInfo() << "[Signaling] WS disconnected";
}

void SignalingService::onSocketError(QAbstractSocket::SocketError)
{
    if (!m_socket) return;

    // ✅ 发生错误时明确置为未连接,并把错误暴露出来
    setConnected(false);
    setLastError(m_socket->errorString());
    qWarning() << "[Signaling] WS error:" << m_socket->errorString()
               << "url=" << m_socket->requestUrl();
}

void SignalingService::attachSocket(QWebSocket* sock)
{
    // ✅ 防止重复连接信号(尤其你反复 connect/disconnect 的时候)
    QObject::disconnect(sock, nullptr, this, nullptr);

    connect(sock, &QWebSocket::textMessageReceived, this, &SignalingService::onTextMessageReceived);
    connect(sock, &QWebSocket::connected, this, &SignalingService::onSocketConnected);
    connect(sock, &QWebSocket::disconnected, this, &SignalingService::onSocketDisconnected);
    connect(sock, &QWebSocket::errorOccurred, this, &SignalingService::onSocketError);
}

void SignalingService::cleanupSocket()
{
    if (m_socket) {
        // 不要用 m_socket->disconnect(this) 那种"wildcard disconnect"写法,容易出你之前截图那种警告
        QObject::disconnect(m_socket, nullptr, this, nullptr);
        m_socket->close();
        m_socket->deleteLater();
        m_socket = nullptr;
    }

    setConnected(false);
    setPeer(QString());
}

void SignalingService::setConnected(bool v)
{
    if (m_connected == v) return;
    m_connected = v;
    emit connectedChanged();
}

void SignalingService::setListening(bool v)
{
    if (m_listening == v) return;
    m_listening = v;
    emit listeningChanged();
}

void SignalingService::setPeer(const QString& p)
{
    if (m_peer == p) return;
    m_peer = p;
    emit peerChanged();
}

void SignalingService::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "signalingservice.h"

#include <QJsonDocument>        // JSON 文档(用于解析和生成 JSON)
#include <QHostAddress>         // IP 地址(用于服务端监听)
#include <QUrl>                 // URL(用于构建 WebSocket 连接地址)
#include <QDebug>               // 调试输出


// ============================================================================
// 构造函数
// ============================================================================

SignalingService::SignalingService(QObject* parent)
    : QObject(parent)            // 调用基类 QObject 的构造函数
{
    // 构造函数为空,所有初始化在 startServer() 或 connectToHost() 中完成
}


// ============================================================================
// 辅助函数:构建 WebSocket URL
// ============================================================================

static QUrl buildWsUrl(const QString& hostRaw, int port)
{
    // 去除首尾空格
    QString host = hostRaw.trimmed();

    // 创建 URL 对象
    QUrl url;

    // 设置协议为 ws(WebSocket)
    url.setScheme(QStringLiteral("ws"));

    // 设置主机地址
    url.setHost(host);

    // 设置端口
    url.setPort(port);

    // 设置路径为 "/"
    // 有些 WebSocket 服务器要求必须有路径,显式设置避免问题
    url.setPath(QStringLiteral("/"));

    return url;
}


// ============================================================================
// 启动 WebSocket 服务器(内机模式)
// ============================================================================

void SignalingService::startServer(int port)
{
    // ---- 1. 停止已有服务器 ----
    stopServer();

    // ---- 2. 参数校验 ----
    if (port <= 0 || port > 65535) {
        setLastError(QStringLiteral("Invalid port: %1").arg(port));
        return;
    }

    // ---- 3. 创建 WebSocket 服务器 ----
    // 参数:服务器名称、安全模式(非加密)、父对象
    m_server = new QWebSocketServer(QStringLiteral("IntercomSignaling"),
                                    QWebSocketServer::NonSecureMode,
                                    this);

    // ---- 4. 开始监听 ----
    // QHostAddress::Any 监听所有网络接口(0.0.0.0)
    // 这样内外机在同一局域网即可连接
    if (!m_server->listen(QHostAddress::Any, quint16(port))) {
        // 监听失败,记录错误
        setLastError(QStringLiteral("Listen failed on port %1: %2")
                         .arg(port)
                         .arg(m_server->errorString()));
        // 清理服务器对象
        stopServer();
        return;
    }

    // ---- 5. 保存端口并连接信号 ----
    m_listenPort = port;

    // 当有新客户端连接时,调用 onNewConnection
    connect(m_server, &QWebSocketServer::newConnection,
            this, &SignalingService::onNewConnection);

    // ---- 6. 更新状态 ----
    setListening(true);
    setLastError(QString());

    // 输出日志
    qInfo() << "[Signaling] server listening on" << port;
}


// ============================================================================
// 停止 WebSocket 服务器
// ============================================================================

void SignalingService::stopServer()
{
    // ---- 1. 清理当前连接 ----
    cleanupSocket();

    // ---- 2. 关闭并删除服务器 ----
    if (m_server) {
        m_server->close();        // 停止监听
        m_server->deleteLater();  // 延迟删除(安全)
        m_server = nullptr;
    }

    // ---- 3. 更新状态 ----
    if (m_listening) {
        m_listening = false;
        emit listeningChanged();
    }
    m_listenPort = 0;
}


// ============================================================================
// 连接到 WebSocket 服务器(外机模式)
// ============================================================================

void SignalingService::connectToHost(const QString& host, int port)
{
    // ---- 1. 参数校验 ----
    const QString h = host.trimmed();
    if (h.isEmpty()) {
        setLastError(QStringLiteral("Host is empty."));
        return;
    }
    if (port <= 0 || port > 65535) {
        setLastError(QStringLiteral("Invalid port: %1").arg(port));
        return;
    }

    // ---- 2. 清理旧连接 ----
    cleanupSocket();  // 会 setConnected(false)

    // ---- 3. 创建 WebSocket 客户端 ----
    if (!m_socket) {
        m_socket = new QWebSocket();
        attachSocket(m_socket);
    }

    // ---- 4. 构建 WebSocket URL ----
    const QUrl url = buildWsUrl(h, port);
    if (!url.isValid()) {
        setLastError(QStringLiteral("Invalid ws url: %1").arg(url.toString()));
        return;
    }

    // ---- 5. 保存对端信息 ----
    setPeer(url.toString());
    setLastError(QStringLiteral("Connecting to %1 ...").arg(url.toString()));

    qInfo() << "[Signaling] connectToHost" << url;

    // ---- 6. 发起连接 ----
    // 先置为未连接,等 onSocketConnected 信号来再置 true
    setConnected(false);

    // open() 开始连接
    m_socket->open(url);
}


// ============================================================================
// 断开与服务器的连接
// ============================================================================

void SignalingService::disconnectFromHost()
{
    // 清理当前连接
    cleanupSocket();
}


// ============================================================================
// 发送 JSON 消息
// ============================================================================

void SignalingService::sendJson(const QJsonObject& obj)
{
    // ---- 1. 检查连接状态 ----
    if (!m_socket || !m_connected) return;

    // ---- 2. 转换为 JSON 字符串 ----
    // QJsonDocument::Compact 紧凑格式(无空格,减少传输量)
    QJsonDocument doc(obj);
    QString msg = QString::fromUtf8(doc.toJson(QJsonDocument::Compact));

    // ---- 3. 发送文本消息 ----
    m_socket->sendTextMessage(msg);
}


// ============================================================================
// 槽函数:有新客户端连接(服务端)
// ============================================================================

void SignalingService::onNewConnection()
{
    if (!m_server) return;

    // ---- 1. 接受新连接 ----
    // 注意:演示版本只保留一个对端连接
    // 如果有新连接,断开旧连接
    QWebSocket* sock = m_server->nextPendingConnection();

    // 清理旧连接
    cleanupSocket();

    // ---- 2. 保存新连接 ----
    m_socket = sock;
    attachSocket(m_socket);

    // ---- 3. 记录对端信息 ----
    setPeer(QStringLiteral("%1:%2")
                .arg(sock->peerAddress().toString(),
                     QString::number(sock->peerPort())));

    // ---- 4. 更新状态 ----
    setConnected(true);
    setLastError(QString());

    qInfo() << "[Signaling] incoming peer connected:" << m_peer;
}


// ============================================================================
// 槽函数:收到文本消息
// ============================================================================

void SignalingService::onTextMessageReceived(const QString& msg)
{
    // ---- 1. 解析 JSON ----
    const auto doc = QJsonDocument::fromJson(msg.toUtf8());

    // ---- 2. 检查是否为 JSON 对象 ----
    if (!doc.isObject()) return;

    // ---- 3. 发射信号 ----
    // CallController 会监听此信号并处理消息
    emit jsonReceived(doc.object());
}


// ============================================================================
// 槽函数:连接成功(客户端)
// ============================================================================

void SignalingService::onSocketConnected()
{
    // 更新状态
    setConnected(true);
    setLastError(QString());

    qInfo() << "[Signaling] WS connected to"
            << (m_socket ? m_socket->requestUrl().toString() : QString());
}


// ============================================================================
// 槽函数:连接断开
// ============================================================================

void SignalingService::onSocketDisconnected()
{
    // 更新状态
    setConnected(false);

    // 如果没有错误信息,设置默认断开信息
    if (m_lastError.isEmpty()) {
        setLastError(QStringLiteral("Disconnected."));
    }

    qInfo() << "[Signaling] WS disconnected";
}


// ============================================================================
// 槽函数:发生错误
// ============================================================================

void SignalingService::onSocketError(QAbstractSocket::SocketError)
{
    if (!m_socket) return;

    // ---- 1. 更新状态 ----
    setConnected(false);

    // ---- 2. 记录错误 ----
    setLastError(m_socket->errorString());

    qWarning() << "[Signaling] WS error:" << m_socket->errorString()
               << "url=" << m_socket->requestUrl();
}


// ============================================================================
// 辅助方法:绑定 WebSocket 信号
// ============================================================================

void SignalingService::attachSocket(QWebSocket* sock)
{
    // ---- 1. 断开所有旧连接 ----
    // 防止重复连接信号导致多次触发
    QObject::disconnect(sock, nullptr, this, nullptr);

    // ---- 2. 连接信号 ----
    // 收到文本消息 → onTextMessageReceived
    connect(sock, &QWebSocket::textMessageReceived,
            this, &SignalingService::onTextMessageReceived);

    // 连接成功 → onSocketConnected
    connect(sock, &QWebSocket::connected,
            this, &SignalingService::onSocketConnected);

    // 连接断开 → onSocketDisconnected
    connect(sock, &QWebSocket::disconnected,
            this, &SignalingService::onSocketDisconnected);

    // 发生错误 → onSocketError
    connect(sock, &QWebSocket::errorOccurred,
            this, &SignalingService::onSocketError);
}


// ============================================================================
// 辅助方法:清理 WebSocket 连接
// ============================================================================

void SignalingService::cleanupSocket()
{
    if (m_socket) {
        // ---- 1. 断开所有信号连接 ----
        QObject::disconnect(m_socket, nullptr, this, nullptr);

        // ---- 2. 关闭连接 ----
        m_socket->close();

        // ---- 3. 延迟删除 ----
        m_socket->deleteLater();
        m_socket = nullptr;
    }

    // ---- 4. 更新状态 ----
    setConnected(false);
    setPeer(QString());
}


// ============================================================================
// 私有方法:设置连接状态
// ============================================================================

void SignalingService::setConnected(bool v)
{
    if (m_connected == v) return;
    m_connected = v;
    emit connectedChanged();      // 通知 QML 更新
}


// ============================================================================
// 私有方法:设置监听状态
// ============================================================================

void SignalingService::setListening(bool v)
{
    if (m_listening == v) return;
    m_listening = v;
    emit listeningChanged();      // 通知 QML 更新
}


// ============================================================================
// 私有方法:设置对端地址
// ============================================================================

void SignalingService::setPeer(const QString& p)
{
    if (m_peer == p) return;
    m_peer = p;
    emit peerChanged();           // 通知 QML 更新
}


// ============================================================================
// 私有方法:设置错误信息
// ============================================================================

void SignalingService::setLastError(const QString& e)
{
    if (m_lastError == e) return;
    m_lastError = e;
    emit lastErrorChanged();      // 通知 QML 更新
}

工作模式总结

cpp 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                  SignalingService 工作流程                      │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  【内机 - 服务端模式】                                         │
│  1. startServer(12345)                                         │
│  2. QWebSocketServer 开始监听                                  │
│  3. 外机连接 → onNewConnection()                              │
│  4. attachSocket() 绑定连接                                   │
│  5. 收发 JSON 消息                                            │
│                                                                 │
│  【外机 - 客户端模式】                                         │
│  1. connectToHost("10.11.100.207", 12345)                     │
│  2. QWebSocket 发起连接                                       │
│  3. 连接成功 → onSocketConnected()                            │
│  4. 收发 JSON 消息                                            │
│                                                                 │
│  【消息处理】                                                  │
│  收到消息 → onTextMessageReceived()                           │
│         ↓                                                     │
│  解析 JSON → 发射 jsonReceived() 信号                         │
│         ↓                                                     │
│  CallController 处理消息                                      │
└─────────────────────────────────────────────────────────────────┘

关键知识点总结

知识点 说明
QWebSocketServer WebSocket 服务端,监听连接
QWebSocket WebSocket 客户端,连接服务器
QWebSocketServer::listen() 开始监听指定端口
QWebSocket::open(url) 连接到 WebSocket 服务器
QWebSocket::sendTextMessage() 发送文本消息
QWebSocket::textMessageReceived 收到文本消息信号
QWebSocket::connected 连接成功信号
QWebSocket::disconnected 连接断开信号
QWebSocket::errorOccurred 发生错误信号
QJsonDocument::toJson() JSON 转字符串
QJsonDocument::fromJson() 字符串转 JSON

videostreamer.h

无注释版

cpp 复制代码
#pragma once

#include <QObject>
#include <QImage>
#include <QVideoFrame>
#include <QJsonObject>

class QCamera;
class QMediaCaptureSession;
class QVideoSink;

class SignalingService;

class VideoStreamer : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString remoteFrameDataUrl READ remoteFrameDataUrl NOTIFY remoteFrameDataUrlChanged)
    Q_PROPERTY(bool streaming READ streaming WRITE setStreaming NOTIFY streamingChanged)

public:
    explicit VideoStreamer(QObject* parent = nullptr);

    Q_INVOKABLE void setLocalVideoSink(QObject* sinkObject);

    Q_INVOKABLE void setStreaming(bool on);
    bool streaming() const { return m_streaming; }

    void setSignaling(SignalingService* s) { m_signaling = s; }
    void setRole(const QString& r) { m_role = r; }
    void setSessionId(const QString& sid) { m_sessionId = sid; }

    QImage latestFrame() const { return m_latestFrame; }

    void handleIncomingFrame(const QJsonObject& payload);

    QString remoteFrameDataUrl() const { return m_remoteFrameDataUrl; }

    // ✅ 新增:挂断/未通话时清空对端画面
    Q_INVOKABLE void clearRemoteFrame();

signals:
    void remoteFrameDataUrlChanged();
    void streamingChanged();

private slots:
    void onVideoFrameChanged(const QVideoFrame& frame);

private:
    void startCameraIfNeeded();
    void stopCamera();
    void sendFrameJpeg(const QImage& img);

private:
    SignalingService* m_signaling = nullptr;

    QCamera* m_camera = nullptr;
    QMediaCaptureSession* m_cap = nullptr;

    QVideoSink* m_qmlSink = nullptr;

    bool m_streaming = false;
    QString m_role;
    QString m_sessionId;

    QImage m_latestFrame;
    QString m_remoteFrameDataUrl;

    int m_sendEveryNFrames = 3;
    int m_frameCounter = 0;
};

有注释版

cpp 复制代码
// ============================================================================
// 头文件保护和包含
// ============================================================================

// #pragma once 确保此头文件只被编译一次
#pragma once

// 包含 QObject 基类头文件
#include <QObject>

// 包含 QImage 头文件(图像处理)
#include <QImage>

// 包含 QVideoFrame 头文件(视频帧数据)
#include <QVideoFrame>

// 包含 QJsonObject 头文件(用于收发视频帧数据)
#include <QJsonObject>

// ============================================================================
// 前置声明(减少编译依赖)
// ============================================================================

class QCamera;               // 摄像头对象
class QMediaCaptureSession;  // 媒体捕获会话(管理摄像头和视频输出)
class QVideoSink;            // 视频输出目标(用于 QML 显示)

class SignalingService;      // 信令服务(用于发送/接收视频数据)


// ============================================================================
// VideoStreamer 类定义
// ============================================================================

class VideoStreamer : public QObject
{
    // 启用信号/槽和属性系统
    Q_OBJECT

    // ========================================================================
    // Q_PROPERTY 定义(暴露给 QML 的属性)
    // ========================================================================

    // ----- 对端视频帧 -----
    // 远程视频帧的 Data URL(base64 编码的 JPEG 图片)
    // QML 中可以用 Image { source: app.video.remoteFrameDataUrl } 显示
    Q_PROPERTY(QString remoteFrameDataUrl READ remoteFrameDataUrl NOTIFY remoteFrameDataUrlChanged)

    // ----- 视频流状态 -----
    // 是否正在采集和发送视频
    Q_PROPERTY(bool streaming READ streaming WRITE setStreaming NOTIFY streamingChanged)

// ============================================================================
// 公有方法
// ============================================================================

public:
    // 构造函数
    explicit VideoStreamer(QObject* parent = nullptr);

    // ---- Q_INVOKABLE 方法(QML 可调用) ----

    // 设置本地视频显示目标
    // 在 QML 中:VideoOutput { id: localVideo } 的 videoSink 属性
    // 调用:videoStreamer.setLocalVideoSink(localVideo.videoSink)
    Q_INVOKABLE void setLocalVideoSink(QObject* sinkObject);

    // 启动或停止视频流采集
    Q_INVOKABLE void setStreaming(bool on);

    // ---- 属性 getter ----

    bool streaming() const { return m_streaming; }

    // ---- 依赖注入 ----

    void setSignaling(SignalingService* s) { m_signaling = s; }
    void setRole(const QString& r) { m_role = r; }
    void setSessionId(const QString& sid) { m_sessionId = sid; }

    // ---- 获取最新视频帧 ----

    QImage latestFrame() const { return m_latestFrame; }

    // ---- 处理接收到的视频帧 ----

    // 处理对端发来的视频帧
    // payload: JSON 对象,包含 "jpegB64" 字段(base64 编码的 JPEG)
    void handleIncomingFrame(const QJsonObject& payload);

    // ---- 获取远程帧 Data URL ----

    QString remoteFrameDataUrl() const { return m_remoteFrameDataUrl; }

    // ---- 清空远程画面 ----

    // 挂断时清空对端画面
    Q_INVOKABLE void clearRemoteFrame();

// ============================================================================
// 信号
// ============================================================================

signals:
    void remoteFrameDataUrlChanged();   // 远程帧数据变化
    void streamingChanged();            // 视频流状态变化

// ============================================================================
// 私有槽函数
// ============================================================================

private slots:
    // 当摄像头视频帧变化时调用
    void onVideoFrameChanged(const QVideoFrame& frame);

// ============================================================================
// 私有方法
// ============================================================================

private:
    // 启动摄像头(内部使用)
    void startCameraIfNeeded();

    // 停止摄像头(内部使用)
    void stopCamera();

    // 发送 JPEG 图像到对端
    void sendFrameJpeg(const QImage& img);

// ============================================================================
// 私有成员变量
// ============================================================================

    // ---------- 依赖注入 ----------

    SignalingService* m_signaling = nullptr;   // 信令服务(收发数据)

    // ---------- 摄像头相关 ----------

    QCamera* m_camera = nullptr;               // 摄像头对象
    QMediaCaptureSession* m_cap = nullptr;     // 媒体捕获会话

    QVideoSink* m_qmlSink = nullptr;           // QML 视频显示目标

    // ---------- 状态 ----------

    bool m_streaming = false;                  // 是否正在采集
    QString m_role;                            // 角色(outer/inner)
    QString m_sessionId;                       // 会话 ID

    // ---------- 视频数据 ----------

    QImage m_latestFrame;                      // 最新的视频帧
    QString m_remoteFrameDataUrl;              // 对端视频帧的 Data URL

    // ---------- 帧率控制 ----------

    int m_sendEveryNFrames = 3;                // 每 N 帧发送一次(降低带宽)
    int m_frameCounter = 0;                    // 帧计数器
};

videostreamer.cpp

无注释版

cpp 复制代码
#include "videostreamer.h"
#include "signalingservice.h"

#include <QCamera>
#include <QMediaCaptureSession>
#include <QMediaDevices>
#include <QVideoSink>
#include <QBuffer>
#include <QDateTime>
#include <QJsonObject>
#include <QDebug>

VideoStreamer::VideoStreamer(QObject* parent)
    : QObject(parent)
{
    m_cap = new QMediaCaptureSession(this);
}

void VideoStreamer::setLocalVideoSink(QObject* sinkObject)
{
    auto* sink = qobject_cast<QVideoSink*>(sinkObject);
    if (!sink) {
        qWarning() << "[Video] setLocalVideoSink: sinkObject is not QVideoSink";
        return;
    }

    if (m_qmlSink == sink) return;

    if (m_qmlSink) {
        disconnect(m_qmlSink, nullptr, this, nullptr);
    }

    m_qmlSink = sink;
    connect(m_qmlSink, &QVideoSink::videoFrameChanged,
            this, &VideoStreamer::onVideoFrameChanged);

    m_cap->setVideoOutput(m_qmlSink);

    qInfo() << "[Video] local sink attached";
    startCameraIfNeeded();
}

void VideoStreamer::setStreaming(bool on)
{
    if (m_streaming == on) return;
    m_streaming = on;
    emit streamingChanged();

    if (m_streaming) startCameraIfNeeded();
    // 不强制 stopCamera,避免预览断掉(需要也可以自己改为 stopCamera())
}

void VideoStreamer::startCameraIfNeeded()
{
    if (!m_qmlSink) return;

    if (!m_camera) {
        const auto dev = QMediaDevices::defaultVideoInput();
        if (dev.isNull()) {
            qWarning() << "[Video] no camera device found";
            return;
        }
        m_camera = new QCamera(dev, this);
        m_cap->setCamera(m_camera);
    }

    if (m_camera && !m_camera->isActive()) {
        m_camera->start();
        qInfo() << "[Video] camera started:" << m_camera->cameraDevice().description();
    }
}

void VideoStreamer::stopCamera()
{
    if (m_camera && m_camera->isActive()) {
        m_camera->stop();
        qInfo() << "[Video] camera stopped";
    }
}

void VideoStreamer::onVideoFrameChanged(const QVideoFrame& frame)
{
    if (!frame.isValid()) return;

    QImage img = frame.toImage();
    if (img.isNull()) return;

    m_latestFrame = img;

    // ✅ 不在通话就不发(CallController 只会在 InCall 打开 streaming)
    if (!m_streaming) return;
    if (!m_signaling || !m_signaling->connected()) return;

    // ✅ 没有 sessionId 不发,避免刚连接就把画面推过去
    if (m_sessionId.isEmpty()) return;

    m_frameCounter++;
    if (m_sendEveryNFrames > 1 && (m_frameCounter % m_sendEveryNFrames) != 0) return;

    QImage scaled = img.scaled(640, 480, Qt::KeepAspectRatio, Qt::SmoothTransformation);
    sendFrameJpeg(scaled);
}

void VideoStreamer::sendFrameJpeg(const QImage& img)
{
    if (m_sessionId.isEmpty()) return;

    QByteArray bytes;
    QBuffer buf(&bytes);
    buf.open(QIODevice::WriteOnly);
    img.save(&buf, "JPG", 70);

    const QString b64 = QString::fromLatin1(bytes.toBase64());

    QJsonObject payload;
    payload["jpegB64"] = b64;
    payload["w"] = img.width();
    payload["h"] = img.height();

    QJsonObject obj;
    obj["type"] = "VIDEO_FRAME";
    obj["sessionId"] = m_sessionId;
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());
    obj["payload"] = payload;
    obj["fromRole"] = m_role;

    m_signaling->sendJson(obj);
}

void VideoStreamer::handleIncomingFrame(const QJsonObject& payload)
{
    const QString b64 = payload.value("jpegB64").toString();
    if (b64.isEmpty()) return;

    m_remoteFrameDataUrl = "data:image/jpeg;base64," + b64;
    emit remoteFrameDataUrlChanged();
}

void VideoStreamer::clearRemoteFrame()
{
    m_remoteFrameDataUrl.clear();
    emit remoteFrameDataUrlChanged();
}

有注释版

cpp 复制代码
// ============================================================================
// 包含头文件
// ============================================================================

#include "videostreamer.h"
#include "signalingservice.h"

#include <QCamera>              // 摄像头控制
#include <QMediaCaptureSession> // 媒体捕获会话
#include <QMediaDevices>        // 获取系统媒体设备
#include <QVideoSink>           // 视频输出目标
#include <QBuffer>              // 内存缓冲区(用于 JPEG 编码)
#include <QDateTime>            // 时间戳
#include <QJsonObject>          // JSON 对象(用于发送视频帧)
#include <QDebug>               // 调试输出


// ============================================================================
// 构造函数
// ============================================================================

VideoStreamer::VideoStreamer(QObject* parent)
    : QObject(parent)
{
    // ---- 创建媒体捕获会话 ----
    // 用于连接摄像头和视频输出
    // 第三个参数 this:当 VideoStreamer 被销毁时,m_cap 自动删除
    m_cap = new QMediaCaptureSession(this);
}


// ============================================================================
// 设置本地视频显示目标(QML 调用)
// ============================================================================

void VideoStreamer::setLocalVideoSink(QObject* sinkObject)
{
    // ---- 1. 检查传入的对象是否为 QVideoSink 类型 ----
    // qobject_cast 是 Qt 的安全类型转换,失败返回 nullptr
    auto* sink = qobject_cast<QVideoSink*>(sinkObject);
    if (!sink) {
        qWarning() << "[Video] setLocalVideoSink: sinkObject is not QVideoSink";
        return;
    }

    // ---- 2. 如果目标没有变化,直接返回 ----
    if (m_qmlSink == sink) return;

    // ---- 3. 断开旧连接 ----
    if (m_qmlSink) {
        disconnect(m_qmlSink, nullptr, this, nullptr);
    }

    // ---- 4. 保存新目标并连接信号 ----
    m_qmlSink = sink;

    // 当 QML VideoOutput 收到新视频帧时,调用 onVideoFrameChanged
    connect(m_qmlSink, &QVideoSink::videoFrameChanged,
            this, &VideoStreamer::onVideoFrameChanged);

    // ---- 5. 设置媒体会话的视频输出 ----
    // 摄像头采集的视频会输出到 QML 的 VideoOutput 控件
    m_cap->setVideoOutput(m_qmlSink);

    qInfo() << "[Video] local sink attached";

    // ---- 6. 启动摄像头 ----
    // 只有在 QML 目标已设置的情况下才启动
    startCameraIfNeeded();
}


// ============================================================================
// 启动/停止视频流
// ============================================================================

void VideoStreamer::setStreaming(bool on)
{
    // ---- 1. 如果状态没有变化,直接返回 ----
    if (m_streaming == on) return;

    // ---- 2. 更新状态 ----
    m_streaming = on;

    // ---- 3. 发射信号通知 QML ----
    emit streamingChanged();

    // ---- 4. 启动摄像头(如果需要) ----
    // 注意:不强制停止摄像头,避免预览画面断掉
    // 这样 QML 的 VideoOutput 可以一直显示本地画面
    if (m_streaming) startCameraIfNeeded();
}


// ============================================================================
// 启动摄像头(内部使用)
// ============================================================================

void VideoStreamer::startCameraIfNeeded()
{
    // ---- 1. 检查 QML 目标是否已设置 ----
    if (!m_qmlSink) return;

    // ---- 2. 如果摄像头对象还未创建 ----
    if (!m_camera) {
        // 获取系统默认的摄像头设备
        const auto dev = QMediaDevices::defaultVideoInput();

        // 如果没有摄像头设备
        if (dev.isNull()) {
            qWarning() << "[Video] no camera device found";
            return;
        }

        // 创建摄像头对象
        m_camera = new QCamera(dev, this);

        // 将摄像头设置到媒体捕获会话
        m_cap->setCamera(m_camera);
    }

    // ---- 3. 如果摄像头未激活,启动它 ----
    if (m_camera && !m_camera->isActive()) {
        m_camera->start();
        qInfo() << "[Video] camera started:" << m_camera->cameraDevice().description();
    }
}


// ============================================================================
// 停止摄像头(内部使用)
// ============================================================================

void VideoStreamer::stopCamera()
{
    // 如果摄像头存在且正在运行,停止它
    if (m_camera && m_camera->isActive()) {
        m_camera->stop();
        qInfo() << "[Video] camera stopped";
    }
}


// ============================================================================
// 槽函数:视频帧变化时调用
// ============================================================================

void VideoStreamer::onVideoFrameChanged(const QVideoFrame& frame)
{
    // ---- 1. 检查帧是否有效 ----
    if (!frame.isValid()) return;

    // ---- 2. 转换为 QImage ----
    // toImage() 是 Qt 6 的方法,将 QVideoFrame 转换为 QImage
    QImage img = frame.toImage();
    if (img.isNull()) return;

    // ---- 3. 保存最新帧 ----
    // 用于人脸识别等需要获取当前画面的功能
    m_latestFrame = img;

    // ---- 4. 检查是否需要发送 ----
    // 如果不在通话状态,不发送(只显示本地预览)
    if (!m_streaming) return;

    // 如果没有信令服务或未连接,无法发送
    if (!m_signaling || !m_signaling->connected()) return;

    // 如果没有会话 ID,不发送(避免刚连接就把画面推过去)
    if (m_sessionId.isEmpty()) return;

    // ---- 5. 帧率控制 ----
    // m_sendEveryNFrames = 3,每 3 帧发送 1 帧
    // 减少网络带宽占用
    m_frameCounter++;
    if (m_sendEveryNFrames > 1 && (m_frameCounter % m_sendEveryNFrames) != 0) return;

    // ---- 6. 缩放图像 ----
    // 缩放到 640x480,减少传输数据量
    QImage scaled = img.scaled(640, 480, Qt::KeepAspectRatio, Qt::SmoothTransformation);

    // ---- 7. 发送 JPEG 图像 ----
    sendFrameJpeg(scaled);
}


// ============================================================================
// 发送 JPEG 图像到对端
// ============================================================================

void VideoStreamer::sendFrameJpeg(const QImage& img)
{
    // ---- 1. 检查会话 ID ----
    if (m_sessionId.isEmpty()) return;

    // ---- 2. JPEG 压缩 ----
    // 使用 QBuffer 将图像写入内存
    QByteArray bytes;
    QBuffer buf(&bytes);
    buf.open(QIODevice::WriteOnly);

    // 保存为 JPEG 格式,质量 70%(平衡画质和大小)
    // 质量范围 0-100,70 是较好的平衡点
    img.save(&buf, "JPG", 70);

    // ---- 3. Base64 编码 ----
    // 将二进制 JPEG 数据转为 Base64 文本,便于 JSON 传输
    const QString b64 = QString::fromLatin1(bytes.toBase64());

    // ---- 4. 构建 JSON payload ----
    QJsonObject payload;
    payload["jpegB64"] = b64;      // base64 编码的 JPEG 数据
    payload["w"] = img.width();     // 图像宽度
    payload["h"] = img.height();    // 图像高度

    // ---- 5. 构建外层消息 ----
    QJsonObject obj;
    obj["type"] = "VIDEO_FRAME";      // 消息类型
    obj["sessionId"] = m_sessionId;   // 会话 ID
    obj["ts"] = static_cast<qint64>(QDateTime::currentSecsSinceEpoch());  // 时间戳
    obj["payload"] = payload;          // 视频数据
    obj["fromRole"] = m_role;          // 发送者角色

    // ---- 6. 发送 ----
    m_signaling->sendJson(obj);
}


// ============================================================================
// 处理接收到的视频帧
// ============================================================================

void VideoStreamer::handleIncomingFrame(const QJsonObject& payload)
{
    // ---- 1. 获取 base64 编码的 JPEG 数据 ----
    const QString b64 = payload.value("jpegB64").toString();
    if (b64.isEmpty()) return;

    // ---- 2. 构建 Data URL ----
    // 格式:data:image/jpeg;base64,xxxxx
    // QML 的 Image 控件可以直接使用这个 URL 显示图片
    m_remoteFrameDataUrl = "data:image/jpeg;base64," + b64;

    // ---- 3. 发射信号通知 QML 更新 ----
    emit remoteFrameDataUrlChanged();
}


// ============================================================================
// 清空远程视频帧
// ============================================================================

void VideoStreamer::clearRemoteFrame()
{
    // ---- 1. 清空 Data URL ----
    m_remoteFrameDataUrl.clear();

    // ---- 2. 发射信号通知 QML 更新 ----
    // QML 中的 Image 控件会自动隐藏或显示占位图
    emit remoteFrameDataUrlChanged();
}

CallStage.qml

无注释版

cpp 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtMultimedia
import Qt5Compat.GraphicalEffects

Item {
    id: root
    property string mode: "outer" // "outer" | "inner"
    readonly property bool inCall: app.call.state === "InCall"

    // ---------- 主题色 ----------
    readonly property color cBg: "#070A12"
    readonly property color cPanel: "#0B1220"
    readonly property color cPanel2: "#0E1A2B"
    readonly property color cStroke: "#20304A"
    readonly property color cText: "#E6F0FF"
    readonly property color cSub: "#9FB3C8"
    readonly property color cGreen: "#22C55E"
    readonly property color cRed: "#FB7185"
    readonly property color cAmber: "#FBBF24"
    readonly property color cCyan: "#22D3EE"
    readonly property color cBlue: "#60A5FA"
    readonly property color cPurple: "#A78BFA"

    // ---------- 通用按钮 ----------
    component TechButton: Button {
        property color accent: root.cBlue
        property bool danger: false
        property bool ghost: false

        font.pixelSize: 15
        padding: 10

        background: Rectangle {
            radius: 14
            border.width: 1
            border.color: parent.enabled
                          ? (parent.ghost ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.22))
                          : Qt.rgba(1,1,1,0.10)
            color: parent.ghost
                   ? Qt.rgba(0,0,0,0)
                   : (parent.enabled
                      ? (parent.danger ? Qt.rgba(0.98,0.33,0.45,0.22) : Qt.rgba(0.38,0.65,0.98,0.16))
                      : Qt.rgba(0.2,0.25,0.35,0.18))

            Rectangle {
                anchors.fill: parent
                radius: parent.radius
                gradient: Gradient {
                    GradientStop { position: 0.0; color: Qt.rgba(1,1,1,0.10) }
                    GradientStop { position: 0.5; color: Qt.rgba(1,1,1,0.03) }
                    GradientStop { position: 1.0; color: Qt.rgba(0,0,0,0.10) }
                }
                opacity: parent.enabled ? 1 : 0.5
            }
        }

        contentItem: Label {
            text: parent.text
            color: parent.enabled ? root.cText : Qt.rgba(1,1,1,0.45)
            font.pixelSize: 15
            elide: Label.ElideRight
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }

        hoverEnabled: true
        onHoveredChanged: {
            if (hovered && enabled && !ghost) scale = 1.02
            else scale = 1.0
        }
        Behavior on scale { NumberAnimation { duration: 120 } }
    }

    // ---------- Drawer ----------
    Drawer {
        id: drawer
        width: Math.min(560, parent.width * 0.92)
        edge: Qt.RightEdge

        Rectangle {
            anchors.fill: parent
            radius: 18
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 14
                spacing: 12

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10
                    Label {
                        text: "更多"
                        color: root.cText
                        font.pixelSize: 18
                        Layout.fillWidth: true
                    }
                    TechButton { text: "关闭"; ghost: true; onClicked: drawer.close() }
                }

                TabBar {
                    id: drawerTabs
                    visible: root.mode === "outer"
                    Layout.fillWidth: true

                    background: Rectangle {
                        radius: 14
                        color: root.cPanel2
                        border.color: root.cStroke
                        border.width: 1
                    }

                    TabButton { text: "授权库" }
                    TabButton { text: "日志" }
                }

                Loader {
                    id: drawerContent
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    active: true
                    sourceComponent: {
                        if (root.mode === "inner") return logTabComp;
                        return (drawerTabs.currentIndex === 0) ? personTabComp : logTabComp;
                    }
                }

                Component { id: personTabComp; PersonTab { } }
                Component { id: logTabComp; LogTab { } }
            }
        }
    }

    // ---------- 来电弹窗 ----------
    Dialog {
        id: incomingDialog
        title: "来电提示"
        modal: true
        standardButtons: Dialog.NoButton
        x: (parent ? (parent.width - width) / 2 : 0)
        y: 80

        background: Rectangle {
            radius: 18
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1
        }

        // ✅ 修复:不用 ColumnLayout.padding,改成外层 Item + anchors.margins
        contentItem: Item {
            implicitWidth: 380
            implicitHeight: 160

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 14
                spacing: 12

                Label {
                    text: "外机呼叫(sessionId=" + app.call.sessionId + ")"
                    wrapMode: Text.WordWrap
                    color: root.cText
                }

                RowLayout {
                    spacing: 12
                    TechButton {
                        text: "接听"
                        accent: root.cGreen
                        onClicked: { incomingDialog.close(); app.call.accept() }
                    }
                    TechButton {
                        text: "拒绝"
                        danger: true
                        onClicked: { incomingDialog.close(); app.call.reject() }
                    }
                }
            }
        }
    }

    Connections {
        target: app.call
        function onIncomingChanged() {
            if (root.mode === "inner" && app.call.incoming)
                incomingDialog.open()
        }
    }

    Component.onCompleted: {
        app.call.setLocalVideoSink(localVideo.videoSink)
        if (app.call.video) app.call.video.setStreaming(true)

        if (root.mode === "outer" && app.face) {
            app.face.loadDefaultsFromSettings()
            app.face.enabled = true
        }
    }

    // ---------- 背景 ----------
    Rectangle {
        anchors.fill: parent
        color: root.cBg
        radius: 16
    }

    // ---------- 主舞台 ----------
    Item {
        id: stage
        anchors.fill: parent
        anchors.margins: 12

        Rectangle {
            id: mainFrame
            anchors.fill: parent
            radius: 22
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1
            clip: true

            Rectangle {
                anchors.fill: parent
                opacity: 0.40
                gradient: Gradient {
                    GradientStop { position: 0.0; color: Qt.rgba(0.13,0.85,0.95,0.12) }
                    GradientStop { position: 0.5; color: Qt.rgba(0.38,0.65,0.98,0.05) }
                    GradientStop { position: 1.0; color: Qt.rgba(0.65,0.55,0.98,0.10) }
                }
            }

            Image {
                anchors.fill: parent
                visible: root.inCall
                fillMode: Image.PreserveAspectFit
                cache: false
                source: (app.call.video && app.call.video.remoteFrameDataUrl) ? app.call.video.remoteFrameDataUrl : ""
            }

            Rectangle {
                id: localBox
                color: Qt.rgba(0,0,0,0.0)
                radius: root.inCall ? 16 : 0
                border.color: root.inCall ? Qt.rgba(1,1,1,0.18) : "transparent"
                border.width: root.inCall ? 1 : 0
                clip: true

                anchors.fill: root.inCall ? undefined : parent

                width: root.inCall ? Math.min(260, parent.width * 0.30) : parent.width
                height: root.inCall ? Math.min(170, parent.height * 0.30) : parent.height
                anchors.right: root.inCall ? parent.right : undefined
                anchors.bottom: root.inCall ? parent.bottom : undefined
                anchors.margins: root.inCall ? 14 : 0

                DropShadow {
                    anchors.fill: localBox
                    horizontalOffset: 0
                    verticalOffset: 6
                    radius: 16
                    samples: 25
                    color: Qt.rgba(0, 0, 0, root.inCall ? 0.55 : 0.0)
                    source: localBox
                    visible: root.inCall
                }

                VideoOutput {
                    id: localVideo
                    anchors.fill: parent
                    fillMode: VideoOutput.PreserveAspectCrop
                    transform: Scale {
                        xScale: -1; yScale: 1
                        origin.x: localVideo.width/2
                        origin.y: localVideo.height/2
                    }
                }

            }

            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.top: parent.top
                anchors.margins: 12
                height: 52
                radius: 18
                color: Qt.rgba(0.06,0.09,0.14,0.75)
                border.color: Qt.rgba(1,1,1,0.14)
                border.width: 1

                RowLayout {
                    anchors.fill: parent
                    anchors.margins: 12
                    spacing: 10

                    Rectangle {
                        width: 10; height: 10
                        radius: 5
                        color: app.signaling.connected ? root.cGreen : (app.signaling.listening ? root.cAmber : root.cRed)
                    }

                    Label {
                        text: (root.mode === "outer" ? "外机" : "内机")
                              + " · " + app.call.state
                              + (root.inCall ? (" · " + app.call.callSeconds + "s") : "")
                        color: root.cText
                        font.pixelSize: 16
                        Layout.fillWidth: true
                        elide: Label.ElideRight
                    }

                }
            }

            // ✅ 修复:中央提示不再用 Rectangle.padding
            Rectangle {
                id: tipBox
                anchors.centerIn: parent
                radius: 14
                color: Qt.rgba(0.02,0.04,0.07,0.65)
                border.color: Qt.rgba(1,1,1,0.12)
                border.width: 1
                visible: root.inCall &&
                         (!app.call.video || !app.call.video.remoteFrameDataUrl || app.call.video.remoteFrameDataUrl === "")

                // 用隐式大小 + margins 模拟 padding
                implicitWidth: tipLabel.implicitWidth + 26
                implicitHeight: tipLabel.implicitHeight + 18

                Label {
                    id: tipLabel
                    anchors.centerIn: parent
                    text: "暂无对端画面"
                    color: root.cSub
                    font.pixelSize: 14
                }
            }
        }
    }

    // ---------- ✅ 外机:密码解锁弹窗(键盘UI版) ----------
    Dialog {
        id: pwdDlg
        modal: true
        standardButtons: Dialog.NoButton

        // 尺寸参考你的截图
        width: Math.min(520, parent.width - 24)
        height: 640
        anchors.centerIn: parent

        property string pwdStr: ""

        background: Rectangle {
            radius: 14
            color: "#FFFFFF"
            border.color: "#D6DFEA"
            border.width: 1
        }

        function appendDigit(d) {
            if (pwdStr.length >= 6) return
            pwdStr = pwdStr + d
        }

        function clearAll() {
            pwdStr = ""
        }

        function doConfirm() {
            if (pwdStr.length !== 6) {
                // 这里不弹错也行;你想弹错我也能给你做一个 toast
                return
            }
            app.doorLock.unlockByPassword(pwdStr)
            pwdStr = ""
            pwdDlg.close()
        }

        contentItem: Item {
            anchors.fill: parent

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 18
                spacing: 14

                // 标题
                Label {
                    text: "密码开锁"
                    color: "#111827"
                    font.pixelSize: 22
                    font.bold: true
                    Layout.alignment: Qt.AlignLeft
                }

                // 输入显示框(大框)
                Rectangle {
                    Layout.fillWidth: true
                    height: 76
                    radius: 12
                    color: "#F8FAFC"
                    border.color: "#D6DFEA"
                    border.width: 1

                    RowLayout {
                        anchors.fill: parent
                        anchors.margins: 14
                        spacing: 10

                        // 显示:●●●●●●
                        Label {
                            Layout.fillWidth: true
                            font.pixelSize: 26
                            color: "#111827"
                            text: {
                                // 6位掩码显示
                                var dots = ""
                                for (var i=0; i<pwdDlg.pwdStr.length; i++) dots += "●"
                                return dots
                            }
                            elide: Label.ElideRight
                            verticalAlignment: Text.AlignVCenter
                        }

                        // 右侧显示"位数"
                        Label {
                            font.pixelSize: 14
                            color: "#6B7280"
                            text: pwdDlg.pwdStr.length + "/6"
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                }

                // 键盘区域
                GridLayout {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    columns: 3
                    rowSpacing: 14
                    columnSpacing: 14

                    // --- 统一按钮组件(截图那种扁平大按钮) ---
                    component KeyBtn: Button {
                        property color bg: "#3498DB"     // 蓝
                        property color fg: "#FFFFFF"
                        property int fs: 24
                        text: ""
                        font.pixelSize: fs
                        font.bold: true
                        background: Rectangle {
                            radius: 12
                            color: parent.enabled ? parent.bg : "#CBD5E1"
                        }
                        contentItem: Label {
                            text: parent.text
                            color: parent.enabled ? parent.fg : "#FFFFFF"
                            font.pixelSize: parent.fs
                            font.bold: true
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }

                    // 1~9
                    KeyBtn { text: "1"; onClicked: pwdDlg.appendDigit("1"); Layout.fillWidth: true; Layout.fillHeight: true }
                    KeyBtn { text: "2"; onClicked: pwdDlg.appendDigit("2"); Layout.fillWidth: true; Layout.fillHeight: true }
                    KeyBtn { text: "3"; onClicked: pwdDlg.appendDigit("3"); Layout.fillWidth: true; Layout.fillHeight: true }

                    KeyBtn { text: "4"; onClicked: pwdDlg.appendDigit("4"); Layout.fillWidth: true; Layout.fillHeight: true }
                    KeyBtn { text: "5"; onClicked: pwdDlg.appendDigit("5"); Layout.fillWidth: true; Layout.fillHeight: true }
                    KeyBtn { text: "6"; onClicked: pwdDlg.appendDigit("6"); Layout.fillWidth: true; Layout.fillHeight: true }

                    KeyBtn { text: "7"; onClicked: pwdDlg.appendDigit("7"); Layout.fillWidth: true; Layout.fillHeight: true }
                    KeyBtn { text: "8"; onClicked: pwdDlg.appendDigit("8"); Layout.fillWidth: true; Layout.fillHeight: true }
                    KeyBtn { text: "9"; onClicked: pwdDlg.appendDigit("9"); Layout.fillWidth: true; Layout.fillHeight: true }

                    // 清除 / 0 / 确认
                    KeyBtn {
                        text: "清除"
                        fs: 22
                        bg: "#E74C3C"
                        onClicked: pwdDlg.clearAll()
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                    }
                    KeyBtn {
                        text: "0"
                        onClicked: pwdDlg.appendDigit("0")
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                    }
                    KeyBtn {
                        text: "确认"
                        fs: 22
                        bg: "#27AE60"
                        enabled: pwdDlg.pwdStr.length === 6
                        onClicked: pwdDlg.doConfirm()
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                    }
                }

                // 提示文字
                Label {
                    Layout.fillWidth: true
                    text: "请输入6位数字密码"
                    color: "#6B7280"
                    font.pixelSize: 16
                    horizontalAlignment: Text.AlignHCenter
                }

                // 取消按钮(底部)
                Button {
                    Layout.alignment: Qt.AlignHCenter
                    text: "取消"
                    font.pixelSize: 16
                    background: Rectangle {
                        radius: 10
                        color: "#FFFFFF"
                        border.color: "#D6DFEA"
                        border.width: 1
                    }
                    contentItem: Label {
                        text: parent.text
                        color: "#111827"
                        font.pixelSize: 16
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                    onClicked: {
                        pwdDlg.pwdStr = ""
                        pwdDlg.close()
                    }
                }
            }
        }

        onOpened: pwdStr = ""
    }

    // ---------- ✅ 内机:修改密码弹窗(美化版-大九宫格) ----------
    Dialog {
        id: changePwdDlg
        modal: true
        standardButtons: Dialog.NoButton
        width: Math.min(560, parent.width - 24)
        height: Math.min(720, parent.height - 24)
        anchors.centerIn: parent

        property string newPwd: ""

        // 只允许内机打开
        onOpened: {
            if (root.mode !== "inner") {
                close()
                return
            }
            newPwd = ""
        }

        background: Rectangle {
            radius: 18
            color: "#FFFFFF"
            border.color: "#D6DFEA"
            border.width: 1
        }

        function appendDigit(d) {
            if (newPwd.length >= 6) return
            newPwd += d
        }
        function clearAll() { newPwd = "" }

        function doConfirm() {
            if (newPwd.length !== 6) return
            app.doorLock.setUnlockPassword(newPwd)   // ✅ 你 cpp 里实现的设置接口
            newPwd = ""
            close()
        }

        contentItem: Item {
            anchors.fill: parent

            Column {
                anchors.fill: parent
                anchors.margins: 22
                spacing: 16

                // 标题
                Column {
                    spacing: 6
                    Text {
                        text: "修改密码"
                        font.pixelSize: 26
                        font.bold: true
                        color: "#111827"
                    }
                    Text {
                        text: "请输入 6 位数字新密码"
                        font.pixelSize: 14
                        color: "#6B7280"
                    }
                }

                // 输入显示框
                Rectangle {
                    width: parent.width
                    height: 86
                    radius: 14
                    color: "#F8FAFC"
                    border.color: "#D6DFEA"
                    border.width: 1

                    Row {
                        anchors.fill: parent
                        anchors.margins: 16
                        spacing: 10

                        Text {
                            id: pwdDots
                            text: {
                                var s = ""
                                for (var i=0; i<changePwdDlg.newPwd.length; i++) s += "●"
                                return s
                            }
                            font.pixelSize: 30
                            color: "#111827"
                            verticalAlignment: Text.AlignVCenter
                            elide: Text.ElideRight
                            width: parent.width - 70
                        }

                        Text {
                            text: changePwdDlg.newPwd.length + "/6"
                            font.pixelSize: 14
                            color: "#6B7280"
                            verticalAlignment: Text.AlignVCenter
                            horizontalAlignment: Text.AlignRight
                            width: 60
                        }
                    }
                }

                // 九宫格键盘区域(居中、等宽等高、填满)
                Item {
                    id: padWrap
                    width: parent.width
                    height: parent.height - 86 - 120   // 预留顶部输入框 + 底部按钮空间

                    // cell 尺寸:固定比例,保证看起来饱满
                    readonly property int cols: 3
                    readonly property int gap: 16
                    readonly property int cellW: Math.floor((width - gap*(cols-1)) / cols)
                    readonly property int cellH: Math.min(110, Math.floor((height - gap*3) / 4))

                    Grid {
                        id: keypad
                        anchors.centerIn: parent
                        columns: 3
                        spacing: padWrap.gap

                        // 12 个键
                        Repeater {
                            model: [
                                {t:"1", type:"num"}, {t:"2", type:"num"}, {t:"3", type:"num"},
                                {t:"4", type:"num"}, {t:"5", type:"num"}, {t:"6", type:"num"},
                                {t:"7", type:"num"}, {t:"8", type:"num"}, {t:"9", type:"num"},
                                {t:"清除", type:"clear"}, {t:"0", type:"num"}, {t:"确认", type:"ok"}
                            ]

                            delegate: Button {
                                width: padWrap.cellW
                                height: padWrap.cellH
                                text: modelData.t
                                enabled: (modelData.type !== "ok") ? true : (changePwdDlg.newPwd.length === 6)

                                background: Rectangle {
                                    radius: 18
                                    color: {
                                        if (!parent.enabled) return "#CBD5E1"
                                        if (modelData.type === "clear") return "#EF4444"
                                        if (modelData.type === "ok")    return "#22C55E"
                                        return "#2F8BD6"
                                    }
                                }

                                contentItem: Text {
                                    text: parent.text
                                    color: "#FFFFFF"
                                    font.pixelSize: (modelData.type === "num") ? 34 : 22
                                    font.bold: true
                                    horizontalAlignment: Text.AlignHCenter
                                    verticalAlignment: Text.AlignVCenter
                                }

                                onClicked: {
                                    if (modelData.type === "num") changePwdDlg.appendDigit(modelData.t)
                                    else if (modelData.type === "clear") changePwdDlg.clearAll()
                                    else if (modelData.type === "ok") changePwdDlg.doConfirm()
                                }
                            }
                        }
                    }
                }

                // 底部取消
                Button {
                    width: 120
                    height: 44
                    anchors.horizontalCenter: parent.horizontalCenter
                    text: "取消"

                    background: Rectangle {
                        radius: 12
                        color: "#FFFFFF"
                        border.color: "#D6DFEA"
                        border.width: 1
                    }

                    contentItem: Text {
                        text: parent.text
                        color: "#111827"
                        font.pixelSize: 16
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }

                    onClicked: {
                        changePwdDlg.newPwd = ""
                        changePwdDlg.close()
                    }
                }
            }
        }
    }




    // ---------- 底部工具栏 ----------
    Rectangle {
        id: bottomBar
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        anchors.margins: 12
        height: 98
        radius: 24
        color: Qt.rgba(0.05,0.08,0.13,0.88)
        border.color: Qt.rgba(1,1,1,0.14)
        border.width: 1

        DropShadow {
            anchors.fill: bottomBar
            horizontalOffset: 0
            verticalOffset: 10
            radius: 18
            samples: 28
            color: Qt.rgba(0,0,0,0.50)
            source: bottomBar
        }

        RowLayout {
            anchors.fill: parent
            anchors.margins: 14
            spacing: 10

            TechButton {
                visible: root.mode === "outer"
                text: app.call.state === "Idle" ? "门铃/呼叫" : "呼叫中"
                enabled: app.signaling.connected && app.call.state === "Idle"
                accent: root.cBlue
                onClicked: app.call.dial()
            }

            TechButton {
                visible: root.mode === "inner"
                text: "远程开锁(3s)"
                enabled: root.inCall
                accent: root.cGreen
                onClicked: app.call.grantUnlock(3000)
            }
            TechButton {
                visible: root.mode === "inner"
                text: "拒绝开锁"
                enabled: root.inCall
                danger: true
                onClicked: app.call.denyUnlock()
            }

            TechButton {
                visible: root.mode === "outer"
                text: "人脸"
                accent: root.cCyan
                onClicked: faceSheet.open()
            }

            // ✅ 新增:外机密码解锁按钮(不影响原有逻辑)
            TechButton {
                visible: root.mode === "outer"
                text: "密码解锁"
                accent: root.cPurple
                onClicked: pwdDlg.open()
            }

            TechButton {
                visible: root.mode === "inner"
                text: "修改密码"
                accent: root.cBlue
                onClicked: changePwdDlg.open()
            }


            TechButton {
                visible: root.mode === "inner"
                text: "人员录入"
                accent: root.cPurple
                onClicked: enrollSheet.open()
            }

            TechButton {
                text: "挂断"
                enabled: app.call.state !== "Idle"
                danger: true
                onClicked: app.call.hangup()
            }

            Rectangle { Layout.fillWidth: true; color: "transparent" }

            ColumnLayout {
                spacing: 2
                Layout.alignment: Qt.AlignVCenter

                Label {
                    visible: root.mode === "outer"
                    text: app.doorLock.locked ? "门锁:锁定" : "门锁:已开"
                    color: app.doorLock.locked ? root.cRed : root.cGreen
                    font.pixelSize: 13
                }

                Label {
                    text: app.signaling.connected ? "连接:OK" : (app.signaling.listening ? "连接:监听中" : "连接:未连接")
                    color: app.signaling.connected ? root.cGreen : root.cAmber
                    font.pixelSize: 12
                }
            }
        }
    }

    // ---------- 外机:人脸 Sheet ----------
    Dialog {
        id: faceSheet
        modal: true
        standardButtons: Dialog.NoButton
        width: Math.min(620, parent.width - 24)
        height: Math.min(480, parent.height - 24)
        anchors.centerIn: parent

        background: Rectangle {
            radius: 20
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1
        }

        // Dialog 的 contentItem 里做 margins
        contentItem: Item {
            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 14
                spacing: 12

                RowLayout {
                    Layout.fillWidth: true
                    Label {
                        text: "人脸功能(外机)"
                        color: root.cText
                        font.pixelSize: 18
                        Layout.fillWidth: true
                    }
                    TechButton { text: "关闭"; ghost: true; onClicked: faceSheet.close() }
                }

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10
                    CheckBox {
                        text: "自动识别"
                        enabled: !!app.face
                        checked: app.face ? app.face.enabled : false
                        onToggled: if (app.face) app.face.enabled = checked
                    }
                    Label {
                        Layout.fillWidth: true
                        color: root.cSub
                        text: "外机只负责识别/开锁,录入请在内机进行。"
                        elide: Label.ElideRight
                    }
                }

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10
                    TechButton {
                        text: "扫描"
                        enabled: !!app.face
                        accent: root.cCyan
                        onClicked: app.face.scan()
                    }
                    Rectangle { Layout.fillWidth: true; color: "transparent" }
                }

                Rectangle {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    radius: 16
                    color: root.cPanel2
                    border.color: root.cStroke
                    border.width: 1

                    ColumnLayout {
                        anchors.fill: parent
                        anchors.margins: 12
                        spacing: 8

                        Label {
                            text: "识别状态: " + (app.face ? app.face.statusText : "n/a")
                            color: root.cText
                            font.pixelSize: 16
                            wrapMode: Text.WordWrap
                        }

                        RowLayout {
                            Layout.fillWidth: true
                            spacing: 12
                            Label {
                                text: "匹配:" + (app.face ? app.face.matchedName : "")
                                color: root.cSub
                                Layout.fillWidth: true
                                elide: Label.ElideRight
                            }
                            Label {
                                text: "分数:" + (app.face ? app.face.matchedScore.toFixed(3) : "0.000")
                                color: root.cSub
                            }
                        }

                        Label {
                            Layout.fillWidth: true
                            wrapMode: Text.WordWrap
                            color: (app.lastError && app.lastError.length > 0) ? root.cRed : root.cSub
                            text: (app.lastError && app.lastError.length > 0)
                                  ? ("错误: " + app.lastError)
                                  : "提示:未通话也可扫描(使用本地预览抓帧)。"
                        }
                    }
                }
            }
        }
    }

    // ---------- 内机:人员录入 Sheet ----------
    Dialog {
        id: enrollSheet
        modal: true
        standardButtons: Dialog.NoButton
        width: Math.min(720, parent.width - 24)
        height: Math.min(600, parent.height - 24)
        anchors.centerIn: parent

        background: Rectangle {
            radius: 20
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1
        }

        contentItem: Item {
            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 14
                spacing: 12

                RowLayout {
                    Layout.fillWidth: true
                    Label {
                        text: "人员录入(内机)"
                        color: root.cText
                        font.pixelSize: 18
                        Layout.fillWidth: true
                    }
                    TechButton { text: "关闭"; ghost: true; onClicked: enrollSheet.close() }
                }

                Label {
                    Layout.fillWidth: true
                    color: root.cSub
                    wrapMode: Text.WordWrap
                    text: "请被授权人面对摄像头,输入姓名后点击【录入】。录入成功后会写入数据库并刷新列表。"
                }

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    TextField {
                        id: enrollNameInner
                        Layout.fillWidth: true
                        placeholderText: "姓名(授权人员)"
                    }

                    TechButton {
                        text: "录入"
                        accent: root.cPurple
                        enabled: !!app.face && enrollNameInner.text.trim().length > 0
                        onClicked: {
                            app.face.enroll(enrollNameInner.text)
                            app.persons.reload()
                        }
                    }

                    TechButton {
                        text: "刷新"
                        ghost: true
                        onClicked: app.persons.reload()
                    }
                }

                Rectangle {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    radius: 16
                    color: root.cPanel2
                    border.color: root.cStroke
                    border.width: 1
                    clip: true

                    PersonTab {
                        anchors.fill: parent
                        anchors.margins: 8
                    }
                }

                Label {
                    Layout.fillWidth: true
                    color: root.cText
                    text: app.face ? ("状态:" + app.face.statusText) : "状态:n/a"
                    elide: Label.ElideRight
                }

                Label {
                    Layout.fillWidth: true
                    wrapMode: Text.WordWrap
                    color: root.cRed
                    visible: (app.lastError && app.lastError.length > 0)
                    text: "错误: " + app.lastError
                }
            }
        }
    }
}

有注释版

cpp 复制代码
// ---- 导入 Qt 核心模块 ----
// QtQuick:QML 的核心模块,提供 Item、Rectangle、Image 等基础元素
import QtQuick

// QtQuick.Controls:提供按钮、对话框、列表等标准控件
import QtQuick.Controls

// QtQuick.Layouts:提供布局管理器(RowLayout、ColumnLayout、GridLayout)
import QtQuick.Layouts

// QtMultimedia:提供视频输出(VideoOutput)、摄像头等多媒体功能
import QtMultimedia

// Qt5Compat.GraphicalEffects:提供图形特效(DropShadow 阴影效果)
// ⚠️ 这个模块需要单独安装,否则编译报错
import Qt5Compat.GraphicalEffects

// ---- 根元素 ----
// Item 是 QML 中最基本的容器元素,可以包含子元素
Item {
    id: root  // 给根元素起名,方便在其他地方引用

    // ---- 自定义属性 ----
    // mode:当前模式,"outer"(外机)或 "inner"(内机)
    // 在 QML 中可以通过 root.mode 访问
    property string mode: "outer"

    // inCall:是否在通话中(只读)
    // readonly property 表示只能读,不能写
    // 值来自 app.call.state 是否等于 "InCall"
    readonly property bool inCall: app.call.state === "InCall"

    // ---- 主题色(颜色常量) ----
    // 这些颜色在整个界面中统一使用,便于主题切换
    readonly property color cBg: "#070A12"      // 背景色(深色)
    readonly property color cPanel: "#0B1220"   // 面板色
    readonly property color cPanel2: "#0E1A2B"  // 面板色(稍亮)
    readonly property color cStroke: "#20304A"  // 边框色
    readonly property color cText: "#E6F0FF"    // 文字色(浅蓝白)
    readonly property color cSub: "#9FB3C8"     // 辅助文字色
    readonly property color cGreen: "#22C55E"   // 绿色(成功/已开)
    readonly property color cRed: "#FB7185"     // 红色(错误/锁定)
    readonly property color cAmber: "#FBBF24"   // 琥珀色(警告)
    readonly property color cCyan: "#22D3EE"    // 青色(外机人脸按钮)
    readonly property color cBlue: "#60A5FA"    // 蓝色(呼叫按钮)
    readonly property color cPurple: "#A78BFA"  // 紫色(密码/录入按钮)


    // ---- 自定义组件:TechButton ----
    // component 关键字定义了一个可复用的组件
    // 可以在 QML 中像普通 Button 一样使用
    component TechButton: Button {
        // ---- 自定义属性 ----
        property color accent: root.cBlue   // 强调色(默认蓝色)
        property bool danger: false         // 是否危险按钮(红色)
        property bool ghost: false          // 是否透明按钮(无背景)

        // ---- 按钮样式 ----
        font.pixelSize: 15
        padding: 10

        // ---- 背景 ----
        background: Rectangle {
            radius: 14
            border.width: 1
            border.color: parent.enabled
                          ? (parent.ghost ? Qt.rgba(1,1,1,0.18) : Qt.rgba(1,1,1,0.22))
                          : Qt.rgba(1,1,1,0.10)
            color: parent.ghost
                   ? Qt.rgba(0,0,0,0)  // 透明背景
                   : (parent.enabled
                      ? (parent.danger ? Qt.rgba(0.98,0.33,0.45,0.22) : Qt.rgba(0.38,0.65,0.98,0.16))
                      : Qt.rgba(0.2,0.25,0.35,0.18))

            // ---- 渐变叠加层 ----
            Rectangle {
                anchors.fill: parent
                radius: parent.radius
                gradient: Gradient {
                    GradientStop { position: 0.0; color: Qt.rgba(1,1,1,0.10) }
                    GradientStop { position: 0.5; color: Qt.rgba(1,1,1,0.03) }
                    GradientStop { position: 1.0; color: Qt.rgba(0,0,0,0.10) }
                }
                opacity: parent.enabled ? 1 : 0.5
            }
        }

        // ---- 文字内容 ----
        contentItem: Label {
            text: parent.text
            color: parent.enabled ? root.cText : Qt.rgba(1,1,1,0.45)
            font.pixelSize: 15
            elide: Label.ElideRight
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }

        // ---- 悬停效果 ----
        hoverEnabled: true
        onHoveredChanged: {
            if (hovered && enabled && !ghost) scale = 1.02
            else scale = 1.0
        }
        Behavior on scale { NumberAnimation { duration: 120 } }
    }

    // ---- 侧边栏 ----
    // Drawer 是从屏幕边缘滑出的面板
    Drawer {
        id: drawer
        width: Math.min(560, parent.width * 0.92)  // 宽度:最大 560px
        edge: Qt.RightEdge  // 从右侧滑出

        // ---- 面板内容 ----
        Rectangle {
            anchors.fill: parent
            radius: 18
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 14
                spacing: 12

                // ---- 标题栏 ----
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10
                    Label {
                        text: "更多"
                        color: root.cText
                        font.pixelSize: 18
                        Layout.fillWidth: true
                    }
                    TechButton { text: "关闭"; ghost: true; onClicked: drawer.close() }
                }

                // ---- 标签页切换(仅外机) ----
                TabBar {
                    id: drawerTabs
                    visible: root.mode === "outer"
                    Layout.fillWidth: true
                    // ... 样式 ...
                    TabButton { text: "授权库" }  // 人员管理
                    TabButton { text: "日志" }    // 事件日志
                }

                // ---- 内容加载器 ----
                Loader {
                    id: drawerContent
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    active: true
                    sourceComponent: {
                        if (root.mode === "inner") return logTabComp;  // 内机只显示日志
                        return (drawerTabs.currentIndex === 0) ? personTabComp : logTabComp;
                    }
                }

                Component { id: personTabComp; PersonTab { } }  // 人员标签页
                Component { id: logTabComp; LogTab { } }        // 日志标签页
            }
        }
    }

    // ---- 来电弹窗 ----
    Dialog {
        id: incomingDialog
        title: "来电提示"
        modal: true
        standardButtons: Dialog.NoButton

        // ---- 弹窗样式 ----
        background: Rectangle {
            radius: 18
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1
        }

        // ---- 弹窗内容 ----
        contentItem: Item {
            implicitWidth: 380
            implicitHeight: 160

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 14
                spacing: 12

                Label {
                    text: "外机呼叫(sessionId=" + app.call.sessionId + ")"
                    wrapMode: Text.WordWrap
                    color: root.cText
                }

                RowLayout {
                    spacing: 12
                    TechButton {
                        text: "接听"
                        accent: root.cGreen
                        onClicked: { incomingDialog.close(); app.call.accept() }
                    }
                    TechButton {
                        text: "拒绝"
                        danger: true
                        onClicked: { incomingDialog.close(); app.call.reject() }
                    }
                }
            }
        }
    }

    // ---- 监听来电信号 ----
    Connections {
        target: app.call
        function onIncomingChanged() {
            if (root.mode === "inner" && app.call.incoming)
                incomingDialog.open()
        }
    }

    // ---- 组件加载完成时执行 ----
    Component.onCompleted: {
        // 1. 设置本地视频显示目标
        app.call.setLocalVideoSink(localVideo.videoSink)

        // 2. 启动视频流
        if (app.call.video) app.call.video.setStreaming(true)

        // 3. 外机:加载人脸识别配置并启用
        if (root.mode === "outer" && app.face) {
            app.face.loadDefaultsFromSettings()
            app.face.enabled = true
        }
    }

    // ---- 背景 ----
    Rectangle {
        anchors.fill: parent
        color: root.cBg
        radius: 16
    }

    // ---- 主舞台 ----
    Item {
        id: stage
        anchors.fill: parent
        anchors.margins: 12

        // ---- 主框架 ----
        Rectangle {
            id: mainFrame
            anchors.fill: parent
            radius: 22
            color: root.cPanel
            border.color: root.cStroke
            border.width: 1
            clip: true

            // ---- 渐变装饰 ----
            Rectangle {
                anchors.fill: parent
                opacity: 0.40
                gradient: Gradient { ... }
            }

            // ---- 对端视频画面 ----
            Image {
                anchors.fill: parent
                visible: root.inCall
                fillMode: Image.PreserveAspectFit
                cache: false
                source: (app.call.video && app.call.video.remoteFrameDataUrl)
                        ? app.call.video.remoteFrameDataUrl : ""
            }

            // ---- 本地视频画面 ----
            Rectangle {
                id: localBox
                // ... 大小和位置随 inCall 变化 ...

                // ---- 阴影效果 ----
                DropShadow {
                    anchors.fill: localBox
                    horizontalOffset: 0
                    verticalOffset: 6
                    radius: 16
                    samples: 25
                    color: Qt.rgba(0, 0, 0, root.inCall ? 0.55 : 0.0)
                    source: localBox
                    visible: root.inCall
                }

                // ---- 视频输出 ----
                VideoOutput {
                    id: localVideo
                    anchors.fill: parent
                    fillMode: VideoOutput.PreserveAspectCrop
                    transform: Scale {
                        xScale: -1; yScale: 1  // 镜像翻转(自拍模式)
                        origin.x: localVideo.width/2
                        origin.y: localVideo.height/2
                    }
                }
            }

            // ---- 顶部状态栏 ----
            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.top: parent.top
                anchors.margins: 12
                height: 52
                radius: 18
                // ...
                RowLayout {
                    // 连接状态指示灯
                    Rectangle { ... }
                    // 状态文字
                    Label { text: "外机 · " + app.call.state + ... }
                }
            }

            // ---- 对端画面提示 ----
            Rectangle {
                id: tipBox
                anchors.centerIn: parent
                visible: root.inCall && (!app.call.video || !app.call.video.remoteFrameDataUrl)
                // ...
                Label {
                    text: "暂无对端画面"
                }
            }
        }
    }

    // ---- 外机:密码解锁弹窗 ----
    Dialog {
        id: pwdDlg
        // ...

        property string pwdStr: ""

        function appendDigit(d) {
            if (pwdStr.length >= 6) return
            pwdStr = pwdStr + d
        }

        function clearAll() {
            pwdStr = ""
        }

        function doConfirm() {
            if (pwdStr.length !== 6) return
            app.doorLock.unlockByPassword(pwdStr)  // C++ 密码验证
            pwdStr = ""
            pwdDlg.close()
        }

        // ... UI 布局:标题 + 输入显示 + 九宫格键盘
    }

    // ---- 内机:修改密码弹窗 ----
    Dialog {
        id: changePwdDlg
        // ...

        property string newPwd: ""

        function doConfirm() {
            if (newPwd.length !== 6) return
            app.doorLock.setUnlockPassword(newPwd)  // C++ 修改密码
            newPwd = ""
            close()
        }

        // ... UI 布局
    }

    // ---- 底部工具栏 ----
    Rectangle {
        id: bottomBar
        // ...

        RowLayout {
            // ---- 外机按钮 ----
            TechButton {
                visible: root.mode === "outer"
                text: app.call.state === "Idle" ? "门铃/呼叫" : "呼叫中"
                enabled: app.signaling.connected && app.call.state === "Idle"
                onClicked: app.call.dial()
            }

            // ---- 内机按钮 ----
            TechButton {
                visible: root.mode === "inner"
                text: "远程开锁(3s)"
                enabled: root.inCall
                onClicked: app.call.grantUnlock(3000)
            }
            TechButton {
                visible: root.mode === "inner"
                text: "拒绝开锁"
                enabled: root.inCall
                danger: true
                onClicked: app.call.denyUnlock()
            }

            // ---- 外机功能按钮 ----
            TechButton {
                visible: root.mode === "outer"
                text: "人脸"
                onClicked: faceSheet.open()
            }
            TechButton {
                visible: root.mode === "outer"
                text: "密码解锁"
                onClicked: pwdDlg.open()
            }

            // ---- 内机功能按钮 ----
            TechButton {
                visible: root.mode === "inner"
                text: "修改密码"
                onClicked: changePwdDlg.open()
            }
            TechButton {
                visible: root.mode === "inner"
                text: "人员录入"
                onClicked: enrollSheet.open()
            }

            // ---- 挂断按钮(通用) ----
            TechButton {
                text: "挂断"
                enabled: app.call.state !== "Idle"
                danger: true
                onClicked: app.call.hangup()
            }

            // ---- 状态显示 ----
            ColumnLayout {
                Label { text: app.doorLock.locked ? "门锁:锁定" : "门锁:已开" }
                Label { text: app.signaling.connected ? "连接:OK" : "连接:未连接" }
            }
        }
    }

    // ---- 外机:人脸功能面板 ----
    Dialog {
        id: faceSheet
        // ...
        contentItem: Item {
            ColumnLayout {
                // ... 标题、开关、扫描按钮、状态显示
                CheckBox {
                    text: "自动识别"
                    checked: app.face ? app.face.enabled : false
                    onToggled: if (app.face) app.face.enabled = checked
                }
                TechButton {
                    text: "扫描"
                    onClicked: app.face.scan()
                }
                Label {
                    text: "识别状态:" + (app.face ? app.face.statusText : "n/a")
                }
                Label {
                    text: "匹配:" + (app.face ? app.face.matchedName : "")
                }
                Label {
                    text: "分数:" + (app.face ? app.face.matchedScore.toFixed(3) : "0.000")
                }
            }
        }
    }

    // ---- 内机:人员录入面板 ----
    Dialog {
        id: enrollSheet
        // ...
        contentItem: Item {
            ColumnLayout {
                // ... 标题、说明、输入框、按钮
                TextField {
                    id: enrollNameInner
                    placeholderText: "姓名(授权人员)"
                }
                TechButton {
                    text: "录入"
                    onClicked: {
                        app.face.enroll(enrollNameInner.text)
                        app.persons.reload()
                    }
                }
                PersonTab { ... }  // 显示人员列表
                Label { text: app.face ? ("状态:" + app.face.statusText) : "状态:n/a" }
            }
        }
    }

界面功能总结

功能 触发位置 调用的 C++ 方法
呼叫 底部工具栏 app.call.dial()
接听 来电弹窗 app.call.accept()
拒绝 来电弹窗 app.call.reject()
挂断 底部工具栏 app.call.hangup()
远程开锁 底部工具栏 app.call.grantUnlock(3000)
拒绝开锁 底部工具栏 app.call.denyUnlock()
密码解锁 底部工具栏 app.doorLock.unlockByPassword()
修改密码 底部工具栏 app.doorLock.setUnlockPassword()
人脸扫描 人脸面板 app.face.scan()
人脸录入 录入面板 app.face.enroll()
人员刷新 录入面板 app.persons.reload()

InnerPage.qml

文件概述

属性 说明
文件名 InnerPage.qml
作用 内机(室内机)的主界面
功能 监听控制 + 通话界面
技术栈 QML、QtQuick.Controls、QtQuick.Layouts

无注释版

cpp 复制代码
// InnerPage.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 12
        spacing: 12

        Frame {
            Layout.fillWidth: true
            padding: 12

            background: Rectangle {
                radius: 16
                color: "#0B1220"
                border.color: "#1B2A44"
                border.width: 1
            }

            ColumnLayout {
                anchors.fill: parent
                spacing: 10

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    Rectangle { width: 8; height: 8; radius: 4; color: "#F59E0B"; opacity: 0.9 }

                    Label {
                        text: "内机监听"
                        color: "white"
                        font.pixelSize: 16
                        font.weight: Font.DemiBold
                        Layout.fillWidth: true
                    }

                    Label {
                        text: app.signaling.listening ? "监听中" : "未监听"
                        color: app.signaling.listening ? "#22C55E" : "#F59E0B"
                        font.pixelSize: 12
                        opacity: 0.95
                    }
                }

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    TextField {
                        id: portField
                        Layout.preferredWidth: 140
                        inputMethodHints: Qt.ImhDigitsOnly
                        placeholderText: "端口"
                        text: String(app.settings.getInt("server_port", 12345))
                        onEditingFinished: app.settings.setInt("server_port", parseInt(text))

                        color: "white"
                        background: Rectangle {
                            radius: 10
                            color: "#0F172A"
                            border.color: "#223047"
                            border.width: 1
                        }
                    }

                    Button {
                        text: app.signaling.listening ? "停止监听" : "启动监听"
                        implicitHeight: 36
                        implicitWidth: 100

                        onClicked: {
                            if (app.signaling.listening) {
                                app.signaling.stopServer()
                            } else {
                                app.settings.setInt("server_port", parseInt(portField.text))
                                app.signaling.startServer(parseInt(portField.text))
                            }
                        }

                        contentItem: Text {
                            text: parent.text
                            color: "white"
                            font.pixelSize: 13
                            font.weight: Font.Medium
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                        background: Rectangle {
                            radius: 10
                            border.width: 1
                            border.color: "#2A3B5E"
                            color: parent.down ? "#1A2A46" : "#0F172A"
                        }
                    }

                    Label {
                        Layout.fillWidth: true
                        text: app.signaling.listening
                              ? ("监听地址: 0.0.0.0:" + app.signaling.listenPort)
                              : "等待启动监听..."
                        color: "#94A3B8"
                        elide: Label.ElideRight
                    }

                    Label {
                        text: app.signaling.connected ? ("外机已连接: " + app.signaling.peer) : "外机未连接"
                        color: app.signaling.connected ? "#86EFAC" : "#94A3B8"
                        font.pixelSize: 12
                        elide: Label.ElideRight
                        opacity: 0.95
                    }
                }
            }
        }

        CallStage {
            Layout.fillWidth: true
            Layout.fillHeight: true
            mode: "inner"
        }
    }
}

有注释版

cpp 复制代码
// ============================================================================
// InnerPage.qml - 内机(室内机)主界面
// ============================================================================

// ---- 导入语句 ----
// QtQuick:QML 核心模块
// QtQuick.Controls:标准控件(按钮、文本框等)
// QtQuick.Layouts:布局管理器
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

// ---- 根元素 ----
// Item 是 QML 中最基本的容器元素
Item {

    // ---- 主布局:垂直排列 ----
    // ColumnLayout 将子元素垂直排列
    ColumnLayout {
        // 填充父元素
        anchors.fill: parent
        anchors.margins: 12
        spacing: 12

        // ====================================================================
        // 第一部分:监听控制面板
        // ====================================================================

        // ---- Frame:带边框的容器 ----
        Frame {
            // 宽度占满父容器
            Layout.fillWidth: true
            padding: 12

            // ---- 边框样式 ----
            background: Rectangle {
                radius: 16
                color: "#0B1220"      // 深色背景
                border.color: "#1B2A44" // 深蓝色边框
                border.width: 1
            }

            // ---- 内容:垂直排列 ----
            ColumnLayout {
                anchors.fill: parent
                spacing: 10

                // ---- 标题行 ----
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    // 状态指示灯(橙色圆点)
                    Rectangle {
                        width: 8
                        height: 8
                        radius: 4
                        color: "#F59E0B"
                        opacity: 0.9
                    }

                    // 标题文字
                    Label {
                        text: "内机监听"
                        color: "white"
                        font.pixelSize: 16
                        font.weight: Font.DemiBold
                        Layout.fillWidth: true
                    }

                    // 监听状态
                    Label {
                        text: app.signaling.listening ? "监听中" : "未监听"
                        color: app.signaling.listening ? "#22C55E" : "#F59E0B"
                        font.pixelSize: 12
                        opacity: 0.95
                    }
                }

                // ---- 控制行:端口 + 按钮 + 状态 ----
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    // ---- 端口输入框 ----
                    TextField {
                        id: portField
                        Layout.preferredWidth: 140
                        inputMethodHints: Qt.ImhDigitsOnly  // 只允许输入数字
                        placeholderText: "端口"
                        text: String(app.settings.getInt("server_port", 12345))

                        // 编辑完成时保存端口配置
                        onEditingFinished: app.settings.setInt("server_port", parseInt(text))

                        // ---- 样式 ----
                        color: "white"
                        background: Rectangle {
                            radius: 10
                            color: "#0F172A"
                            border.color: "#223047"
                            border.width: 1
                        }
                    }

                    // ---- 启动/停止监听按钮 ----
                    Button {
                        text: app.signaling.listening ? "停止监听" : "启动监听"
                        implicitHeight: 36
                        implicitWidth: 100

                        onClicked: {
                            if (app.signaling.listening) {
                                // 如果正在监听,停止
                                app.signaling.stopServer()
                            } else {
                                // 如果未监听,保存端口并启动
                                app.settings.setInt("server_port", parseInt(portField.text))
                                app.signaling.startServer(parseInt(portField.text))
                            }
                        }

                        // ---- 按钮样式 ----
                        contentItem: Text {
                            text: parent.text
                            color: "white"
                            font.pixelSize: 13
                            font.weight: Font.Medium
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                        background: Rectangle {
                            radius: 10
                            border.width: 1
                            border.color: "#2A3B5E"
                            color: parent.down ? "#1A2A46" : "#0F172A"
                        }
                    }

                    // ---- 监听地址显示 ----
                    Label {
                        Layout.fillWidth: true
                        text: app.signaling.listening
                              ? ("监听地址: 0.0.0.0:" + app.signaling.listenPort)
                              : "等待启动监听..."
                        color: "#94A3B8"
                        elide: Label.ElideRight
                    }

                    // ---- 外机连接状态 ----
                    Label {
                        text: app.signaling.connected
                              ? ("外机已连接: " + app.signaling.peer)
                              : "外机未连接"
                        color: app.signaling.connected ? "#86EFAC" : "#94A3B8"
                        font.pixelSize: 12
                        elide: Label.ElideRight
                        opacity: 0.95
                    }
                }
            }
        }

        // ====================================================================
        // 第二部分:通话界面
        // ====================================================================

        // ---- CallStage:通话主界面 ----
        // 这是外机和内机共用的通话界面组件
        // mode: "inner" 表示内机模式
        CallStage {
            Layout.fillWidth: true
            Layout.fillHeight: true
            mode: "inner"
        }
    }
}

界面布局图

cpp 复制代码
┌─────────────────────────────────────────────────────────────┐
│  ● 内机监听                    未监听                      │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  [端口: 12345]  [启动监听]  监听地址: 0.0.0.0:12345 │   │
│  │                                 外机未连接           │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  ┌──────────────────────────────────────────────┐   │   │
│  │  │              CallStage                       │   │   │
│  │  │        (通话界面,mode: "inner")             │   │   │
│  │  └──────────────────────────────────────────────┘   │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

功能说明

功能 说明 用户操作
端口设置 设置内机监听的端口 修改端口号,编辑完成自动保存
启动监听 内机开始监听外机呼叫 点击"启动监听"按钮
停止监听 内机停止监听 点击"停止监听"按钮
查看状态 显示监听地址和端口 自动显示
外机连接状态 显示是否有外机连接 自动显示
通话界面 显示通话相关功能 由 CallStage 组件提供

状态显示

状态 显示颜色 显示文字
监听中 绿色(#22C55E "监听中"
未监听 橙色(#F59E0B "未监听"
外机已连接 绿色(#86EFAC "外机已连接: IP"
外机未连接 灰色(#94A3B8 "外机未连接"

与 OuterPage 的区别

特性 InnerPage(内机) OuterPage(外机)
主要功能 监听控制 连接设置
服务端/客户端 服务端(监听) 客户端(连接)
端口配置 设置监听端口 设置服务器端口
连接状态 显示是否有外机连接 显示是否连接到内机
特有功能 人员录入、修改密码 密码解锁、人脸扫描

LogTab.qml

文件概述

属性 说明
文件名 LogTab.qml
作用 显示和导出事件日志列表
功能 过滤、刷新、导出 CSV
技术栈 QML、QtQuick.Controls、QtQuick.Layouts

无注释版

cpp 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 10
        spacing: 10

        RowLayout {
            Layout.fillWidth: true
            spacing: 8

            ComboBox {
                id: filterBox
                model: ["(全部)", "call", "unlock", "face_ok", "face_fail", "face_enroll"]
                onActivated: {
                    app.events.typeFilter = (currentText === "(全部)") ? "" : currentText
                    app.events.reload()
                }
            }

            Button { text: "刷新"; onClicked: app.events.reload() }

            Button {
                text: "导出 CSV"
                onClicked: {
                    var path = app.events.exportCsvToDocuments()
                    if (path !== "") {
                        toast.text = "已导出: " + path
                        toast.open()
                    }
                }
            }

            Label {
                Layout.fillWidth: true
                text: app.events.lastError === "" ? "已加载" : ("错误: " + app.events.lastError)
                elide: Label.ElideRight
            }
        }

        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#101010"
            radius: 6

            ListView {
                anchors.fill: parent
                anchors.margins: 8
                clip: true
                spacing: 6
                model: app.events

                delegate: Rectangle {
                    width: ListView.view.width
                    height: 64
                    color: "#202020"
                    radius: 6

                    ColumnLayout {
                        anchors.fill: parent
                        anchors.margins: 8
                        spacing: 4

                        RowLayout {
                            Layout.fillWidth: true
                            Label { text: "#" + eid + "  " + type + "  " + result; color: "white"; Layout.fillWidth: true; elide: Label.ElideRight }
                            Label { text: new Date(ts * 1000).toLocaleString(); color: "#c0c0c0" }
                        }
                        Label {
                            text: (src === "" && dst === "" ? "" : ("src=" + src + " dst=" + dst + "  ")) + (extra === "" ? "" : ("extra=" + extra))
                            color: "#c0c0c0"
                            wrapMode: Text.WordWrap
                            elide: Label.ElideRight
                        }
                    }
                }
            }
        }

        Dialog {
            id: toast
            modal: false
            x: 20
            y: parent.height - 120
            width: Math.min(800, parent.width - 40)
            standardButtons: Dialog.NoButton
            property string text: ""
            contentItem: Label { text: toast.text; wrapMode: Text.WordWrap; padding: 12 }
            onOpened: closeTimer.start()
            Timer { id: closeTimer; interval: 2500; onTriggered: toast.close() }
        }
    }
}

有注释版

cpp 复制代码
// ============================================================================
// LogTab.qml - 事件日志标签页
// ============================================================================

// ---- 导入语句 ----
import QtQuick           // QML 核心模块
import QtQuick.Controls  // 控件模块(按钮、下拉框等)
import QtQuick.Layouts   // 布局管理器

// ---- 根元素 ----
Item {

    // ---- 主布局:垂直排列 ----
    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 10
        spacing: 10

        // ====================================================================
        // 第一部分:控制栏
        // ====================================================================

        RowLayout {
            Layout.fillWidth: true
            spacing: 8

            // ---- 类型过滤器下拉框 ----
            ComboBox {
                id: filterBox
                // 过滤选项列表
                model: ["(全部)", "call", "unlock", "face_ok", "face_fail", "face_enroll"]

                // 当用户选择不同选项时触发
                onActivated: {
                    // 如果选择 "(全部)",清空过滤器;否则使用选中的类型
                    app.events.typeFilter = (currentText === "(全部)") ? "" : currentText
                    // 重新加载数据
                    app.events.reload()
                }
            }

            // ---- 刷新按钮 ----
            Button {
                text: "刷新"
                onClicked: app.events.reload()
            }

            // ---- 导出 CSV 按钮 ----
            Button {
                text: "导出 CSV"
                onClicked: {
                    // 调用 C++ 方法导出 CSV
                    var path = app.events.exportCsvToDocuments()

                    // 如果导出成功(返回非空路径)
                    if (path !== "") {
                        toast.text = "已导出: " + path
                        toast.open()  // 显示提示
                    }
                }
            }

            // ---- 状态显示 ----
            Label {
                Layout.fillWidth: true
                text: app.events.lastError === "" ? "已加载" : ("错误: " + app.events.lastError)
                elide: Label.ElideRight
            }
        }

        // ====================================================================
        // 第二部分:日志列表
        // ====================================================================

        // ---- 列表容器 ----
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#101010"          // 深色背景
            radius: 6

            // ---- 列表视图 ----
            ListView {
                anchors.fill: parent
                anchors.margins: 8
                clip: true            // 裁剪超出部分
                spacing: 6            // 每项间距

                // ---- 绑定数据模型 ----
                model: app.events

                // ---- 列表项委托 ----
                delegate: Rectangle {
                    width: ListView.view.width
                    height: 64
                    color: "#202020"   // 深灰色背景
                    radius: 6

                    // ---- 内容布局 ----
                    ColumnLayout {
                        anchors.fill: parent
                        anchors.margins: 8
                        spacing: 4

                        // ---- 第一行:ID、类型、结果、时间 ----
                        RowLayout {
                            Layout.fillWidth: true

                            // 事件信息(ID + 类型 + 结果)
                            Label {
                                text: "#" + eid + "  " + type + "  " + result
                                color: "white"
                                Layout.fillWidth: true
                                elide: Label.ElideRight  // 超出省略
                            }

                            // 时间戳(转换为本地时间)
                            Label {
                                text: new Date(ts * 1000).toLocaleString()
                                color: "#c0c0c0"
                            }
                        }

                        // ---- 第二行:来源、目标、额外信息 ----
                        Label {
                            text: (src === "" && dst === "" ? "" : ("src=" + src + " dst=" + dst + "  ")) + (extra === "" ? "" : ("extra=" + extra))
                            color: "#c0c0c0"
                            wrapMode: Text.WordWrap
                            elide: Label.ElideRight
                        }
                    }
                }
            }
        }

        // ====================================================================
        // 第三部分:Toast 提示弹窗
        // ====================================================================

        Dialog {
            id: toast
            modal: false              // 非模态(不阻塞操作)
            x: 20
            y: parent.height - 120    // 显示在底部
            width: Math.min(800, parent.width - 40)
            standardButtons: Dialog.NoButton

            // ---- 自定义属性 ----
            property string text: ""

            // ---- 内容 ----
            contentItem: Label {
                text: toast.text
                wrapMode: Text.WordWrap
                padding: 12
            }

            // ---- 自动关闭 ----
            onOpened: closeTimer.start()
            Timer {
                id: closeTimer
                interval: 2500         // 2.5 秒后自动关闭
                onTriggered: toast.close()
            }
        }
    }
}

Main.qml

文件概述

属性 说明
文件名 Main.qml
作用 应用程序主窗口
功能 窗口管理、页面切换、状态显示
技术栈 QML、QtQuick.Controls、QtQuick.Layouts

无注释版

cpp 复制代码
// Main.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

ApplicationWindow {
    id: win
    width: 600
    height: 720
    visible: true
    title: "DoorQML"
    color: "#070B12"  // 整体背景改深色

    // ===== 顶部标题栏(替代之前那条白色)=====
    header: ToolBar {
        id: topBar
        implicitHeight: 56

        background: Rectangle {
            anchors.fill: parent
            color: "#0B1220"
            border.color: "#1B2A44"
            border.width: 1

            // 一条细的"霓虹线"
            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.bottom: parent.bottom
                height: 2
                color: "#3B82F6"
                opacity: 0.55
            }
        }

        RowLayout {
            anchors.fill: parent
            anchors.margins: 12
            spacing: 10

            RowLayout {
                Layout.fillWidth: true
                spacing: 10

                Rectangle {
                    width: 10
                    height: 10
                    radius: 5
                    color: app.lastError && app.lastError.length > 0 ? "#FF6B6B" : "#38BDF8"
                    opacity: 0.9
                }

                Label {
                    text: "DoorQML"
                    color: "white"
                    font.pixelSize: 18
                    font.weight: Font.DemiBold
                }

                Label {
                    text: app.role === "outer" ? "外机" : (app.role === "inner" ? "内机" : "未选择角色")
                    color: "#93C5FD"
                    font.pixelSize: 13
                    opacity: 0.95
                }
            }

        }
    }

    // ✅ 注意:不要写 contentItem:
    StackView {
        id: stack
        anchors.fill: parent
        initialItem: roleSelectComponent
    }

    Component {
        id: roleSelectComponent
        RoleSelectPage {
            stackView: stack
            outerComponent: outerPageComponent
            innerComponent: innerPageComponent
        }
    }

    Component { id: outerPageComponent; OuterPage { } }
    Component { id: innerPageComponent; InnerPage { } }

    // ===== 底栏(更贴合深色主题)=====
    footer: Rectangle {
        height: 40
        color: "#0B1220"
        border.color: "#1B2A44"
        border.width: 1

        RowLayout {
            anchors.fill: parent
            anchors.margins: 10
            spacing: 10

            Label {
                Layout.fillWidth: true
                color: "#CBD5E1"
                font.pixelSize: 12
                text: app.dbPath === "" ? "DB: 未初始化" : ("DB: " + app.dbPath)
                elide: Label.ElideRight
            }

            Rectangle {
                width: 8; height: 8; radius: 4
                color: app.lastError === "" ? "#22C55E" : "#EF4444"
                opacity: 0.9
            }

            Label {
                color: app.lastError === "" ? "#86EFAC" : "#FCA5A5"
                font.pixelSize: 12
                text: app.lastError === "" ? "OK" : ("ERROR: " + app.lastError)
                elide: Label.ElideRight
            }
        }
    }
}

有注释版

cpp 复制代码
// ============================================================================
// Main.qml - 应用程序主窗口
// ============================================================================

// ---- 导入语句 ----
import QtQuick           // QML 核心模块
import QtQuick.Controls  // 控件模块
import QtQuick.Layouts   // 布局管理器

// ============================================================================
// ApplicationWindow:应用程序主窗口
// ============================================================================

ApplicationWindow {
    id: win
    width: 600
    height: 720
    visible: true
    title: "DoorQML"
    color: "#070B12"          // 深色背景

    // ========================================================================
    // 顶部标题栏(header)
    // ========================================================================

    header: ToolBar {
        id: topBar
        implicitHeight: 56

        // ---- 标题栏背景 ----
        background: Rectangle {
            anchors.fill: parent
            color: "#0B1220"
            border.color: "#1B2A44"
            border.width: 1

            // ---- 底部霓虹装饰线 ----
            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.bottom: parent.bottom
                height: 2
                color: "#3B82F6"      // 蓝色
                opacity: 0.55
            }
        }

        // ---- 标题栏内容 ----
        RowLayout {
            anchors.fill: parent
            anchors.margins: 12
            spacing: 10

            RowLayout {
                Layout.fillWidth: true
                spacing: 10

                // 状态指示灯
                Rectangle {
                    width: 10
                    height: 10
                    radius: 5
                    color: app.lastError && app.lastError.length > 0
                           ? "#FF6B6B"     // 有错误 → 红色
                           : "#38BDF8"     // 正常 → 蓝色
                    opacity: 0.9
                }

                // 应用名称
                Label {
                    text: "DoorQML"
                    color: "white"
                    font.pixelSize: 18
                    font.weight: Font.DemiBold
                }

                // 当前角色
                Label {
                    text: app.role === "outer"
                          ? "外机"
                          : (app.role === "inner" ? "内机" : "未选择角色")
                    color: "#93C5FD"
                    font.pixelSize: 13
                    opacity: 0.95
                }
            }
        }
    }

    // ========================================================================
    // 主内容区域:StackView(页面容器)
    // ========================================================================

    StackView {
        id: stack
        anchors.fill: parent

        // ---- 初始页面:角色选择 ----
        initialItem: roleSelectComponent
    }

    // ---- 角色选择页面组件 ----
    Component {
        id: roleSelectComponent
        RoleSelectPage {
            // 传递属性给 RoleSelectPage
            stackView: stack
            outerComponent: outerPageComponent
            innerComponent: innerPageComponent
        }
    }

    // ---- 外机页面组件 ----
    Component {
        id: outerPageComponent
        OuterPage { }
    }

    // ---- 内机页面组件 ----
    Component {
        id: innerPageComponent
        InnerPage { }
    }

    // ========================================================================
    // 底部状态栏(footer)
    // ========================================================================

    footer: Rectangle {
        height: 40
        color: "#0B1220"
        border.color: "#1B2A44"
        border.width: 1

        RowLayout {
            anchors.fill: parent
            anchors.margins: 10
            spacing: 10

            // ---- 数据库路径 ----
            Label {
                Layout.fillWidth: true
                color: "#CBD5E1"
                font.pixelSize: 12
                text: app.dbPath === ""
                      ? "DB: 未初始化"
                      : ("DB: " + app.dbPath)
                elide: Label.ElideRight
            }

            // ---- 状态指示灯 ----
            Rectangle {
                width: 8
                height: 8
                radius: 4
                color: app.lastError === "" ? "#22C55E" : "#EF4444"
                opacity: 0.9
            }

            // ---- 状态文字 ----
            Label {
                color: app.lastError === "" ? "#86EFAC" : "#FCA5A5"
                font.pixelSize: 12
                text: app.lastError === "" ? "OK" : ("ERROR: " + app.lastError)
                elide: Label.ElideRight
            }
        }
    }
}

界面布局图

cpp 复制代码
┌─────────────────────────────────────────────────────────────┐
│ ● DoorQML  外机                                            │ ← 标题栏
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              RoleSelectPage                        │    │
│  │         (角色选择页面 - 初始页面)                  │    │
│  │                                                   │    │
│  │          [外机]    [内机]                         │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │              OuterPage / InnerPage                 │    │
│  │          (选择角色后切换)                          │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│ DB: /Users/xxx/AppData/intercom.db       ●  OK             │ ← 状态栏
└─────────────────────────────────────────────────────────────┘

页面切换流程

cpp 复制代码
程序启动
    ↓
Main.qml 加载
    ↓
StackView 显示 roleSelectComponent
    ↓
RoleSelectPage 显示(角色选择)
    ↓
用户点击"外机"或"内机"
    ↓
RoleSelectPage 调用 stackView.push()
    ↓
切换到 outerPageComponent 或 innerPageComponent

状态显示说明

标题栏状态灯

状态 颜色 条件
正常 蓝色(#38BDF8 app.lastError 为空
错误 红色(#FF6B6B app.lastError 非空

底部状态灯

状态 颜色 条件
正常 绿色(#22C55E app.lastError 为空
错误 红色(#EF4444 app.lastError 非空

数据库路径显示

cpp 复制代码
text: app.dbPath === "" ? "DB: 未初始化" : ("DB: " + app.dbPath)
  • 未初始化 → 显示 "DB: 未初始化"

  • 已初始化 → 显示 "DB: /path/to/intercom.db"


关键设计模式

模式 体现
单例模式 ApplicationWindow 只有一个实例
工厂模式 Component 定义可复用的页面组件
策略模式 StackView 根据用户选择切换不同页面
观察者模式 状态变化自动更新 UI(属性绑定)

属性传递

cpp 复制代码
Component {
    id: roleSelectComponent
    RoleSelectPage {
        stackView: stack          // 传递 StackView 引用
        outerComponent: outerPageComponent  // 传递外机页面组件
        innerComponent: innerPageComponent  // 传递内机页面组件
    }
}

这样 RoleSelectPage 可以通过 stackView.push(outerComponent) 来切换页面。

OuterPage.qml

文件概述

属性 说明
文件名 OuterPage.qml
作用 外机(门口机)的主界面
功能 连接内机 + 通话界面
技术栈 QML、QtQuick.Controls、QtQuick.Layouts

无注释版

cpp 复制代码
// OuterPage.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 12
        spacing: 12

        // ===== 顶部连接卡片(替代 GroupBox 的默认白边框)=====
        Frame {
            Layout.fillWidth: true
            padding: 12

            background: Rectangle {
                radius: 16
                color: "#0B1220"
                border.color: "#1B2A44"
                border.width: 1
            }

            ColumnLayout {
                anchors.fill: parent
                spacing: 10

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    Rectangle { width: 8; height: 8; radius: 4; color: "#F59E0B"; opacity: 0.9 }

                    Label {
                        text: "外机连接内机"
                        color: "white"
                        font.pixelSize: 16
                        font.weight: Font.DemiBold
                        Layout.fillWidth: true
                    }

                    Label {
                        text: app.signaling.connected ? "已连接" : "未连接"
                        color: app.signaling.connected ? "#22C55E" : "#F59E0B"
                        font.pixelSize: 12
                        opacity: 0.95
                    }
                }

                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    TextField {
                        id: hostField
                        Layout.fillWidth: true
                        placeholderText: "内机 IP / Host"
                        text: app.settings.getString("server_host", "127.0.0.1")
                        onEditingFinished: app.settings.setString("server_host", text)

                        color: "white"
                        background: Rectangle {
                            radius: 10
                            color: "#0F172A"
                            border.color: "#223047"
                            border.width: 1
                        }
                    }

                    TextField {
                        id: portField
                        Layout.preferredWidth: 110
                        inputMethodHints: Qt.ImhDigitsOnly
                        placeholderText: "端口"
                        text: String(app.settings.getInt("server_port", 12345))
                        onEditingFinished: app.settings.setInt("server_port", parseInt(text))

                        color: "white"
                        background: Rectangle {
                            radius: 10
                            color: "#0F172A"
                            border.color: "#223047"
                            border.width: 1
                        }
                    }

                    Button {
                        text: app.signaling.connected ? "断开" : "连接"
                        implicitHeight: 36
                        implicitWidth: 86

                        onClicked: {
                            if (app.signaling.connected) {
                                app.signaling.disconnectFromHost()
                            } else {
                                app.settings.setString("server_host", hostField.text)
                                app.settings.setInt("server_port", parseInt(portField.text))
                                app.signaling.connectToHost(hostField.text, parseInt(portField.text))
                            }
                        }

                        contentItem: Text {
                            text: parent.text
                            color: "white"
                            font.pixelSize: 13
                            font.weight: Font.Medium
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                        background: Rectangle {
                            radius: 10
                            border.width: 1
                            border.color: parent.enabled ? "#2A3B5E" : "#223047"
                            color: parent.down ? "#1A2A46" : "#0F172A"
                        }
                    }

                    Label {
                        Layout.fillWidth: true
                        text: app.signaling.connected ? ("Peer: " + app.signaling.peer) : "等待连接..."
                        color: "#94A3B8"
                        elide: Label.ElideRight
                    }
                }
            }
        }

        CallStage {
            Layout.fillWidth: true
            Layout.fillHeight: true
            mode: "outer"
        }
    }
}

有注释版

cpp 复制代码
// ============================================================================
// OuterPage.qml - 外机(门口机)主界面
// ============================================================================

// ---- 导入语句 ----
import QtQuick           // QML 核心模块
import QtQuick.Controls  // 控件模块
import QtQuick.Layouts   // 布局管理器

// ---- 根元素 ----
Item {

    // ---- 主布局:垂直排列 ----
    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 12
        spacing: 12

        // ====================================================================
        // 第一部分:连接控制面板
        // ====================================================================

        // ---- Frame:带边框的容器 ----
        Frame {
            Layout.fillWidth: true
            padding: 12

            // ---- 边框样式 ----
            background: Rectangle {
                radius: 16
                color: "#0B1220"          // 深色背景
                border.color: "#1B2A44"   // 深蓝色边框
                border.width: 1
            }

            // ---- 内容:垂直排列 ----
            ColumnLayout {
                anchors.fill: parent
                spacing: 10

                // ---- 标题行 ----
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    // 状态指示灯(橙色圆点)
                    Rectangle {
                        width: 8
                        height: 8
                        radius: 4
                        color: "#F59E0B"
                        opacity: 0.9
                    }

                    // 标题文字
                    Label {
                        text: "外机连接内机"
                        color: "white"
                        font.pixelSize: 16
                        font.weight: Font.DemiBold
                        Layout.fillWidth: true
                    }

                    // 连接状态
                    Label {
                        text: app.signaling.connected ? "已连接" : "未连接"
                        color: app.signaling.connected ? "#22C55E" : "#F59E0B"
                        font.pixelSize: 12
                        opacity: 0.95
                    }
                }

                // ---- 控制行:IP + 端口 + 按钮 ----
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 10

                    // ---- IP 地址输入框 ----
                    TextField {
                        id: hostField
                        Layout.fillWidth: true
                        placeholderText: "内机 IP / Host"
                        text: app.settings.getString("server_host", "127.0.0.1")

                        // 编辑完成时保存配置
                        onEditingFinished: app.settings.setString("server_host", text)

                        // ---- 样式 ----
                        color: "white"
                        background: Rectangle {
                            radius: 10
                            color: "#0F172A"
                            border.color: "#223047"
                            border.width: 1
                        }
                    }

                    // ---- 端口输入框 ----
                    TextField {
                        id: portField
                        Layout.preferredWidth: 110
                        inputMethodHints: Qt.ImhDigitsOnly  // 只允许数字
                        placeholderText: "端口"
                        text: String(app.settings.getInt("server_port", 12345))

                        // 编辑完成时保存配置
                        onEditingFinished: app.settings.setInt("server_port", parseInt(text))

                        // ---- 样式 ----
                        color: "white"
                        background: Rectangle {
                            radius: 10
                            color: "#0F172A"
                            border.color: "#223047"
                            border.width: 1
                        }
                    }

                    // ---- 连接/断开按钮 ----
                    Button {
                        text: app.signaling.connected ? "断开" : "连接"
                        implicitHeight: 36
                        implicitWidth: 86

                        onClicked: {
                            if (app.signaling.connected) {
                                // 如果已连接,断开
                                app.signaling.disconnectFromHost()
                            } else {
                                // 如果未连接,保存配置并连接
                                app.settings.setString("server_host", hostField.text)
                                app.settings.setInt("server_port", parseInt(portField.text))
                                app.signaling.connectToHost(hostField.text, parseInt(portField.text))
                            }
                        }

                        // ---- 按钮样式 ----
                        contentItem: Text {
                            text: parent.text
                            color: "white"
                            font.pixelSize: 13
                            font.weight: Font.Medium
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                        background: Rectangle {
                            radius: 10
                            border.width: 1
                            border.color: parent.enabled ? "#2A3B5E" : "#223047"
                            color: parent.down ? "#1A2A46" : "#0F172A"
                        }
                    }

                    // ---- 对端信息显示 ----
                    Label {
                        Layout.fillWidth: true
                        text: app.signaling.connected
                              ? ("Peer: " + app.signaling.peer)
                              : "等待连接..."
                        color: "#94A3B8"
                        elide: Label.ElideRight
                    }
                }
            }
        }

        // ====================================================================
        // 第二部分:通话界面
        // ====================================================================

        // ---- CallStage:通话主界面 ----
        // 这是外机和内机共用的通话界面组件
        // mode: "outer" 表示外机模式
        CallStage {
            Layout.fillWidth: true
            Layout.fillHeight: true
            mode: "outer"
        }
    }
}

界面布局图

cpp 复制代码
┌─────────────────────────────────────────────────────────────┐
│  ● 外机连接内机                    未连接                  │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  [内机 IP / Host: 10.11.100.207]  [端口: 12345]    │   │
│  │  [连接]  等待连接...                                  │   │
│  └──────────────────────────────────────────────────────┘   │
│                                                             │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  ┌──────────────────────────────────────────────┐   │   │
│  │  │              CallStage                       │   │   │
│  │  │        (通话界面,mode: "outer")             │   │   │
│  │  └──────────────────────────────────────────────┘   │   │
│  └──────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

功能说明

功能 说明 用户操作
IP 设置 设置内机的 IP 地址 修改 IP,编辑完成自动保存
端口设置 设置内机的端口 修改端口,编辑完成自动保存
连接内机 外机连接到内机 点击"连接"按钮
断开连接 断开与内机的连接 点击"断开"按钮
查看状态 显示连接状态和对端信息 自动显示
通话界面 显示通话相关功能 由 CallStage 组件提供

配置保存

cpp 复制代码
// IP 配置在编辑完成后自动保存
TextField {
    id: hostField
    text: app.settings.getString("server_host", "127.0.0.1")
    onEditingFinished: app.settings.setString("server_host", text)
}

// 端口配置在编辑完成后自动保存
TextField {
    id: portField
    text: String(app.settings.getInt("server_port", 12345))
    onEditingFinished: app.settings.setInt("server_port", parseInt(text))
}

// 点击连接时,先保存配置再连接
onClicked: {
    app.settings.setString("server_host", hostField.text)
    app.settings.setInt("server_port", parseInt(portField.text))
    app.signaling.connectToHost(hostField.text, parseInt(portField.text))
}
复制代码

连接状态显示

状态 显示颜色 显示文字
已连接 绿色(#22C55E "已连接" + "Peer: IP:端口"
未连接 橙色(#F59E0B "未连接" + "等待连接..."

与 InnerPage 的区别

特性 OuterPage(外机) InnerPage(内机)
主要功能 连接内机 监听外机
服务端/客户端 客户端(连接) 服务端(监听)
IP 配置 设置内机 IP 无(监听所有 IP)
端口配置 设置目标端口 设置监听端口
连接状态 显示是否连接到内机 显示是否有外机连接

状态指示灯说明

指示灯 位置 颜色 含义
标题行圆点 连接卡片标题前 橙色(#F59E0B 装饰性提示
连接状态文字 标题行右侧 绿色/橙色 已连接/未连接

PersonTab.qml

cpp 复制代码
文件概述
属性	说明
文件名	PersonTab.qml
作用	显示和管理授权人员列表
功能	查看、启用/禁用、删除人员
技术栈	QML、QtQuick.Controls、QtQuick.Layouts

无注释版

cpp 复制代码
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    anchors.fill: parent

    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 10
        spacing: 10

        RowLayout {
            Layout.fillWidth: true
            spacing: 8

            Button {
                text: "刷新"
                onClicked: app.persons.reload()
            }
            Label {
                text: app.persons.lastError === "" ? ("人数: " + app.persons.count) : ("错误: " + app.persons.lastError)
                Layout.fillWidth: true
                elide: Label.ElideRight
            }
        }

        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#101010"
            radius: 6

            ListView {
                anchors.fill: parent
                anchors.margins: 8
                clip: true
                spacing: 6
                model: app.persons

                delegate: Rectangle {
                    width: ListView.view.width
                    height: 48
                    color: "#202020"
                    radius: 6

                    RowLayout {
                        anchors.fill: parent
                        anchors.margins: 8
                        spacing: 10

                        Label {
                            text: "#" + pid + "  " + name
                            color: "white"
                            Layout.fillWidth: true
                            elide: Label.ElideRight
                        }

                        Switch {
                            checked: enabled
                            text: checked ? "启用" : "禁用"
                            onToggled: app.persons.setEnabled(index, checked)
                        }

                        Button {
                            text: "删除"
                            onClicked: app.persons.remove(index)
                        }
                    }
                }
            }
        }

        Label {
            text: "说明:录入功能在【门口】页;这里仅管理授权人员启用/禁用/删除。"
            opacity: 0.8
            wrapMode: Text.WordWrap
        }
    }
}

有注释版

cpp 复制代码
// ============================================================================
// PersonTab.qml - 人员管理标签页
// ============================================================================

// ---- 导入语句 ----
import QtQuick           // QML 核心模块
import QtQuick.Controls  // 控件模块
import QtQuick.Layouts   // 布局管理器

// ---- 根元素 ----
Item {
    anchors.fill: parent

    // ---- 主布局:垂直排列 ----
    ColumnLayout {
        anchors.fill: parent
        anchors.margins: 10
        spacing: 10

        // ====================================================================
        // 第一部分:控制栏
        // ====================================================================

        RowLayout {
            Layout.fillWidth: true
            spacing: 8

            // ---- 刷新按钮 ----
            Button {
                text: "刷新"
                onClicked: app.persons.reload()
            }

            // ---- 状态显示 ----
            Label {
                text: app.persons.lastError === ""
                      ? ("人数: " + app.persons.count)   // 正常:显示人数
                      : ("错误: " + app.persons.lastError) // 错误:显示错误信息
                Layout.fillWidth: true
                elide: Label.ElideRight
            }
        }

        // ====================================================================
        // 第二部分:人员列表
        // ====================================================================

        // ---- 列表容器 ----
        Rectangle {
            Layout.fillWidth: true
            Layout.fillHeight: true
            color: "#101010"          // 深色背景
            radius: 6

            // ---- 列表视图 ----
            ListView {
                anchors.fill: parent
                anchors.margins: 8
                clip: true            // 裁剪超出部分
                spacing: 6            // 每项间距

                // ---- 绑定数据模型 ----
                model: app.persons

                // ---- 列表项委托 ----
                delegate: Rectangle {
                    width: ListView.view.width
                    height: 48
                    color: "#202020"   // 深灰色背景
                    radius: 6

                    // ---- 内容布局 ----
                    RowLayout {
                        anchors.fill: parent
                        anchors.margins: 8
                        spacing: 10

                        // ---- 人员信息(ID + 姓名) ----
                        Label {
                            text: "#" + pid + "  " + name
                            color: "white"
                            Layout.fillWidth: true
                            elide: Label.ElideRight  // 超出省略
                        }

                        // ---- 启用/禁用开关 ----
                        Switch {
                            checked: enabled
                            text: checked ? "启用" : "禁用"

                            // 切换时调用 C++ 方法更新数据库
                            onToggled: app.persons.setEnabled(index, checked)
                        }

                        // ---- 删除按钮 ----
                        Button {
                            text: "删除"
                            onClicked: app.persons.remove(index)
                        }
                    }
                }
            }
        }

        // ====================================================================
        // 第三部分:说明文字
        // ====================================================================

        Label {
            text: "说明:录入功能在【门口】页;这里仅管理授权人员启用/禁用/删除。"
            opacity: 0.8
            wrapMode: Text.WordWrap
        }
    }
}

界面布局图

cpp 复制代码
┌─────────────────────────────────────────────────────────────┐
│  [刷新]  人数: 5                                            │
│  ┌──────────────────────────────────────────────────────┐   │
│  │  #1  张三                  [启用]  [删除]           │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │  #2  李四                  [禁用]  [删除]           │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │  #3  王五                  [启用]  [删除]           │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │  #4  赵六                  [启用]  [删除]           │   │
│  ├──────────────────────────────────────────────────────┤   │
│  │  #5  钱七                  [禁用]  [删除]           │   │
│  └──────────────────────────────────────────────────────┘   │
│  说明:录入功能在【门口】页;这里仅管理授权人员启用/禁用/删除。│
└─────────────────────────────────────────────────────────────┘

功能说明

功能 说明 用户操作
刷新 重新加载人员列表 点击"刷新"按钮
查看人员 显示人员 ID 和姓名 自动显示
启用/禁用 控制人员是否可被识别 切换开关
删除 从数据库中删除人员 点击"删除"按钮

数据模型映射

QML 字段 C++ 角色 说明
pid IdRole 人员 ID
name NameRole 人员姓名
enabled EnabledRole 是否启用

操作流程

启用/禁用人员

cpp 复制代码
用户切换开关
    ↓
onToggled: app.persons.setEnabled(index, checked)
    ↓
PersonModel::setEnabled(row, enabled)
    ↓
PersonRepo::setEnabled(id, enabled)
    ↓
数据库 UPDATE person SET enabled = ?
    ↓
模型更新 → QML 自动刷新

删除人员

cpp 复制代码
用户点击"删除"按钮
    ↓
onClicked: app.persons.remove(index)
    ↓
PersonModel::remove(row)
    ↓
PersonRepo::removePerson(id)
    ↓
数据库 DELETE FROM person WHERE id = ?
    ↓
模型更新 → QML 自动刷新

状态显示

状态 显示文字 条件
正常 "人数: N" app.persons.lastError === ""
错误 "错误: xxx" app.persons.lastError !== ""

设计说明

职责分离

  • PersonTab:只负责显示和管理人员列表

  • FaceController :负责人员录入(在 CallStage.qml 中)

  • PersonRepo:负责数据库操作

数据流向

cpp 复制代码
PersonTab (QML)
    ↓
PersonModel (C++ 模型)
    ↓
PersonRepo (C++ 数据访问)
    ↓
SQLite 数据库

RoleSelectPage.qml

文件概述

属性 说明
文件名 RoleSelectPage.qml
作用 程序启动后的初始页面,让用户选择角色
功能 选择"外机"或"内机"模式
技术栈 QML、QtQuick.Controls、QtQuick.Layouts

无注释版

cpp 复制代码
// RoleSelectPage.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Item {
    property var stackView
    property var outerComponent
    property var innerComponent

    // 背景
    Rectangle {
        anchors.fill: parent
        color: "#070B12"
    }

    // 柔和光晕
    Rectangle {
        width: parent.width * 0.9
        height: parent.height * 0.7
        anchors.centerIn: parent
        radius: 24
        color: "#0B1220"
        opacity: 0.5
    }

    // 中央卡片
    Frame {
        anchors.centerIn: parent
        width: Math.min(720, parent.width - 48)
        padding: 20

        background: Rectangle {
            radius: 18
            color: "#0B1220"
            border.color: "#1B2A44"
            border.width: 1
        }

        ColumnLayout {
            anchors.fill: parent
            spacing: 14

            RowLayout {
                Layout.fillWidth: true
                spacing: 10

                Rectangle {
                    width: 10; height: 10; radius: 5
                    color: "#38BDF8"
                    opacity: 0.9
                }

                Label {
                    text: "选择运行角色"
                    color: "white"
                    font.pixelSize: 22
                    font.weight: Font.DemiBold
                    Layout.fillWidth: true
                }
            }

            Label {
                text: "提示:内机先启动监听(默认 12345),外机填写内机 IP 后连接。"
                color: "#94A3B8"
                wrapMode: Text.WordWrap
                Layout.fillWidth: true
            }

            RowLayout {
                Layout.fillWidth: true
                spacing: 12

                Button {
                    Layout.fillWidth: true
                    implicitHeight: 44
                    text: "外机(门口机)"
                    onClicked: {
                        app.role = "outer"
                        app.initIfNeeded()
                        if (stackView && outerComponent) stackView.replace(outerComponent)
                        else console.error("stackView/outerComponent is null")
                    }

                    contentItem: Text {
                        text: parent.text
                        color: "white"
                        font.pixelSize: 14
                        font.weight: Font.Medium
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                    background: Rectangle {
                        radius: 12
                        border.width: 1
                        border.color: "#2A3B5E"
                        color: parent.down ? "#1A2A46" : "#0F172A"
                    }
                }

                Button {
                    Layout.fillWidth: true
                    implicitHeight: 44
                    text: "内机(门禁终端)"
                    onClicked: {
                        app.role = "inner"
                        app.initIfNeeded()
                        if (stackView && innerComponent) stackView.replace(innerComponent)
                        else console.error("stackView/innerComponent is null")
                    }

                    contentItem: Text {
                        text: parent.text
                        color: "white"
                        font.pixelSize: 14
                        font.weight: Font.Medium
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                    background: Rectangle {
                        radius: 12
                        border.width: 1
                        border.color: "#2A3B5E"
                        color: parent.down ? "#1A2A46" : "#0F172A"
                    }
                }
            }
        }
    }
}

有注释版

cpp 复制代码
// ============================================================================
// RoleSelectPage.qml - 角色选择页面
// ============================================================================

// ---- 导入语句 ----
import QtQuick           // QML 核心模块
import QtQuick.Controls  // 控件模块
import QtQuick.Layouts   // 布局管理器

// ---- 根元素 ----
Item {
    // ---- 从父组件接收的属性 ----
    // stackView:页面容器引用(用于切换页面)
    property var stackView

    // outerComponent:外机页面组件(用于切换)
    property var outerComponent

    // innerComponent:内机页面组件(用于切换)
    property var innerComponent

    // ========================================================================
    // 背景层
    // ========================================================================

    // ---- 主背景 ----
    Rectangle {
        anchors.fill: parent
        color: "#070B12"          // 深色背景
    }

    // ---- 柔和光晕效果 ----
    Rectangle {
        width: parent.width * 0.9
        height: parent.height * 0.7
        anchors.centerIn: parent
        radius: 24
        color: "#0B1220"          // 深色半透明
        opacity: 0.5
    }

    // ========================================================================
    // 中央卡片
    // ========================================================================

    Frame {
        anchors.centerIn: parent
        width: Math.min(720, parent.width - 48)   // 最大宽度 720px
        padding: 20

        // ---- 卡片边框 ----
        background: Rectangle {
            radius: 18
            color: "#0B1220"
            border.color: "#1B2A44"
            border.width: 1
        }

        // ---- 卡片内容 ----
        ColumnLayout {
            anchors.fill: parent
            spacing: 14

            // ---- 标题行 ----
            RowLayout {
                Layout.fillWidth: true
                spacing: 10

                // 状态指示灯(蓝色)
                Rectangle {
                    width: 10
                    height: 10
                    radius: 5
                    color: "#38BDF8"
                    opacity: 0.9
                }

                // 标题文字
                Label {
                    text: "选择运行角色"
                    color: "white"
                    font.pixelSize: 22
                    font.weight: Font.DemiBold
                    Layout.fillWidth: true
                }
            }

            // ---- 提示文字 ----
            Label {
                text: "提示:内机先启动监听(默认 12345),外机填写内机 IP 后连接。"
                color: "#94A3B8"
                wrapMode: Text.WordWrap
                Layout.fillWidth: true
            }

            // ---- 两个角色按钮 ----
            RowLayout {
                Layout.fillWidth: true
                spacing: 12

                // ---- 外机按钮 ----
                Button {
                    Layout.fillWidth: true
                    implicitHeight: 44
                    text: "外机(门口机)"

                    onClicked: {
                        // 1. 设置角色
                        app.role = "outer"
                        // 2. 初始化系统
                        app.initIfNeeded()
                        // 3. 切换到外机页面
                        if (stackView && outerComponent) {
                            stackView.replace(outerComponent)
                        } else {
                            console.error("stackView/outerComponent is null")
                        }
                    }

                    // ---- 按钮样式 ----
                    contentItem: Text {
                        text: parent.text
                        color: "white"
                        font.pixelSize: 14
                        font.weight: Font.Medium
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                    background: Rectangle {
                        radius: 12
                        border.width: 1
                        border.color: "#2A3B5E"
                        color: parent.down ? "#1A2A46" : "#0F172A"
                    }
                }

                // ---- 内机按钮 ----
                Button {
                    Layout.fillWidth: true
                    implicitHeight: 44
                    text: "内机(门禁终端)"

                    onClicked: {
                        // 1. 设置角色
                        app.role = "inner"
                        // 2. 初始化系统
                        app.initIfNeeded()
                        // 3. 切换到内机页面
                        if (stackView && innerComponent) {
                            stackView.replace(innerComponent)
                        } else {
                            console.error("stackView/innerComponent is null")
                        }
                    }

                    // ---- 按钮样式 ----
                    contentItem: Text {
                        text: parent.text
                        color: "white"
                        font.pixelSize: 14
                        font.weight: Font.Medium
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                    background: Rectangle {
                        radius: 12
                        border.width: 1
                        border.color: "#2A3B5E"
                        color: parent.down ? "#1A2A46" : "#0F172A"
                    }
                }
            }
        }
    }
}

界面布局图

cpp 复制代码
┌─────────────────────────────────────────────────────────────┐
│                                                             │
│                  [柔和光晕背景]                              │
│                                                             │
│  ┌─────────────────────────────────────────────────────┐    │
│  │  ● 选择运行角色                                    │    │
│  │  提示:内机先启动监听(默认 12345),              │    │
│  │  外机填写内机 IP 后连接。                          │    │
│  │                                                   │    │
│  │  ┌───────────────────┐  ┌───────────────────┐     │    │
│  │  │  外机(门口机)    │  │  内机(门禁终端)  │     │    │
│  │  └───────────────────┘  └───────────────────┘     │    │
│  └─────────────────────────────────────────────────────┘    │
│                                                             │
└─────────────────────────────────────────────────────────────┘

页面切换流程

cpp 复制代码
程序启动
    ↓
Main.qml 加载 RoleSelectPage
    ↓
用户点击"外机(门口机)"
    ↓
1. app.role = "outer"          ← 设置角色
2. app.initIfNeeded()          ← 初始化系统
3. stackView.replace(outerComponent)  ← 切换到外机页面
    ↓
显示 OuterPage(外机主界面)

用户点击"内机(门禁终端)"
    ↓
1. app.role = "inner"          ← 设置角色
2. app.initIfNeeded()          ← 初始化系统
3. stackView.replace(innerComponent)  ← 切换到内机页面
    ↓
显示 InnerPage(内机主界面)

属性传递

cpp 复制代码
// 在 Main.qml 中
Component {
    id: roleSelectComponent
    RoleSelectPage {
        stackView: stack               // 传递 StackView 引用
        outerComponent: outerPageComponent  // 传递外机页面
        innerComponent: innerPageComponent  // 传递内机页面
    }
}

这样 RoleSelectPage 就能在点击按钮时切换页面。


设计说明

视觉层次

层级 元素 颜色
背景 主背景矩形 #070B12
中层 光晕矩形 #0B1220 (50% 透明度)
前景 中央卡片 #0B1220 + 边框 #1B2A44

交互反馈

  • 按钮按下时颜色变暗(parent.down ? "#1A2A46" : "#0F172A"

  • 按钮有边框(border.color: "#2A3B5E"

相关推荐
fpcc1 小时前
跟我学C++中级篇——编译期的条件选择
开发语言·c++
SomeB1oody1 小时前
【RustyML入门】6.3. 并行归约
开发语言·后端·机器学习·rust·教程
mqiqe3 小时前
AgentScope Java 2.0 协议集成全景解析:A2A、AG-UI、Agent Protocol 三大开放协议实战指南
java·开发语言·ui
lzhdim3 小时前
12、JavaScript常见的内存泄露问题 - JavaScript学习系列文章
开发语言·前端·javascript·学习·ecmascript
脉动数据行情3 小时前
Java 实现台股 TWSE/TPEx 行情采集(个股 + K 线)
java·开发语言·twse·tpex·台股
爱学习的小邓同学3 小时前
Golang --- (1)第一个Golang程序
开发语言·后端·golang
zx_741484814 小时前
【Python 入门】面向对象基础:类、对象、成员变量与构造方法
开发语言·python
liangshanbo12155 小时前
虚拟列表深度面试题整理
java·开发语言·前端
ZISHU_9875 小时前
用 TLabel 给 SynTouch BioTac 数据做语义标注:从原始信号到结构化标注
开发语言·人工智能·python·数据·机器人触觉