一、项目介绍

1.1 项目背景与定位

        随着住宅小区规模不断扩大,传统依靠纸质登记、电话沟通和人工统计的物业管理方式逐渐暴露出效率低、信息不透明、数据分散、协同困难等问题。尤其在报修处理、物业收费、车辆与车位管理、访客登记、公告发布等高频场景中,线下流程容易出现记录遗漏、进度不可追踪和责任界定不清等情况。

        本项目面向社区物业日常运营场景,建设一套集业主服务、物业管理、维修协同、访客登记与数据统计于一体的数字物业管理系统。系统通过统一登录入口和角色化功能分流,将原本分散的业务流程在线化、规范化、可追踪化,从而提升物业管理效率和住户服务体验。

1.2 建设目标

  1. 将报修、收费、公告、访客登记等高频业务线上化。

  2. 将业主、员工、维修工和管理员纳入统一平台管理。

  3. 建立报修闭环、收费闭环和数据统计闭环。

  4. 为物业管理提供更清晰的过程追踪能力和基础数据支撑。

1.3 适用对象

角色 说明 主要职责
业主 小区住户 报修、缴费、查看公告、意见反馈、车辆管理、个人信息维护
普通员工 前台或日常物业工作人员 访客登记、排班查看、公告查看、紧急情况上报
维修工 维修处理人员 接单、处理工单、查看历史工单、工具管理、查看评价
管理员 物业管理人员 用户、收费、报修、车位、车辆、公告、设施、环卫、访客和统计分析管理

1.4 系统架构概述

项目采用典型分层结构,主要由 UI、BLL、DAL 和 Models 四部分组成:

        UI 层:负责界面交互与功能入口,包含 Owner、Repair、Staff、Visitor 等模块。
        BLL 层:负责业务逻辑封装,如报修、缴费、公告、访客、统计等。
        DAL 层:负责数据库访问与数据读写。
        Models 层:负责业务实体、上下文对象和查询条件对象的定义。

二、 项目主要功能截图

管理员端:

业主端:

维修工端:

普通员工端:

三、我负责的模块

        我负责的是业主端的所有功能,本来我还需要完成一个排班管理(管理员部分),但由于我是组长,需要统筹一些内容,时间来不及,就只完成了业主的所有功能,可以从左边的菜单看出我主要的功能,然后对于样式来说,最开始没有统一样式颜色,答辩完成后的第一个晚上只修改了业主端的部分样式,还没有完全统一,但内容基本完善。

主窗体中适合复用的代码内容

       就是在MainPanel中嵌套其他的窗体,具体: 在OwnerForm中的加载事件中添加以下代码:

// 自动给左侧所有按钮绑定点击事件
foreach (Control control in LeftPanel.Controls)
{
    if (control is Button btn)
    {
        btn.Click += NavBtn_Click;
    }
}

// 默认显示在线报修
NavigateUtil.NavigateUtilTo<OnlineRepairForm>(MainPanel);

OwnerForm.cs中添加以下方法

/// <summary>
/// 左侧菜单所有按钮共用这一个点击事件
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void NavBtn_Click(object sender, EventArgs e)
{
    Button clickedBtn = sender as Button;
    if (clickedBtn == null)
    {
        return;
    }

    // 重置所有按钮颜色
    foreach (Control control in LeftPanel.Controls)
    {
        if (control is Button btn)
        {
            btn.BackColor = Color.FromArgb(24, 30, 54); // 未选中颜色
        }
    }
    // 设置当前点击按钮为选中色
    clickedBtn.BackColor = Color.FromArgb(46, 51, 73);
    // 添加侧边指示条
    PnlNav.Height = clickedBtn.Height;
    PnlNav.Top = clickedBtn.Top;
    PnlNav.Left = clickedBtn.Left;

    RefreshOwnerHeader(false);

    // 根据 Tag 跳转页面
    string tag = clickedBtn.Tag?.ToString();
    OpenPageByTag(tag);
}

/// <summary>
/// 页面跳转核心方法
/// </summary>
/// <param name="tag"></param>
private void OpenPageByTag(string tag)
{
    switch (tag)
    {
        case "OnlineRepair": // 在线报修
            NavigateUtil.NavigateUtilTo<OnlineRepairForm>(MainPanel);
            break;

        case "MyRepairOrder":// 我的维修工单
            NavigateUtil.NavigateUtilTo<MyRepairOrderForm>(MainPanel);
            break;

        case "RepairEvaluate":// 工单评价
            NavigateUtil.NavigateUtilTo<RepairEvaluateForm>(MainPanel);
            break;

        case "PayFee": // 个人缴费
            NavigateUtil.NavigateUtilTo<PayFeeForm>(MainPanel);
            break;

        case "VehicleManage":// 车辆管理
            NavigateUtil.NavigateUtilTo<VehicleManageForm>(MainPanel);
            break;

        case "NoticeAndFeedback":  //公告与意见反馈
            NavigateUtil.NavigateUtilTo<NoticeAndFeedbackForm>(MainPanel);
            break;

        case "MyInfo": // 个人信息
            NavigateUtil.NavigateUtilTo<MyInfoForm>(MainPanel);
            break;

        case "AddFamilyInfo":  //添加家人信息
            NavigateUtil.NavigateUtilTo<AddFamilyInfoForm>(MainPanel);
            break;

        case "AIHelper":  //物业智慧助手
            NavigateUtil.NavigateUtilTo<AIHelperForm>(MainPanel);
            break;
    }
}

UI的工具文件夹中添加NavigateUtil.cs

using Sunny.UI;
using System.Windows.Forms;

