数字物业项目总结——业主端版
一、项目介绍
1.1 项目背景与定位
随着住宅小区规模不断扩大,传统依靠纸质登记、电话沟通和人工统计的物业管理方式逐渐暴露出效率低、信息不透明、数据分散、协同困难等问题。尤其在报修处理、物业收费、车辆与车位管理、访客登记、公告发布等高频场景中,线下流程容易出现记录遗漏、进度不可追踪和责任界定不清等情况。
本项目面向社区物业日常运营场景,建设一套集业主服务、物业管理、维修协同、访客登记与数据统计于一体的数字物业管理系统。系统通过统一登录入口和角色化功能分流,将原本分散的业务流程在线化、规范化、可追踪化,从而提升物业管理效率和住户服务体验。
1.2 建设目标
-
将报修、收费、公告、访客登记等高频业务线上化。
-
将业主、员工、维修工和管理员纳入统一平台管理。
-
建立报修闭环、收费闭环和数据统计闭环。
-
为物业管理提供更清晰的过程追踪能力和基础数据支撑。
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- 上传图片
用的是 ShowImagePreview4. 提交时,两种模式分别是哪段代码
入口在 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 对接数字大屏并可视化展示的完整流程。

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

所有评论(0)