Full Calendar – 适用于 jQuery 和 C# MVC 的完整 Web 日记系统






4.90/5 (142投票s)
使用 FullCalendar、jQuery 和 MVC 为您的 Web 应用提供即用型预约日历功能
引言
本文介绍了如何使用 Adam Shaw 开发的非常出色的开源 JQuery 插件“FullCalendar
”来开发一个预约预订系统。我将演示该插件的多种用法,并展示它如何与 SQL 后端集成 Entity-Framework。本文的目的是为您提供几乎所有可以立即进行调整的内容,以便为您的 MVC 应用程序带来日历/预约功能。这是一个完整的演练,包括设置一个链接的 SQL 中的 EF 数据库。我将使用 Twitter Bootstrap 框架来加快开发速度。我还将详细介绍,以帮助希望尽快上手该插件的用户。附加的项目是用 C# MVC 4 编写的。
对于需要多用户或多资源附加功能的用户,您可以在我关于 使用 FullCalendar 的多用户多资源日历 的另一篇文章中获取信息以及一个完整的示例供下载和试用。
背景
我最近一直在寻找一个良好可靠的基于 Web 的日历插件,它能让我提供稳固的预约管理功能。除了某些商业产品外,我找到的最合适的开源插件是 FullCalendar
。 在此处获取。演示场景将具有以下功能
- 每天管理预约。
- 在月视图中,查看所有预约事件的概览摘要信息。
注意事项
一些棘手的问题包括日期格式(此插件使用 Unix 时间戳),以及必须处理多个插件数据源,因为我希望根据正在查看的日历视图(日、周、月...)查询不同类型的数据。在尝试让事件准确地按我想要的方式渲染时,我也遇到了一些问题 - 顿悟的时刻是当我意识到我以错误的顺序/序列引用了一些 CSS 时。对于拖放功能,该插件依赖于包含 jQueryUI,最后,为了阻止插件在我不想让它触发时触发,我必须仔细控制数据加载的流程。
设置
开始
您还会注意到,我已经将 JQuery-UI JS 文件添加到我的包中 - 这是支持拖放所必需的。
<div id='calendar' style="width:65%"></div>
(请注意,内联样式仅用于在 CodeProject 发布大小准则内强制截屏的宽度!)
- 创建一个新的 C# MVC 项目,并清除默认的视图模板。
- 在此处 下载 FullCalendar。
- 下载 Twitter Bootstrap(我使用的是 稳定版 2.3.2,直到 v3 发布 RC)。
- 对于这两个库,解压并将 CSS 文件放在您的 /Content 文件夹中,并将 JS 脚本放在 /Scripts 文件夹中。
- 在您的 MVC 项目中,将 bootstrap 文件添加到您的脚本包中。这在 BundleConfig.cs 文件中,位于 App_Start 文件夹中。
- 我们将使用默认的 index.cshml 页面进行操作,所以在此页面中,放置一个
DIV
来容纳该插件 - 最后,为了证明它至少能够正确加载和渲染,我们将在文档就绪并加载后添加一些基本的 JS
初始化设置告诉插件在标题中显示什么,默认视图(在本例中为“agenda day”...对大多数人来说很熟悉),以及设置 15 分钟的默认时间段。
当我们运行项目时,正如预期的那样,插件出现了
好的,那么让我们开始工作吧!
Using the Code
我不想使用客户端的模拟数据,而是想展示如何在工作环境中存储和操作数据,因此我构建了一个 SQL 数据库来存储数据并使用 Entity Framework 进行连接。(SQL 源已附加到本文档。)
SQL 表名为“AppointmentDiary
”,包含以下字段
ID
是自动递增的,Title
在 Calendar
控件中显示为一个事件,SomeImportantKey
代表与其他数据表的引用链接,DateTimescheduled
是日历日期/时间,Appointment
长度是一个整数,表示预约的持续分钟数,最后 StatusENUM
是一个整数,链接到一个 ENUM
值,表示预约是咨询、预订、已确认等。
我们将一个名为 Diary
的新 Entity Data Model 添加到项目中,并将其指向我们的新数据库和表。这将创建一个 EDMX 文件,在本例中我们称之为“Diary
”并引用为“DiaryContainer
”。
由于此示例基于日期,因此我不想预先填充数据,因为我存储的任何事件在发布后的第二天就会过期!因此,我构建了一个快速方法来初始化数据库。该方法通过浏览器中的按钮调用,该按钮向应用程序发送 Ajax 请求(尽可能减少完整的服务器往返!)。
按钮最初是一个链接
<a href="#" id="btnInit" class="btn btn-secondary">Initialise database!</a>
但是通过 Bootstrap 的魔力,添加“btn
”类可以将链接变成一个按钮,而“btn-secondary
”则赋予它灰色。
调用初始化代码的脚本足够简单
$('#btnInit').click(function () {
$.ajax({
type: 'POST',
url: "/Home/Init",
success: function (response) {
if (response == 'True') {
$('#calendar').fullCalendar('refetchEvents');
alert('Database populated! ');
}
else {
alert('Error, could not populate database!');
}
}
});
});
在服务器端,我们有一个控制器“/Home/Init”,它调用一个共享文件 Utils 中的方法,名为“InitialiseDiary
”。此方法的目的是生成一系列围绕当前日期的测试日历预约。它为当前日期创建一些项目,也为日期之前和之后创建一些项目。
public static bool InitialiseDiary() {
// init connection to database
DiaryContainer ent = new DiaryContainer();
try
{
for(int i= 0; i<30; i++){
AppointmentDiary item = new AppointmentDiary();
// record ID is auto generated
item.Title = "Appt: " + i.ToString();
item.SomeImportantKey = i;
item.StatusENUM = GetRandomValue(0,3); // random is exclusive - we have
// three status enums
if (i <= 5) // create ten appointments for today's date
{
item.DateTimeScheduled = GetRandomAppointmentTime(false, true);
}
else { // rest of the appointments on previous and future dates
if (i % 2 == 0)
item.DateTimeScheduled = GetRandomAppointmentTime(true, false);
// flip/flop between date ahead of today and behind today
else item.DateTimeScheduled = GetRandomAppointmentTime(false, false);
}
item.AppointmentLength = GetRandomValue(1,5) * 15;
// appointment length always less than an hour in this demo
// in blocks of fifteen minutes
ent.AppointmentDiary.Add(item);
ent.SaveChanges();
}
}
catch (Exception)
{
return false;
}
return ent.AppointmentDiary.Count() > 0;
}
此方法调用另外两个支持方法,一个生成随机数,另一个生成随机日期/时间。
/// <summary>
/// sends back a date/time +/- 15 days from todays date
/// </summary>
public static DateTime GetRandomAppointmentTime(bool GoBackwards, bool Today) {
Random rnd = new Random(Environment.TickCount); // set a new random seed each call
var baseDate = DateTime.Today;
if (Today)
return new DateTime(baseDate.Year, baseDate.Month,
baseDate.Day, rnd.Next(9, 18), rnd.Next(1, 6)*5, 0);
else
{
int rndDays = rnd.Next(1, 15);
if (GoBackwards)
rndDays = rndDays * -1; // make into negative number
return new DateTime(baseDate.Year, baseDate.Month,
baseDate.Day, rnd.Next(9, 18), rnd.Next(1, 6)*5, 0).AddDays(rndDays);
}
}
现在我们有了这些,我们可以通过运行应用程序并单击按钮来生成示例数据(由 Twitter Bootstrap 的魔力使其变得美味而精彩....),但在此之前,让我们在控制器中创建一个方法来将我们的示例数据发送回插件....
Full Calendar 可以以多种方式创建日历“事件”进行渲染。其中一种更常见的方式是发送 JSON 列表作为数据。
“事件”至少需要以下信息
ID
:日历项的唯一标识符Title
:要在屏幕上渲染的文本Start
:事件的开始日期/时间End
:事件的结束日期/时间
您还可以发送其他信息,例如事件在屏幕上的颜色,如果您想以特定方式渲染事件,可以提供 CSS 类名。您还可以发送任何您可能需要在客户端处理的其他信息,例如,链接到相关数据表的键字段等。
为了连接到数据表,我创建了一个名为 DiaryEvent
的模型,并为其添加了一些映射到实体模型的字段。
public class DiaryEvent
{
public int ID;
public string Title;
public int SomeImportantKeyID;
public string StartDateString;
public string EndDateString;
public string StatusString;
public string StatusColor;
public string ClassName;
...
}
此外,我还添加了一些提取信息并将其保存回的方法。我们首先感兴趣的方法接收开始和结束日期作为参数,并返回 DiaryEvent
列表
public static List<DiaryEvent> LoadAllAppointmentsInDateRange(double start, double end)
{
var fromDate = ConvertFromUnixTimestamp(start);
var toDate = ConvertFromUnixTimestamp(end);
using (DiaryContainer ent = new DiaryContainer())
{
var rslt = ent.AppointmentDiary.Where(s => s.DateTimeScheduled >=
fromDate && System.Data.Objects.EntityFunctions.AddMinutes(
s.DateTimeScheduled, s.AppointmentLength) <= toDate);
List<DiaryEvent> result = new List<DiaryEvent>();
foreach (var item in rslt)
{
DiaryEvent rec = new DiaryEvent();
rec.ID = item.ID;
rec.SomeImportantKeyID = item.SomeImportantKey;
rec.StartDateString = item.DateTimeScheduled.ToString("s");
// "s" is a preset format that outputs as: "2009-02-27T12:12:22"
rec.EndDateString = item.DateTimeScheduled.AddMinutes
(item.AppointmentLength).ToString("s");
// field AppointmentLength is in minutes
rec.Title = item.Title + " - " + item.AppointmentLength.ToString() + " mins";
rec.StatusString = Enums.GetName<AppointmentStatus>
((AppointmentStatus)item.StatusENUM);
rec.StatusColor = Enums.GetEnumDescription<AppointmentStatus>(rec.StatusString);
string ColorCode = rec.StatusColor.Substring(0, rec.StatusColor.IndexOf(":"));
rec.ClassName = rec.StatusColor.Substring(rec.StatusColor.IndexOf(":")+1,
rec.StatusColor.Length - ColorCode.Length-1);
rec.StatusColor = ColorCode;
result.Add(rec);
}
return result;
}
}
代码非常简单
- 我们使用 Entity Framework 连接到数据库,运行一个 LINQ 查询,该查询提取开始日期和结束日期之间的所有预约事件(使用“
EntityFunctions.AddMinutes
”根据StartDateTime
+AppointmentLength
(以分钟为单位)即时创建结束日期) - 对于返回的每个事件,我们创建一个
DiaryEvent
项,并将数据表记录信息添加到其中,准备发送回。
需要注意的一些事项 - 首先,FullCalander
处理的是 UNIX 格式的日期,因此我们在查询之前必须进行转换。其次,我将事件“Status
”的颜色属性存储在 StatusENUM
的“DESCRIPTION ANNOTATION
”中,然后使用一个方法来提取描述、颜色代码等。颜色代码等包括一个 CSS“类名”,我们将在本文稍后使用它来让事件项看起来更漂亮一些...
CSS
.ENQUIRY {
background-color: #FF9933;
border-color: #C0C0C0;
color: White;
background-position: 1px 1px;
background-repeat: no-repeat;
background-image: url('Bubble.png');
padding-left: 50px;
}
.BOOKED {
background-color: #33CCFF;
border-color: #C0C0C0;
color: White;
background-position: 1px 1px;
background-repeat: no-repeat;
background-image: url('ok.png');
padding-left: 50px;
... etc...
StatusENUM
:
从 StatusENUM
的 Description
属性提取 CSS 类/颜色的方法
public static string GetEnumDescription<T>(string value)
{
Type type = typeof(T);
var name = Enum.GetNames(type).Where(f => f.Equals(value,
StringComparison.CurrentCultureIgnoreCase)).Select(d => d).FirstOrDefault();
if (name == null)
{
return string.Empty;
}
var field = type.GetField(name);
var customAttribute = field.GetCustomAttributes(typeof(DescriptionAttribute), false);
return customAttribute.Length > 0 ?
((DescriptionAttribute)customAttribute[0]).Description : name;
}
好的,差不多了,我们需要将列表转换为 JSON 格式发送回插件...这在一个控制器中完成...
public JsonResult GetDiaryEvents(double start, double end)
{
var ApptListForDate = DiaryEvent.LoadAllAppointmentsInDateRange(start, end);
var eventList = from e in ApptListForDate
select new
{
id = e.ID,
title = e.Title,
start = e.StartDateString,
end = e.EndDateString,
color = e.StatusColor,
someKey = e.SomeImportantKeyID,
allDay = false
};
var rows = eventList.ToArray();
return Json(rows, JsonRequestBehavior.AllowGet);
}
最后!...让我们回到 INDEX 页面,并添加一行,告诉 FullCalendar
插件去哪里获取其 JSON 数据...
现在当我们运行应用程序时,我们可以单击我们的 INIT
按钮,它将填充数据库,并看到数据正在传输...
太棒了 ....
您是否还记得我之前谈到的颜色和 ClassName
- 该插件允许我们传入一个 CSS 类名,它将使用它来帮助渲染事件。在前面显示的 CSS 中,我在 CSS 装饰中添加了一个图标图像,以使事件看起来更美观。为了让这个传递下去,我们在 ClassName
字符串中添加它...
渲染效果如下...
这太棒了,现在让我们看看客户端用户功能 - 用户如何与我们的日历进行交互?
通常,人们会期望能够选择一个事件并获取有关它的信息,编辑它,移动它,调整它的大小等等。让我们看看如何实现这一点。
首先,让我们检查一下我们随事件发送的信息。我们可以通过在初始化插件时添加一个回调函数来做到这一点
eventClick: function (calEvent, jsEvent, view) {
alert('You clicked on event id: ' + calEvent.id
+ "\nSpecial ID: " + calEvent.someKey
+ "\nAnd the title is: " + calEvent.title);
这里的关键参数是第一个 calEvent
- 从中,我们可以访问发送 JSON 记录时发送的任何数据。
这是它的样子
利用这些信息,您可以连接并使用它来弹出编辑表单,重定向到详细信息窗口等 - 您的代码技巧就是您的魔杖。
要将事件在插件中移动,我们钩住 eventDrop
。
eventDrop: function (event, dayDelta, minuteDelta, allDay, revertFunc) {
if (confirm("Confirm move?")) {
UpdateEvent(event.id, event.start);
}
else {
revertFunc();
}
}
dayDelta
和 minuteDelta
参数很有用,因为它们告诉我们事件移动了多少天或分钟。我们可以用它来调整后端数据库,但还有另一种我使用的方法。
在这种情况下,我决定采用 event
对象并使用其信息。当我们最初创建事件时,它会根据我们给定的开始/结束日期和时间进行设置。当项目被移动/拖放时,该日期和时间会发生变化,但是 ID 和我们随事件保存的额外信息不会。因此,在这种情况下,我的策略是获取 ID 和新的开始/结束时间,并使用这些来更新数据库。
FullCalendar
提供了一个“revertFunc
”方法,如果用户决定不确认事件移动,该方法会将事件移动恢复到其先前状态。
在事件移动时,我调用一个本地脚本函数“UpdateEvent
”。它获取相关数据,并通过 Ajax 将其发送回服务器上的控制器
function UpdateEvent(EventID, EventStart, EventEnd) {
var dataRow = {
'ID': EventID,
'NewEventStart': EventStart,
'NewEventEnd': EventEnd
}
$.ajax({
type: 'POST',
url: "/Home/UpdateEvent",
dataType: "json",
contentType: "application/json",
data: JSON.stringify(dataRow)
});
}
控制器 (Controller)
public void UpdateEvent(int id, string NewEventStart, string NewEventEnd)
{
DiaryEvent.UpdateDiaryEvent(id, NewEventStart, NewEventEnd);
}
从控制器调用的方法
public static void UpdateDiaryEvent(int id, string NewEventStart, string NewEventEnd)
{
// EventStart comes ISO 8601 format,
// e.g.: "2000-01-10T10:00:00Z" - need to convert to DateTime
using (DiaryContainer ent = new DiaryContainer()) {
var rec = ent.AppointmentDiary.FirstOrDefault(s => s.ID == id);
if (rec != null)
{
DateTime DateTimeStart = DateTime.Parse(NewEventStart, null,
DateTimeStyles.RoundtripKind).ToLocalTime(); // and convert offset to localtime
rec.DateTimeScheduled = DateTimeStart;
if (!String.IsNullOrEmpty(NewEventEnd)) {
TimeSpan span = DateTime.Parse(NewEventEnd, null,
DateTimeStyles.RoundtripKind).ToLocalTime() - DateTimeStart;
rec.AppointmentLength = Convert.ToInt32(span.TotalMinutes);
}
ent.SaveChanges();
}
}
}
这里需要注意的重要一点是,日期格式以 IS8601 格式发送,因此我们需要进行转换。我还使用 Timespan
来计算任何新的预约长度。
事件调整大小以及在服务器端更新数据库预约长度的操作方式类似。首先,钩住事件
eventResize: function (event, dayDelta, minuteDelta, revertFunc) {
if (confirm("Confirm change appointment length?")) {
UpdateEvent(event.id, event.start, event.end);
}
else {
revertFunc();
}
},
您会注意到,我使用了同一个控制器和方法来更新数据库 - 所有这些都是为了尽可能重用代码!如果 update
方法看到发送了一个新的结束日期/时间,它会假定已发生调整大小并相应地调整“AppointmentLength
”值,否则它只是更新新的开始/日期时间。
if (!String.IsNullOrEmpty(NewEventEnd)) {
TimeSpan span = DateTime.Parse(NewEventEnd, null,
DateTimeStyles.RoundtripKind).ToLocalTime() - DateTimeStart;
rec.AppointmentLength = Convert.ToInt32(span.TotalMinutes);
}
现在,这很好,我们对此感到非常满意,直到我们转向查看 **MONTH** 视图....
哦,天哪....它渲染了每一个事件,让我们的日历看起来很混乱...对于月视图来说,最好是控件只显示每天的预约/日历事件的摘要。这个问题在“数据源”中有优雅的解决方案。
Full Calendar 可以存储事件 SOURCES
的数组。我们所要做的就是钩住“viewRender
”事件,查询当前活动的视图类型(日、周、月...),并在此基础上,更改源并告诉插件刷新数据。
首先,让我们回到 index.cshtml 插件初始化部分,并删除数据路径的引用,因为我们将用其他东西替换它...
为了支持我们需要的新功能,我创建了两个变量来保存每个视图的 URL 路径。
var sourceSummaryView = { url: '/Home/GetDiarySummary/' };
var sourceFullView = { url: '/Home/GetDiaryEvents/' };
我们的目标是钩住视图按钮组的 Click/更改事件...
方法是插入代码来检查用户何时单击按钮来更改视图 - 这是“viewRender
”。传递两个参数,第一个是“view
”,它告诉我们刚刚单击了什么
因此,根据单击的视图,我们从源数组(按名称)中删除任何数据源,从插件中删除任何事件,最后分配我们之前创建的 string
变量之一作为新的数据源,然后立即加载。
由于我们的摘要视图包含略有不同的信息,因此我们需要创建另一个方法在服务器端提取数据。这与我们之前的查询几乎相同,除了我们需要扩展我们的 LINQ 查询以使用 GROUP
和 COUNT
。我们的目标是按日期分组,并获得每天的日历事件计数。让我们快速看一下那个方法...
public static List<DiaryEvent> LoadAppointmentSummaryInDateRange(double start, double end)
{
var fromDate = ConvertFromUnixTimestamp(start);
var toDate = ConvertFromUnixTimestamp(end);
using (DiaryContainer ent = new DiaryContainer())
{
var rslt = ent.AppointmentDiary.Where(
s => s.DateTimeScheduled >= fromDate &&
System.Data.Objects.EntityFunctions.AddMinutes
(s.DateTimeScheduled, s.AppointmentLength) <= toDate)
.GroupBy(s =>
System.Data.Objects.EntityFunctions.TruncateTime(s.DateTimeScheduled))
.Select(x => new { DateTimeScheduled = x.Key, Count = x.Count() });
List<DiaryEvent> result = new List<DiaryEvent>();
int i = 0;
foreach (var item in rslt)
{
DiaryEvent rec = new DiaryEvent();
rec.ID = i; //we don't link this back to anything as it's a group summary
// but the fullcalendar needs unique IDs for each event item
// (unless it's a repeating event)
rec.SomeImportantKeyID = -1;
string StringDate = string.Format("{0:yyyy-MM-dd}", item.DateTimeScheduled);
rec.StartDateString = StringDate + "T00:00:00"; //ISO 8601 format
rec.EndDateString = StringDate +"T23:59:59";
rec.Title = "Booked: " + item.Count.ToString();
result.Add(rec);
i++;
}
return result;
}
}
和以前一样,我们需要转换传入的 unix 格式日期。接下来,因为我们是按日期分组,但我们存储的是 dateTIME
,我们需要从查询中删除时间部分 - 为此,我们使用 EntityFunctions.TruncateTime
方法,并将此结果链接到一个子查询,该子查询返回日期作为键字段,计数作为值。
请注意,由于这是摘要信息,因此我们没有一个键-ID 字段可以链接回,但这对于 FullCalendar
是一个问题,因为它需要一个唯一的 ID 来跟踪事件,因此在这种情况下,我们分配了一个任意的 ID,即一个简单的计数变量(“i
”)。另一件事是,由于我们剥离了结果的时间部分以启用分组,因此我们需要使用 ISO 格式将其重新添加。
一旦代码实现,一切看起来都好多了!
我们要做的最后一件事是在日历上的空白区域单击时添加一个新事件。
我们将构建一个快速的 bootstrap 模态弹出窗口来捕获事件标题、日期/时间以及预约时长
<div id="popupEventForm" class="modal hide" style="display: none;">
<div class="modal-header"><h3>Add new event</h3></div>
<div class="modal-body">
<form id="EventForm" class="well">
<input type="hidden" id="eventID">
<label>Event title</label>
<input type="text" id="eventTitle" placeholder="Title here"><br />
<label>Scheduled date</label>
<input type="text" id="eventDate"><br />
<label>Scheduled time</label>
<input type="text" id="eventTime"><br />
<label>Appointment length (minutes)</label>
<input type="text" id="eventDuration" placeholder="15"><br />
</form>
</div>
<div class="modal-footer">
<button type="button" id="btnPopupCancel" data-dismiss="modal" class="btn">Cancel</button>
<button type="button" id="btnPopupSave" data-dismiss="modal"
class="btn btn-primary">Save event</button>
</div>
</div>
接下来,我们将向我们的友好插件添加一个钩子,以在单击日期槽时显示此模态弹出窗口。
dayClick: function (date, allDay, jsEvent, view) {
$('#eventTitle').val("");
$('#eventDate').val($.fullCalendar.formatDate(date, 'dd/MM/yyyy'));
$('#eventTime').val($.fullCalendar.formatDate(date, 'HH:mm'));
ShowEventPopup(date);
},
一个小脚本方法初始化弹出表单,首先清除任何残余的输入值,然后将焦点设置到第一个输入框
function ShowEventPopup(date) {
ClearPopupFormValues();
$('#popupEventForm').show();
$('#eventTitle').focus();
}
最后,我们将脚本附加到“保存事件”按钮,通过 Ajax 将数据发送回服务器。
$('#btnPopupSave').click(function () {
$('#popupEventForm').hide();
var dataRow = {
'Title':$('#eventTitle').val(),
'NewEventDate': $('#eventDate').val(),
'NewEventTime': $('#eventTime').val(),
'NewEventDuration': $('#eventDuration').val()
}
ClearPopupFormValues();
$.ajax({
type: 'POST',
url: "/Home/SaveEvent",
data: dataRow,
success: function (response) {
if (response == 'True') {
$('#calendar').fullCalendar('refetchEvents');
alert('New event saved!');
}
else {
alert('Error, could not save event!');
}
}
});
});
如果服务器成功保存记录,它会返回“True
”,然后我们告诉插件重新获取其事件。
我想解决的最后一件事情是数据重复加载。当我们使用 viewRender
回调更改数据源时,会发生的是,在初始页面加载时,viewRender
会触发两次。为了解决这个问题,我在主脚本的顶部,在初始化插件之前,添加了一个变量 var CalLoading = true;
,在渲染视图时检查它,并在文档就绪后重置它。
好了,就是这样!...本文提供了足够详细的信息,让您只需花费很少的精力,就可以为您的 MVC 应用程序添加相当有用的日历功能。完整的源代码包含在文章的顶部。
(附注:如果您觉得本文有用或下载了代码,请通过给下面的评分来告诉我!)
历史
- 2013 年 8 月 18 日:初始版本
- 2016 年 9 月 14 日:更新 - 链接到关于
FullCalander
的新文章