namespace UI.Owner.Util
{
    /// <summary>
    /// 导航工具类
    /// </summary>
    /// <typeparam name="T"></typeparam>
    public static class NavigateUtil
    {
        public static void NavigateUtilTo<T>(UIPanel panel) where T : UIForm, new()
        {
            // 确保可见
            panel.Visible = true;
            // 把这个 Panel 控件放到最顶层显示
            panel.BringToFront();
            // 指示要显示的窗体是否存在
            bool isExist = false;
            foreach (Control control in panel.Controls)
            {
                Form form = control as Form;
                // 如果这个窗体是要显示的窗体
                if (form is T)
                {
                    // 显示
                    form.Show();
                    isExist = true;
                    break;
                }
                else
                {
                    form.Hide(); //其他窗体隐藏, 只有一个窗体显示
                }
            }
            if (!isExist)
            {
                // 如果要显示的窗体不存在,创建
                T childForm = new T();
                childForm.TopLevel = false;// 将子窗体设置非顶级窗体
                childForm.ShowTitle = false; // 不显示标题栏
                childForm.Dock = DockStyle.Fill; // 填充父容器
                childForm.WindowState = FormWindowState.Maximized;// 最大化显示
                panel.Controls.Add(childForm); // 将子窗体添加到容器中
                childForm.Show();// 显示子窗体
            }
        }
    }
}

3.1 在线报修

       这部分图片请看业主端的截图,之前做了一部分OnlineRepairForm,按照自己的想法,有业主姓名,维修类型,根据维修类型有维修工的姓名下拉框显示,有详情内容和上传照片,还有填写地址,最后就是提交按钮和取消按钮了,和成员探讨了一下后,发现不用填写维修工姓名和地址,我们做的维修工单表没有这部分字段,所以这两部分需要管理员在后台处理(多表联查),之后我增加了工单编号(自动生成,只读形式),上传照片那块也是个重难点,还增加了上门报修时间,然后在我的报修工单中有编辑按钮,我就复用了OnlineRepairForm,单窗体双模式

上传照片以及它的显示

// 所有上传图片统一放到程序目录下的 Upload\RepairImages
private readonly string imageRootPath = Path.Combine(Application.StartupPath, "Upload", "RepairImages");

/// <summary>
/// 选择本地图片并复制到系统上传目录,同时显示预览图
/// </summary>
/// <param name="sender">事件源</param>
/// <param name="e">事件参数</param>
private void UploadImageButton_Click(object sender, EventArgs e)
{
    openFileDialog1.Filter = "图片文件(*.jpg;*.jpeg;*.png;*.bmp)|*.jpg;*.jpeg;*.png;*.bmp|所有文件(*.*)|*.*";
    openFileDialog1.Title = "请选择要上传的图片";

    if (openFileDialog1.ShowDialog() == DialogResult.OK)
    {
        try
        {
            // 生成唯一文件名,避免同名图片互相覆盖
            string originalFilePath = openFileDialog1.FileName;
            string fileExt = Path.GetExtension(originalFilePath);
            string newFileName = DateTime.Now.ToString("yyyyMMddHHmmssfff") + "_" + Guid.NewGuid().ToString("N").Substring(0, 8) + fileExt;
            string saveFilePath = Path.Combine(imageRootPath, newFileName);

            // 先复制到系统约定目录,再把保存后的路径回写到文本框
            File.Copy(originalFilePath, saveFilePath, true);

            txtImagePath.Text = saveFilePath;
            ShowImagePreview(saveFilePath);
        }
        catch (Exception ex)
        {
            MessageBox.Show("图片加载失败:" + ex.Message, "错误", MessageBoxButtons.OK, MessageBoxIcon.Error);
        }
    }
}


/// <summary>
/// 根据图片路径加载并显示预览图
/// </summary>
/// <param name="imagePath">图片绝对路径或相对路径</param>
private void ShowImagePreview(string imagePath)
{
    // 每次切换预览前都先释放旧图片,避免文件句柄被占用
    if (PreviewPictureBox.Image != null)
    {
        Image oldImage = PreviewPictureBox.Image;
        PreviewPictureBox.Image = null;
        oldImage.Dispose();
    }

    string fullImagePath = GetFullImagePath(imagePath);
    if (string.IsNullOrWhiteSpace(fullImagePath) || !File.Exists(fullImagePath))
    {
        return;
    }

    // 通过 new Bitmap(image) 复制一份到内存里,避免图片文件一直被锁定
    using (Image image = Image.FromFile(fullImagePath))
    {
        PreviewPictureBox.Image = new Bitmap(image);
    }

    PreviewPictureBox.SizeMode = PictureBoxSizeMode.Zoom;
}


/// <summary>
/// 将图片路径统一转换为可访问的完整路径
/// </summary>
/// <param name="imagePath">数据库中保存的图片路径</param>
/// <returns>可直接访问的完整图片路径</returns>
private string GetFullImagePath(string imagePath)
{
    if (string.IsNullOrWhiteSpace(imagePath))
    {
        return string.Empty;
    }

    // 兼容两种保存方式:
    // 1. 已经是绝对路径,直接用
    // 2. 是相对路径,则拼到程序启动目录下
    if (Path.IsPathRooted(imagePath))
    {
        return imagePath;
    }

    return Path.Combine(Application.StartupPath, imagePath.TrimStart('\\', '/'));
}

“单窗体双模式”思路

        “单窗体双模式”模板:新建/编辑 共用一套表单,加载时分流,提交时分流。这样可以少写一个窗体,但是也很容易搞混,现在我来理清思路:

首先各个模块的流程如下:

  • 新建模式:生成编号 -> 填表 -> 校验 -> 新建工单 -> 清空表单
  • 编辑模式:带 Id 打开 -> 回填旧数据 -> 校验 -> 更新工单 -> 关闭窗口

1. 模式是怎么区分的

  • isEditMode:是否编辑模式
  • editingOrderId:编辑时对应的工单 Id

对应构造函数:

  • 无参构造:默认是“新建模式”
  • 带 repairOrderId 的构造:进入“编辑模式”,并记录 editingOrderId

2. 加载时,两种模式分别是哪段代码

先执行的是两种模式共用的初始化:

  • 回填当前业主姓名
  • 加载预约时间下拉框
  • 加载维修类型下拉框

然后开始分流:

编辑模式:

  • 改窗体标题和按钮文案
  • 调用 LoadRepairOrderForEdit 回填原工单数据
  • 不再生成新工单号

新建模式:

  • 调用 SetOrderNo 自动生成工单编号

3. 编辑模式的核心代码是哪段
最核心的是 LoadRepairOrderForEdit,这里做了三件很关键的事:

  • 先通过 GetSubmitOwner  确认当前是谁
  • 再按“当前业主 + 工单 Id”查询工单,避免编辑别人的数据
  • 再判断 repairOrder.CanEdit,只有允许编辑的工单才能继续

然后才回填界面:

  • 工单编号
  • 维修类型
  • 报修内容和预约时间
    用的是 FillRepairContentAndTime 
  • 上传图片
    用的是 ShowImagePreview 

4. 提交时,两种模式分别是哪段代码
入口在 SubmitButton_Click ,先共用:

  • ValidateSubmit  做输入校验
  • BuildSaveContent 拼最终保存内容

然后分流:(1).编辑模式:

  • 构造 editRepairOrder
  • 保留原来的 Id = editingOrderId
  • 调用 repairBLL.UpdateRepairOrder(editRepairOrder)
  • 成功后 DialogResult = OK 并关闭窗口

(2).新建模式:

  • 重新生成工单号
  • 构造 newRepairOrder
  • 设置初始状态 PendingStatusId
  • 调用 repairBLL.SubmitRepairOrder(newRepairOrder)
  • 成功后调用 ResetForm清空表单

5. 取消按钮也有双模式
在 CancelButton_Click :

编辑模式:直接 Close()

新建模式:调用 ResetForm清空表单

总结成一句话:

  • “新建模式”核心代码:无参构造、SetOrderNo()、SubmitRepairOrder()、ResetForm()
  • “编辑模式”核心代码:带 Id 构造、LoadRepairOrderForEdit()、UpdateRepairOrder()、Close()

3.2 我的报修工单

         这一块它的分页查询我觉得比较重要,很多地方都会用到按条件分页查询,所以要做到熟练掌握,然后DataGridView里面的编辑按钮是复用了在线报修窗体

按条件分页查询

//DAL层
/// <summary>
/// 带条件查询当前业主的工单列表
/// </summary>
/// <param name="ownerId">业主编号</param>
/// <param name="pageIndex">页码</param>
/// <param name="pageSize">页容量</param>
/// <param name="orderNo">工单编号</param>
/// <param name="repairTypeId">维修类型编号</param>
/// <param name="status">工单状态</param>
/// <returns>工单列表</returns>
public List<RepairOrderModel> SelectMyRepairOrders(int ownerId, int pageIndex, int pageSize, string orderNo, int repairTypeId, int status)
{
    StringBuilder sql = new StringBuilder(@"select 
                                                ro.id,
                                                ro.order_no,
                                                ro.owner_id,
                                                ro.repair_user_id,
                                                ro.repair_type_id,
                                                isnull(rt.TypeName, ro.repair_type) as RepairType,
                                                ro.content,
                                                ro.uploadImage,
                                                isnull(ro.status, 1) as Status,
                                                " + OwnerRepairDisplayStatusSql + @" as DisplayStatus,
                                                ro.create_time,
                                                ro.finish_time,
                                                case when re.id is not null then 1 else 0 end as HasEvaluation
                                            from RepairOrder ro
                                            left join RepairType rt on ro.repair_type_id = rt.TypeId
                                            left join RepairEvaluate re on re.order_id = ro.id
                                            where ro.owner_id = @ownerId");
    List<SqlParameter> parameters = new List<SqlParameter>()
    {
        new SqlParameter("@ownerId", ownerId)
    };

    if (!string.IsNullOrWhiteSpace(orderNo))
    {
        sql.Append(" and ro.order_no = @orderNo");
        parameters.Add(new SqlParameter("@orderNo", orderNo));
    }

    if (repairTypeId > 0)
    {
        sql.Append(" and ro.repair_type_id = @repairTypeId");
        parameters.Add(new SqlParameter("@repairTypeId", repairTypeId));
    }

    if (status > 0)
    {
        sql.Append(" and (" + OwnerRepairDisplayStatusSql + ") = @status");
        parameters.Add(new SqlParameter("@status", status));
    }

    sql.Append(@" order by ro.id desc
                  offset @offset rows
                  fetch next @pageSize rows only;");
    parameters.Add(new SqlParameter("@offset", (pageIndex - 1) * pageSize));
    parameters.Add(new SqlParameter("@pageSize", pageSize));

    using (SqlDataReader sqlDataReader = DBHelper.GetRead(sql.ToString(), parameters))
    {
        List<RepairOrderModel> repairOrders = new List<RepairOrderModel>();
        while (sqlDataReader.Read())
        {
            repairOrders.Add(new RepairOrderModel()
            {
                Id = Convert.ToInt32(sqlDataReader["id"]),
                OrderNo = sqlDataReader["order_no"].ToString(),
                OwnerId = Convert.ToInt32(sqlDataReader["owner_id"]),
                RepairUserId = sqlDataReader["repair_user_id"] == DBNull.Value ? 0 : Convert.ToInt32(sqlDataReader["repair_user_id"]),
                RepairTypeId = sqlDataReader["repair_type_id"] == DBNull.Value ? 0 : Convert.ToInt32(sqlDataReader["repair_type_id"]),
                RepairType = sqlDataReader["RepairType"] == DBNull.Value ? string.Empty : sqlDataReader["RepairType"].ToString(),
                Content = sqlDataReader["content"] == DBNull.Value ? string.Empty : sqlDataReader["content"].ToString(),
                UploadImage = sqlDataReader["uploadImage"] == DBNull.Value ? null : sqlDataReader["uploadImage"].ToString(),
                Status = Convert.ToInt32(sqlDataReader["Status"]),
                DisplayStatus = Convert.ToInt32(sqlDataReader["DisplayStatus"]),
                CreateTime = sqlDataReader["create_time"] == DBNull.Value ? DateTime.MinValue : Convert.ToDateTime(sqlDataReader["create_time"]),
                FinishTime = sqlDataReader["finish_time"] == DBNull.Value ? DateTime.MinValue : Convert.ToDateTime(sqlDataReader["finish_time"]),
                HasEvaluation = Convert.ToInt32(sqlDataReader["HasEvaluation"]) == 1
            });
        }

        return repairOrders;
    }
}




//BLL层
/// <summary>
/// 分页查询当前业主的工单
/// </summary>
/// <param name="ownerId">业主编号</param>
/// <param name="pageIndex">页码</param>
/// <param name="pageSize">页容量</param>
/// <param name="orderNo">工单编号</param>
/// <param name="repairTypeId">维修类型</param>
/// <param name="status">状态</param>
/// <returns>分页结果</returns>
public PageModel<RepairOrderModel> QueryMyRepairOrders(int ownerId, int pageIndex, int pageSize, string orderNo, int repairTypeId, int status)
{
    PageModel<RepairOrderModel> pageModel = new PageModel<RepairOrderModel>()
    {
        PageIndex = pageIndex,
        PageSize = pageSize
    };
    pageModel.Data = repairDAL.SelectMyRepairOrders(ownerId, pageIndex, pageSize, orderNo, repairTypeId, status);
    pageModel.TotalCount = repairDAL.SelectMyRepairOrderTotalCount(ownerId, orderNo, repairTypeId, status);
    return pageModel;
}



//UI层
/// <summary>
/// 根据当前筛选条件查询指定页数据
/// </summary>
/// <param name="pageIndex">要加载的页码</param>
private void LoadRepairOrders(int pageIndex)
{
    if (currentOwner == null || currentOwner.Id <= 0)
    {
        return;
    }

    // 统一从当前界面的筛选条件里取值,再交给业务层查询分页数据
    PageModel<RepairOrderModel> pageModel = repairBLL.QueryMyRepairOrders(
        currentOwner.Id,
        pageIndex,
        DefaultPageSize,
        GetSelectedOrderNo(),
        GetSelectedRepairTypeId(),
        GetSelectedStatus());

    // 例如当前在第 3 页,但删除或筛选后只剩 2 页了,就自动回退到最后一页
    if (pageIndex > 1 && pageModel.TotalCount > 0 && pageIndex > pageModel.TotalPage)
    {
        LoadRepairOrders(pageModel.TotalPage);
        return;
    }

    BindPageData(pageModel);
}

/// <summary>
/// 将查询结果绑定到表格和分页控件
/// </summary>
/// <param name="pageModel">当前页的分页结果</param>
private void BindPageData(PageModel<RepairOrderModel> pageModel)
{
    // 通过 BindingSource 绑定表格,后续如果需要刷新或扩展会更方便
    BindingSource source = new BindingSource();
    source.DataSource = pageModel.Data;
    repairOrderDataGridView.DataSource = source;
    UpdateRepairOrderGridRows();
    repairOrderDataGridView.ClearSelection();

    currentLabel.Text = $"当前工单数:{pageModel.TotalCount}";

    // 这里是“程序主动更新分页控件”,不是用户点页码
    // 所以先打开保护标记,避免 ActivePage 赋值后再次触发 PageChanged 进入递归
    isUpdatingPagination = true;
    try
    {
        repairPagination.PageSize = pageModel.PageSize;
        repairPagination.TotalCount = pageModel.TotalCount;

        if (repairPagination.ActivePage != pageModel.PageIndex)
        {
            repairPagination.ActivePage = pageModel.PageIndex;
        }
    }
    finally
    {
        isUpdatingPagination = false;
    }
}

3.3 工单评价

        RepairEvaluateForm 我把它设计成了一个“列表 + 评价详情区”的详情页模板。页面打开后先识别当前业主,再加载该业主名下所有和评价有关的工单,然后把数据拆成“待评价工单”和“我的评价”两个视图,同时同步到顶部下拉框和页签列表。用户选中工单后,下面评价详情区会根据工单状态自动切换成三种模式:可填写评价、只读查看历史评价、或者不可操作提示。

        这个窗体的关键点有两个。第一是控件联动比较多,我用 RunControlUpdate 防止程序改下拉框和列表时重复触发事件。第二是提交评价前不会直接信界面上的旧数据,而是重新查询数据库,校验工单归属、工单状态和是否已经评价,避免提交脏数据。这个思路后面可以复用到很多业主端页面,比如待缴费/已缴费、待处理/已处理、列表查看详情这类页面。

“一个数据源拆成多个视图”的模式

/// <summary>
/// 加载当前业主可评价或已评价的工单
/// </summary>
/// <param name="preferredOrderId">优先选中的工单编号</param>
private void LoadEvaluateOrders(int preferredOrderId)
{
    if (currentOwner == null || currentOwner.Id <= 0)
    {
        return;
    }

    // 一次性取出当前业主在评价页会用到的全部工单
    List<EvaluateOrderItem> orderItems = QueryEvaluateOrderItems();
    if (targetOrderId > 0 && orderItems.All(item => item.OrderId != targetOrderId))
    {
        // 处理传入的目标工单不可评价或不可见的场景
        RepairOrderModel repairOrder = repairBLL.QueryRepairOrderById(targetOrderId, currentOwner.Id);
        if (repairOrder == null)
        {
            UIMessageBox.ShowError("只能评价当前业主自己的工单,或该工单已不存在");
            Close();
            return;
        }

        UIMessageBox.ShowWarning($"只有已完成的工单才能评价,当前工单状态:{repairOrder.StatusName}");
        Close();
        return;
    }

    // 把同一批工单拆成待评价和历史评价两个集合
    pendingOrderItems = orderItems.Where(item => CanEvaluate(item.Order)).ToList();
    historyOrderItems = orderItems.Where(item => item.Order.HasEvaluation).ToList();

    // 顶部下拉框和两个列表页签都使用这次加载出来的最新数据
    BindOrderControls(orderItems);

    if (orderItems.Count == 0)
    {
        ShowNoDataState("暂无可评价工单。只有已完成或已评价的工单会显示在这里。");
        return;
    }

    // 指定目标工单打开时不允许用户随意切换
    orderSelectComboBox.Enabled = targetOrderId <= 0 && orderItems.Count > 1;
    SelectOrder(ResolveSelectedOrderId(orderItems, preferredOrderId));
}

/// <summary>
/// 查询评价页所需的工单列表
/// </summary>
/// <returns>界面绑定用的工单集合</returns>
private List<EvaluateOrderItem> QueryEvaluateOrderItems()
{
    return (repairBLL.QueryMyEvaluateOrders(currentOwner.Id) ?? new List<RepairOrderModel>())
        .Select(order => new EvaluateOrderItem()
        {
            // 这里提前整理显示文本 便于下拉框和列表直接复用
            OrderId = order.Id,
            DisplayText = $"{order.OrderNo} | {order.RepairType} | {order.StatusName}",
            Order = order
        })
        .ToList();
}

/// <summary>
/// 将工单下拉框和页签列表统一绑定到最新数据
/// </summary>
/// <param name="orderItems">当前可见的工单集合</param>
private void BindOrderControls(List<EvaluateOrderItem> orderItems)
{
    RunControlUpdate(() =>
    {
        // 顶部下拉框作为当前工单的统一选择入口
        orderSelectComboBox.DataSource = null;
        orderSelectComboBox.DisplayMember = "DisplayText";
        orderSelectComboBox.ValueMember = "OrderId";
        orderSelectComboBox.DataSource = orderItems;

        // 两个列表分别承载待评价和历史评价
        BindListBox(pendingOrderListBox, pendingOrderItems);
        BindListBox(myEvaluateListBox, historyOrderItems);
    });

    // 页签标题顺手展示数量 方便用户快速判断数据规模
    pendingTabPage.Text = $"待评价工单 ({pendingOrderItems.Count})";
    historyTabPage.Text = $"我的评价 ({historyOrderItems.Count})";
}



/// <summary>
/// 绑定单个列表控件
/// </summary>
/// <param name="listBox">列表控件</param>
/// <param name="items">要绑定的数据</param>
private void BindListBox(ListBox listBox, List<EvaluateOrderItem> items)
{
     listBox.DataSource = null;
     listBox.DisplayMember = "DisplayText";
     listBox.DataSource = items;
}

/// <summary>
/// 切换当前工单
/// </summary>
/// <param name="orderId">要切换到的工单编号</param>
private void SelectOrder(int orderId)
{
    if (orderId <= 0 || orderSelectComboBox.DataSource == null)
    {
        return;
    }

    RunControlUpdate(() =>
    {
        // SelectedValue 是当前工单切换的统一入口
        orderSelectComboBox.SelectedValue = orderId;
        if (orderSelectComboBox.SelectedIndex < 0 && orderSelectComboBox.Items.Count > 0)
        {
            // 如果值匹配失败 就兜底回到第一项
            orderSelectComboBox.SelectedIndex = 0;
        }
    });
    // 加载当前选中工单的评价状态
    LoadSelectedOrderEvaluate();
}

/// <summary>
/// 计算本次加载后默认应选中的工单编号
/// </summary>
/// <param name="orderItems">当前工单集合</param>
/// <param name="preferredOrderId">调用方期望优先选中的工单编号</param>
/// <returns>最终应选中的工单编号</returns>
private int ResolveSelectedOrderId(List<EvaluateOrderItem> orderItems, int preferredOrderId)
{
    // 调用方显式指定优先工单时 优先按调用方要求展示
    if (preferredOrderId > 0 && orderItems.Any(item => item.OrderId == preferredOrderId))
    {
        return preferredOrderId;
    }

    // 如果窗体是带着 targetOrderId 打开的 则优先展示该工单
    if (targetOrderId > 0 && orderItems.Any(item => item.OrderId == targetOrderId))
    {
        return targetOrderId;
    }
    // 否则优先找到一条可评价工单 没有的话就退回第一条
    EvaluateOrderItem pendingItem = orderItems.FirstOrDefault(item => CanEvaluate(item.Order));
    return pendingItem != null ? pendingItem.OrderId : orderItems[0].OrderId;
}

RunControlUpdate 避免重复触发事件

防止程序改下拉框和列表时重复触发事件

/// <summary>
/// 统一包装界面控件赋值,避免程序性更新再次触发联动事件
/// </summary>
/// <param name="updateAction">界面更新动作</param>
private void RunControlUpdate(Action updateAction)
{
    // 程序主动修改控件时 先打开保护标记 避免重复触发联动事件
    isBindingControls = true;
    try
    {
        updateAction();
    }
    finally
    {
        isBindingControls = false;
    }
}

提交前校验

/// <summary>
/// 提交前校验工单归属、工单状态和总评分
/// </summary>
/// <param name="latestOrder">数据库中的最新工单信息</param>
/// <param name="totalScore">格式化后的总评分</param>
/// <returns>校验通过时返回 true</returns>
private bool ValidateSubmit(out RepairOrderModel latestOrder, out int totalScore)
{
    latestOrder = null;
    totalScore = 0;

    // 当前登录业主无效时 不允许继续提交
    if (currentOwner == null || currentOwner.Id <= 0)
    {
        UIMessageBox.ShowError("当前登录业主信息无效,无法提交评价");
        return false;
    }

    // 没有选中工单时 先提示用户补充选择
    EvaluateOrderItem selectedOrderItem = GetSelectedOrderItem();
    if (selectedOrderItem == null)
    {
        UIMessageBox.ShowWarning("请先选择要评价的工单");
        return false;
    }

    // 总评分必须能解析成 1 到 5 星
    if (!TryGetTotalScoreInput(out totalScore))
    {
        UIMessageBox.ShowWarning("请输入 1 到 5 星的总评分");
        return false;
    }

    totalScoreTextBox.Text = FormatScore(totalScore);

    // 提交前重新查询一次 避免工单状态变化后仍然沿用界面旧数据
    latestOrder = repairBLL.QueryRepairOrderById(selectedOrderItem.OrderId, currentOwner.Id);
    if (latestOrder == null)
    {
        UIMessageBox.ShowError("未找到对应工单,或当前工单不属于当前业主");
        LoadEvaluateOrders(targetOrderId);
        return false;
    }

    // 如果数据库里已经存在评价记录 说明当前工单已被处理过
    if (repairBLL.QueryRepairEvaluateByOrderId(latestOrder.Id, currentOwner.Id) != null)
    {
        UIMessageBox.ShowWarning("该工单已经评价过,将为您展示历史评价内容");
        LoadEvaluateOrders(latestOrder.Id);
        return false;
    }

    // 再按数据库中的最新状态确认一次是否还允许评价
    if (!CanEvaluate(latestOrder))
    {
        UIMessageBox.ShowWarning($"只有已完成的工单才能评价,当前工单状态:{latestOrder.StatusName}");
        LoadEvaluateOrders(latestOrder.Id);
        return false;
    }

    return true;
}

3.4 个人缴费

        PayFeeForm 我把它设计成了一个典型的操作型业务页。页面打开后先识别当前登录业主,再加载该业主名下的收费项目和缴费记录。和普通列表页不同的是,这个页面的核心不只是展示数据,而是要根据当前选中的收费项目,动态决定按钮是否可用、提示信息显示什么,以及用户下一步是否可以直接执行缴费操作。这一块我就展示与按钮和提示文本有关的代码,因为前面窗体有获取列表信息的相关复用代码了

按钮业务相关代码

/// <summary>
/// 根据当前选中项目更新缴费按钮状态和提示文本
/// </summary>
private void UpdatePayButtonState()
{
    // 没有识别到业主时 不允许执行缴费
    if (currentOwner == null || currentOwner.Id <= 0)
    {
        payButton.Enabled = false;
        selectionHintLabel.Text = "未获取到当前业主信息";
        return;
    }

    FeeItemPaymentModel selectedFeeItem = GetSelectedFeeItem();
    if (selectedFeeItem == null)
    {
        // 没有选中项目时 根据列表是否为空显示不同提示
        payButton.Enabled = false;
        selectionHintLabel.Text = currentFeeItems.Count == 0 ? "暂无收费项目" : "请选择收费项目";
        return;
    }

    if (selectedFeeItem.PayStatus == 1)
    {
        // 已缴费项目只允许查看 不再重复缴费
        payButton.Enabled = false;
        selectionHintLabel.Text = $"当前选择:{selectedFeeItem.FeeName} 已缴费";
        return;
    }

    // 只有未缴费项目才启用缴费按钮
    payButton.Enabled = true;
    selectionHintLabel.Text = $"当前选择:{selectedFeeItem.FeeName} 应缴 {selectedFeeItem.DisplayAmount:N2} 元";
}

/// <summary>
/// 缴费按钮点击后提交当前选中项目的缴费请求
/// </summary>
/// <param name="sender">事件发送者</param>
/// <param name="e">事件参数</param>
private void PayButton_Click(object sender, EventArgs e)
{
    // 先校验是否已经识别到当前业主
    if (currentOwner == null || currentOwner.Id <= 0)
    {
        UIMessageBox.ShowError("未获取到当前业主信息 无法缴费");
        return;
    }

    // 再校验是否选中了要处理的收费项目
    FeeItemPaymentModel selectedFeeItem = GetSelectedFeeItem();
    if (selectedFeeItem == null)
    {
        UIMessageBox.ShowWarning("请选择要缴费的收费项目");
        return;
    }

    // 已缴费项目不允许重复提交
    if (selectedFeeItem.PayStatus == 1)
    {
        UIMessageBox.ShowWarning("当前收费项目已缴费");
        return;
    }

    // 在真正提交前再次让用户确认金额和项目
    if (!UIMessageBox.ShowAsk($"确认缴纳 {selectedFeeItem.FeeName} {selectedFeeItem.DisplayAmount:N2} 元吗"))
    {
        return;
    }

    try
    {
        // 提交成功后刷新列表和记录 让界面状态保持最新
        if (!payFeeBLL.SubmitFeePayment(currentOwner.Id, selectedFeeItem, out string errorMessage))
        {
            UIMessageBox.ShowWarning(errorMessage);
            return;
        }

        UIMessageBox.ShowSuccess("缴费成功");
        LoadFeeData();
    }
    catch (SqlException exception)
    {
        ShowDataAccessError("缴费", exception);
    }
    catch (ConfigurationErrorsException exception)
    {
        ShowDataAccessError("读取数据库配置", exception);
    }
}

/// <summary>
/// 收费项目选中行变化时同步刷新按钮状态
/// </summary>
/// <param name="sender">事件发送者</param>
/// <param name="e">事件参数</param>
private void FeeItemDataGridView_SelectionChanged(object sender, EventArgs e)
{
    // 列表选中项变化后 立即同步按钮状态和顶部提示
    UpdatePayButtonState();
}

3.5 车辆管理

        VehicleManageForm的思路是先识别当前业主,再加载车辆和车位数据,然后根据选中的车位联动右侧绑定面板,最后提交绑定并刷新页面。但由于绑定那一块限制逻辑比较少,所以还需要完善,目前仅做一个页面展示。

3.6 公告与意见反馈

        NoticeAndFeedbackForm 我把它设计成了一个“公告查看 + 意见反馈”的综合型信息交互页。页面打开后会先识别当前登录业主,然后分别加载物业公告列表和该业主自己提交过的反馈记录。和普通列表页不同的是,这个页面不只是展示数据,还承担了业主向物业提交意见、查看处理状态和查看物业回复的功能。

        这个窗体的关键点主要有三个。第一是公告和反馈的类型区分,系统共用 NoticeFeedback 这张表,但通过 type 字段区分数据,type = 1 表示公告通知,type = 2 表示意见反馈,这样既复用了数据结构,又保证两个功能互不混淆。第二是反馈提交校验,用户提交前必须填写反馈标题和意见内容,并且系统会绑定当前业主编号,保证反馈记录能追溯到具体业主。第三是提交后的列表刷新,意见反馈提交成功后会清空输入框,并重新加载“我的反馈”列表,让用户马上看到新提交的记录及其处理状态。

        另外,这个页面还对长文本展示做了处理。公告内容会被整理成一行摘要,避免列表显示混乱;反馈内容和物业回复可能比较长,所以表格里会配合提示文本展示完整内容。整体来说,它的实现重点是把“物业单向发布公告”和“业主主动反馈问题”放在同一个窗体里,通过类型字段、业主编号和状态字段把数据流转清楚。

换行替换和截断显示

/// <summary>
/// 生成公告列表项显示文本
/// </summary>
/// <param name="notice">公告实体</param>
/// <returns>列表显示文本</returns>
private string BuildNoticeDisplayText(NoticeFeedbackModel notice)
{
    if (notice == null)
    {
        return string.Empty;
    }

    string content = string.IsNullOrWhiteSpace(notice.Content) ? string.Empty : notice.Content.Trim();

    //将"\r"和"\n"用" "来代替
    string singleLineContent = content.Replace("\r", " ").Replace("\n", " ");

    //长度大于34,则后续的字符有省略号代替
    if (singleLineContent.Length > 34)
    {
        singleLineContent = singleLineContent.Substring(0, 34) + "...";
    }

    //将"{0}  {1}  {2}"分别替换成公告的创建时间、标题、内容
    return string.Format("{0}  {1}  {2}", notice.CreateTimeText, notice.Title, singleLineContent);
}

长文本列设置鼠标悬停提示

/// <summary>
/// 给“我的反馈”表格里的长文本列设置鼠标悬停提示
/// </summary>
private void UpdateFeedbackGridRows()
{
    foreach (DataGridViewRow row in AdviceDataGridView.Rows)
    {
        NoticeFeedbackModel feedback = row.DataBoundItem as NoticeFeedbackModel;
        if (feedback == null)
        {
            continue;
        }

        //给这两列设置 ToolTipText,用户把鼠标移到对应单元格上时,就能看到完整文本
        row.Cells["ContentColumn"].ToolTipText = string.IsNullOrWhiteSpace(feedback.Content)
            ? "未填写意见内容" : feedback.Content;
        row.Cells["ReplyContentColumn"].ToolTipText = string.IsNullOrWhiteSpace(feedback.ReplyContent)
            ? "暂无回复" : feedback.ReplyContent;
    }
}

3.7 个人信息和添加家人信息

        MyInfoForm 被设计成业主个人信息管理 + 头像上传的综合信息维护页。页面打开后自动识别当前登录业主,加载并回填个人档案信息,同时支持业主修改姓名、手机号、住址、车辆、头像等资料,并同步保存至数据库。与单纯的数据展示页不同,该窗体不仅负责信息回显,还承担资料校验、头像文件处理、未建档自动初始化、数据一键保存的完整业务流程。

        该窗体的关键点主要有三个。第一是业主身份与上下文同步,窗体从登录上下文、业主档案、用户信息三层获取当前身份,自动判断 “已建档 / 未建档” 状态,并根据状态动态切换提示文本、按钮权限与数据保存逻辑,保证身份唯一、数据可追溯。第二是头像上传与安全显示,支持选择本地图片并复制到系统统一目录,避免原图丢失导致头像失效;同时做了 GDI + 资源释放与文件占用防护,确保图片正常加载、不报错、不卡死。第三是表单校验与数据回填,保存前强制校验用户 ID、业主姓名必填项,地址支持多分隔符智能拆分,修改 / 取消后可一键还原数据,保证界面与数据库始终一致。这些在前面窗体都有相关代码,我就不详细展示代码了

        此外,页面还对缺省状态与异常做了健壮处理:无头像时自动生成占位图,图片加载失败自动降级;数据库异常、配置错误、文件读写异常统一捕获并友好提示。整体来说,它的实现重点是把身份解析、信息维护、文件上传、异常兜底四条主线整合在一个窗体中,通过状态标记、路径管理、模型绑定把个人信息的 “查–改–存–显” 流程闭环处理。

        AddFamilyInfoForm 是家庭成员专项管理的从属型业务窗体,与 MyInfoForm 个人信息主页不同,它不负责业主自身档案维护,而是基于已建档的业主身份,专门实现家人信息的添加、展示与删除,属于业主档案的附属扩展功能页

        窗体的关键点主要有三个。第一是强依赖业主主档案,必须先在 MyInfoForm 完成个人信息建档,才能使用添加、删除功能,未建档则全程禁用操作,严格遵循 “主档案–附属信息” 的数据从属关系。第二是业务逻辑更轻量化,仅聚焦家人姓名、头像、车牌号三项核心信息,表单校验以必填项与组合校验为主,无需处理复杂的地址拆分、多身份上下文同步。第三是纯列表驱动交互,以数据表格为核心展示方式,新增 / 删除后只刷新家人列表、清空简易表单,没有个人信息页的多模块回填、全局状态更新逻辑。

        此外,该窗体同样具备头像上传、异常兜底、空值占位等通用能力,但整体更轻量、边界更清晰,与 MyInfoForm 形成个人主信息 + 家庭成员附属信息的完整业务组合,职责分离、数据关联且互不重叠。

3.8 物业智慧助手(物业AI助手)

        这个是小组伙伴分享的方法,因为是最后一天才做的,窗体就写的很简单,使用硅基流动这个网站SiliconCloud,里面有很多的模型,要让你的项目与AI相连接,那就要使用API秘钥,Url和model,之后改的地方也主要是这三个地方

AIHelperForm

public partial class AIHelperForm : UIForm
{
    public AIHelperForm()
    {
        InitializeComponent();

        // 初始化RichTextBox属性
        AIHelpRichTextBox.Multiline = true;
        AIHelpRichTextBox.WordWrap = true;
        AIHelpRichTextBox.ScrollBars = RichTextBoxScrollBars.Vertical;
        AIHelpRichTextBox.ReadOnly = true;
    }

    // 导入Win32 API设置RichTextBox行距
    [StructLayout(LayoutKind.Sequential)]
    private struct PARAFORMAT2
    {
        public int cbSize;
        public uint dwMask;
        public short wNumbering;
        public short wReserved;
        public int dxStartIndent;
        public int dxRightIndent;
        public int dxOffset;
        public short wAlignment;
        public short cTabCount;
        [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
        public int[] rgxTabs;
        public int dySpaceBefore;
        public int dySpaceAfter;
        public int dyLineSpacing;
        public short sStyle;
        public byte bLineSpacingRule;
        public byte bOutlineLevel;
        public short wShadingWeight;
        public short wNumberingStart;
        public short wNumberingStyle;
        public short wNumberingTab;
        public short wBorderSpace;
        public short wBorderWidth;
        public short wBorders;
    }

    [DllImport("user32.dll", CharSet = CharSet.Auto)]
    private static extern IntPtr SendMessage(IntPtr hWnd, uint msg, IntPtr wParam, ref PARAFORMAT2 lParam);
    private const uint EM_SETPARAFORMAT = 0x0437;
    private const uint PFM_LINESPACING = 0x00000800;

    /// <summary>
    /// 设置RichTextBox行间距(单位:像素,推荐1.2-1.5倍行距,对应18-24像素)
    /// </summary>
    private void SetRichTextBoxLineSpacing(RichTextBox rtb, int lineSpacing)
    {
        PARAFORMAT2 fmt = new PARAFORMAT2();
        fmt.cbSize = Marshal.SizeOf(fmt);
        fmt.dwMask = PFM_LINESPACING;
        fmt.dyLineSpacing = lineSpacing;
        fmt.bLineSpacingRule = 4; // 4=按像素设置行距
        SendMessage(rtb.Handle, EM_SETPARAFORMAT, IntPtr.Zero, ref fmt);
    }

    private async void SubmitButton_Click(object sender, EventArgs e)
    {

        if (string.IsNullOrWhiteSpace(QuestionTextBox.Text))
        {
            UIMessageBox.Show("请输入内容");
            return;
        }
        string content = await Response(QuestionTextBox.Text);

        // 替换为RichTextBox赋值,并设置行距(比如20像素,约1.5倍行距)
        AIHelpRichTextBox.Text = content;
        SetRichTextBoxLineSpacing(AIHelpRichTextBox, 20); // 可调整数值,越大行距越宽
    }

    //API Key 和 地址
    private const string ApiKey = "sk-qgbcpnzhqofjhsalfmnohfjttdcdbumyorfwidijirfehumc";
    private const string ApiUrl = "https://api.siliconflow.cn/v1/chat/completions";
    private const string PropertyAssistantSystemPrompt = @"你是“数字物业管理平台”的AI助手,主要服务对象是小区业主。

    请严格遵守以下要求:
    1. 优先围绕物业场景回答,例如在线报修、我的工单、工单评价、个人缴费、车辆管理、公告与意见反馈、个人信息、添加家人信息。
    2. 如果是一般生活问题,也可以回答,但尽量结合业主日常居住场景给出建议。
    3. 如果问题涉及安全隐患,如漏水、漏电、燃气泄漏、火灾、电梯故障等,请先提醒用户优先联系物业或专业人员处理,再给出后续建议。
    4. 回答使用简体中文,语气自然、耐心、清晰。
    5. 回答尽量分点,优先给出结论和可操作步骤,不要大段空话。
    6. 不要输出JSON、代码、调试信息,也不要机械重复用户原话。
    7. 如果信息不足,请先按常见情况给出建议,再提醒用户补充关键信息。";

    private static string BuildPropertyAssistantPrompt(string question)
    {
        return $@"请按下面的风格回答业主问题。

    回答要求:
    - 使用简体中文
    - 优先给结论,再给步骤
    - 使用 1. 2. 3. 分点回答
    - 每点尽量简洁、实用
    - 涉及平台功能时,优先结合本物业平台页面说明
    - 涉及安全风险时,第一点先写安全提醒

    示例1:
    用户问题:水管堵塞了怎么解决
    回答:
    1. 先关闭附近水阀,避免积水或漏水情况继续扩大。
    2. 可以先尝试用皮搋子、管道疏通器做简单疏通,不要盲目拆管。
    3. 如果堵塞严重、反复发生,建议立即在“在线报修”中提交工单,请物业安排人员上门处理。

    示例2:
    用户问题:怎么查看缴费记录
    回答:
    1. 可以进入“个人缴费”页面查看历史账单、缴费状态和金额明细。
    2. 如果页面信息不完整,可以先刷新页面或重新登录后再查看。
    3. 如果仍然没有记录,建议联系物业财务或前台核对账户信息。

    示例3:
    用户问题:我家车位怎么绑定车辆
    回答:
    1. 进入“车辆管理”页面,先确认当前账号下已经关联车位信息。
    2. 按页面提示填写车牌号、车辆信息并提交绑定申请。
    3. 如果提示绑定失败或无权限,建议联系物业核实车位归属和账户信息。

    现在请回答下面的问题:
    用户问题:{question}
    回答:";
    }

    private async Task<string> Response(string question)
    {
        using (HttpClient client = new HttpClient())
        {
            client.DefaultRequestHeaders.Add("Authorization", "Bearer " + ApiKey);
            string prompt = BuildPropertyAssistantPrompt(question);

            var request = new
            {
                model = "Qwen/Qwen2.5-7B-Instruct",
                temperature = 0.3,
                messages = new[]
                {
                new { role = "system", content = PropertyAssistantSystemPrompt },
                new { role = "user", content = prompt }
            }
            };

            string json = JsonConvert.SerializeObject(request);
            StringContent content = new StringContent(json, Encoding.UTF8, "application/json");

            HttpResponseMessage response = await client.PostAsync(ApiUrl, content);
            response.EnsureSuccessStatusCode();
            string resultJson = await response.Content.ReadAsStringAsync();
            dynamic result = JsonConvert.DeserializeObject(resultJson);
            return result?.choices?[0]?.message?.content?.ToString() ?? "暂时没有获取到有效回复,请稍后重试。";
        }
    }

    private void CancelActionButton_Click(object sender, EventArgs e)
    {
        QuestionTextBox.Clear();
        AIHelpRichTextBox.Clear(); // 替换为RichTextBox清空
    }
}

3.9 数字大屏的学习

        在数字大屏学习部分,主要学习并掌握了山海鲸可视化这款大屏工具的基本使用。学习过程中先了解了JSON 数据结构API 接口的基础概念,知道了前端大屏可以通过 API 接口获取后端数据。随后我在山海鲸软件中选择了一个合适的展示模板,将自己 C# 项目里提供的数据接口 URL配置到模板中,完成了大屏与 C# 后端项目的数据连接。连接成功后,我根据项目实际返回的字段,在山海鲸里修改数据绑定、调整对应字段名称与展示方式,让后端接口的数据能正确显示在大屏上,最终实现了 C# 项目数据通过 API 对接数字大屏并可视化展示的完整流程。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