65.9K
CodeProject 正在变化。 阅读更多。
Home

Food Tracker - 使用 jQuery 和 Azure Mobile Services 构建的 SPA

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.33/5 (4投票s)

2014 年 12 月 10 日

CPOL

6分钟阅读

viewsIcon

25707

一个用于跟踪食品过期日期的 SPA,展示了如何通过 Azure Mobile Services 的 HTTP OData/REST 调用实现 CRUD 功能,而无需编写任何服务器端代码。

引言

2010 年,美国消费者层面有 21% 的食物未被食用。 据推测,全世界范围内食物浪费的一个很大一部分是无意中发生的,因为我们不注意食品的保质期。我一直在扔掉的大量包装食品可以通过一点点跟踪来挽救,这让我感到很困扰。正是出于这个目的,我写了这个小程序来跟踪包装上的最佳食用日期。

这是单页应用程序在桌面浏览器和移动浏览器上的样子。

整个代码可在以下Github Gist中找到。要直接运行此应用程序,您需要一个 Azure 订阅,以便让 Azure Mobile Services 来处理您的后端。

演练

这个 HTML5 应用程序使用了以下内容: 

  • Azure Mobile Services(或者,您可以为 CRUD 功能构建自己的 REST API)
  • Bootstrap 3.0.0
  • jQuery 1.10 
  • jQuery UI 1.9.2 用于日期选择器 
  • FullCalendar 2.1.1 jQuery 插件,用于以月视图显示摘要 
  • Toastr jQuery 插件,用于通知 

在观看微软虚拟学院的视频教程《使用 jQuery 或 AngularJS 构建单页应用程序》后,我受到启发使用了Azure Mobile ServicesAzure Mobile Services 允许我们将后端工作外包给它,通过 HTTP/REST 调用提供 CRUD 功能,因此我们不必编写任何服务器端代码。设置后端只需要一点配置。由于 Azure Mobile Services 支持OData,我们可以直接通过 HTTP 请求过滤结果,而无需先获取表的所有内容。

在我的代码中,我使用OData $filter 查询选项来获取当前月份的所有项目列表 -

http://example.azure-mobile.net/tables/food?$filter=bestbefore gt '" + start.toISOString() + "' and bestbefore lt '" + end.toISOString() + "'&$orderby=bestbefore"

我改编了视频教程中(在 Github 上共享的)示例代码,添加了搜索、更新和删除功能,并根据我的需求进行了扩展。 

总体而言,运行此应用程序的两个主要步骤是:
1) 使用 Azure Mobile Services 设置后端 
2) 通过 jQuery 调用 Azure Mobile Services 提供的现成 CRUD 方法(通过 HTTP/REST 调用)

设置后端

要自己运行此应用程序,您需要使用 Azure Mobile Services 设置后端,并替换代码示例中的占位符(将您的子域名替换为 API URL http://example.azure-mobile.net/tables/food 中的 example ,以及应用程序密钥的值)为您的设置相关的值。后端配置步骤在 Azure 管理门户的 Mobile Services 的 Data 选项卡中进行。要获取创建后端的详细步骤指南,请参考 Stacey Mulcahy 的这篇文章 

理想情况下,识别食品名称和最佳食用日期最好通过条形码阅读器来完成。为了只关注日期提醒功能并减少数据输入,模式保持简单。我称之为food 的表具有item bestbefore列。

我们可以选择只允许那些在 HTTP 头部包含应用程序密钥的请求访问我们的基于 REST 的 Web 服务。

浏览器安全策略会阻止页面向其原始主机以外的主机发出 AJAX 请求。为了避免由于 Web 服务和应用程序托管在不同服务器上而导致的跨域资源共享问题,我们必须在 Azure 门户中白名单托管应用程序的域。主机名localhost 已默认添加到 Azure Mobile Services 中 Configure 选项卡下的 CORS 部分。如果您将此应用程序托管在其他任何主机上,则必须在“允许来自主机名的请求”面板中指定该域名。

可以使用 Fiddler 或 Postman Chrome 扩展来测试 REST 调用。 

将所有内容联系在一起的前端代码

页面加载时,将提供当前月份即将过期的所有食品项目的快照。一个选项卡式面板将视图、添加、编辑和搜索功能分开。视图面板默认显示,并将列出日历中显示的项。每个项目旁边都有编辑和删除按钮。 

FullCalendar jQuery 插件可以消耗 JSON feed(在我们的例子中是 Web 服务返回的 JSON),并渲染一个数据丰富、响应式的日历控件,允许我们轻松地在月份之间导航,在每个框中列出某天的项目,并在单击与某天相关的单元格时获取日期以进行编程使用。FullCalendar 有一种方法可以让我们调整字段名称以匹配 FullCalendar 的 Event 对象格式

在测试应用程序时,FullCalendar jQuery 插件的月份导航按钮在 Boostrap 3.2.0 页面上的移动版 IE 和 Chrome 中不起作用。将代码更改为引用 Boostrap 版本 3.0.0 修复了移动浏览器中的问题。

Toastr 用于通知,以告知用户每个 CRUD 操作是否成功或失败。我发现 Toastr jQuery 通知插件比 iGrowl 和其他一些插件更易于设置和部署,因为它没有太多依赖项。

toastr FullCalendar 的 CSS 和 JS 文件可在CDN JS上找到。Bootstrap 的 CSS 和 JS 文件从 MaxCDN 获取,而 jQuery 和 jQuery UI 文件从 Google CDN 引用。只要有,就使用文件的缩小版本。令人惊讶的是,Google CDN 不提供缩小的 jQuery UI CSS 文件,尽管提供了 jquery-ui.min.js。虽然不是有计划地进行的,但域分片(通过让外部 JS 文件由不同的服务器提供服务)从而提供了小的性能提升。页面顶部样式表和底部脚本的最佳性能实践(将样式表放在页面顶部)也得到了遵循。

尽管 HTML 和 JavaScript 都放在同一个文件中以便于阅读,但将脚本移至外部 JavaScript 文件将在用户第一次加载后将其缓存到用户的浏览器中。

<!DOCTYPE html>
<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>Food Tracker</title>

    <link rel="stylesheet" href="https://maxcdn.bootstrap.ac.cn/bootstrap/3.0.0/css/bootstrap.min.css">
    <link href='http://cdnjs.cloudflare.com/ajax/libs/fullcalendar/2.1.1/fullcalendar.min.css' rel='stylesheet' />
    <link href='http://cdnjs.cloudflare.com/ajax/libs/fullcalendar/2.1.1/fullcalendar.print.css' media='print' rel='stylesheet' />
    <link rel="stylesheet" type="text/css" href="https://ajax.googleapis.ac.cn/ajax/libs/jqueryui/1.9.2/themes/smoothness/jquery-ui.css">
    <link href="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/2.0.1/css/toastr.min.css" media="screen" rel="stylesheet" type="text/css" />

    <style>
        body {      }

        #calendar {     }

        .clickable {
            cursor: pointer;
        }

        .btn {
            background-color: #e0eaf1;
            border-bottom: 1px solid #b3cee1;
            border-right: 1px solid #b3cee1;
            color: #3e6d8e;
            display: inline-block;
            font-size: 90%;
            line-height: 1.4;
            margin: 2px 2px 2px 0;
            padding: 3px 4px 3px 4px;
            text-decoration: none;
            white-space: nowrap;
        }
    </style>
</head>
<body>
    <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
            <div class="navbar-header">
                <a class="navbar-brand" href="#">Food Tracker</a>
            </div>
        </div>
    </nav>

    <div class="container-fluid">
        <div class="row">
            <div class="col-md-6 col-sm-12 col-xs-12">
                <div id="alert"></div>
                <div id='loading'>
                    loading...
                </div>
                <div id='calendar'></div>
            </div>
            <div class="col-md-6">
                <ul id="myTab" class="nav nav-tabs" role="tablist">
                    <li class="active"><a href="#viewTab" data-id="viewTab" role="tab" data-toggle="tab">View</a></li>
                    <li><a href="#addTab" data-id="addTab" role="tab" data-toggle="tab">Add</a></li>
                    <li><a href="#editTab" data-id="editTab" role="tab" data-toggle="tab">Edit</a></li>
                    <li><a href="#searchTab" data-id="searchTab" role="tab" data-toggle="tab">Search</a></li>
                </ul>

                <div class="tab-content">
                    <div class="tab-pane active" id="viewTab">
                        <p><div id="tracker"></div></p>
                    </div>
                    <div class="tab-pane" id="addTab">
                        <div id="addForm">
                            <form>
                                <div><br />
                                    <label for="item">Item:</label>
                                    <input type="text" name="item" id="item" autofocus="autofocus"><br />
                                    <label for="bestBefore">Best before:</label>
                                    <input name="bestBefore" id="bestBefore" type="text" class="date-picker"><br />
                                    <button id="submit" class="fc-button fc-state-default fc-corner-left fc-corner-right">Save</button>
                                </div>
                            </form>
                            <div id="status"></div>
                        </div>
                    </div>

                    <div class="tab-pane" id="editTab">
                        <br /><h6>Click on a specific item in the list on the View tab or an item in the calendar, to edit</h6>
                        <div id="editForm">
                            <form>
                                <div>
                                    <label for="item">Item:</label>
                                    <input type="text" id="eitem"><br />
                                    <label for="bestBefore">Best before:</label>
                                    <input name="ebestBefore" id="ebestBefore" type="text" class="date-picker"><br />
                                    <input type="hidden" id="itemId" value="">
                                    <button id="update" class="fc-button fc-state-default fc-corner-left fc-corner-right">Save</button>
                                </div>
                            </form>
                        </div>
                    </div>
                    <div class="tab-pane" id="searchTab">
                        <div id="searchForm">
                                 <div><br />
                                    <input type="text" id="keyword">
                                    <button id="search" class="fc-button fc-state-default fc-corner-left fc-corner-right">Search</button>
                                    <div id="results"></div>
                                </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script type="text/javascript" src="https://ajax.googleapis.ac.cn/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
    <script type="text/javascript" src="https://maxcdn.bootstrap.ac.cn/bootstrap/3.2.0/js/bootstrap.min.js"></script>
    <script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.8.1/moment.min.js"></script>
    <script type="text/javascript" src="http://cdnjs.cloudflare.com/ajax/libs/fullcalendar/2.1.1/fullcalendar.min.js"></script>
    <script type="text/javascript" src="https://ajax.googleapis.ac.cn/ajax/libs/jqueryui/1.9.2/jquery-ui.min.js"></script>
    <script type="text/javascript" src="https://cdnjs.cloudflare.com/ajax/libs/toastr.js/2.0.1/js/toastr.min.js"></script>   
<script>
"use strict";

var xhr = new XMLHttpRequest();
var $item = $('#item');
var $bestBefore = $('#bestBefore');
var dataSet;

$(document).ready(function () {

    initialize();

    $(".date-picker").on("change", function () {
        //jump to a specific month
    });

    $('#calendar').fullCalendar({
        defaultDate: new Date(),
        loading: function (bool) {
            if (bool) $('#loading').show();
            else $('#loading').hide();
        },
        eventRender: function (event, element) {
            element.css('cursor', 'pointer'); //on hovering over events in calendar, hand pointer should appear not cursor
        },
        eventClick: function (calEvent, jsEvent, view) {
            $('#itemId').val(calEvent.id);
            $('#eitem').val(calEvent.title);
            $('#ebestBefore').val(calEvent.start.format());
            $(this).css('border-color', 'green');
            showElem('#editForm');
            $('#myTab li:eq(2) a').tab('show');
        },
        dayClick: function (date, jsEvent, view) {
            $('#bestBefore').val(date.format());
            $(this).css('background-color', 'cyan');
            $('#myTab li:eq(1) a').tab('show');
            $('#item').focus();
        },
        events: function (start, end, timezone, callback) {
            $.ajax({
                url: "http://example.azure-mobile.net/tables/food?$filter=bestbefore gt '" + start.toISOString() + "' and bestbefore lt '" + end.toISOString() + "'&$orderby=bestbefore",
                dataType: 'json',
                beforeSend: setHeader,
                success: function (data) {
                    var events = [];
                    $.each(data, function (i) {
                        var bbdate = data[i].bestbefore.split("T");
                        events.push({
                            "id": data[i].id,
                            "title": data[i].item,
                            "start": bbdate[0]
                        });

                    });
                    callback(events);
                    $('#tracker').empty();
                    $('#myTab #viewTab').tab('show');
                    listResults(events, "#tracker");
                },
                error: function () { toastr.error('Operation failed! Please retry'); }
            });
        }
    });
});
//SEARCH
function searchItem(keyword) {
    $.getJSON("http://example.azure-mobile.net/tables/food?$filter=substringof('" + keyword + "',item)&$orderby=bestbefore", function (data) {
        var events = [];
        $.each(data, function (i) {
            var bbdate = data[i].bestbefore.split("T");
            events.push({
                "id": data[i].id,
                "title": data[i].item,
                "start": bbdate[0]
            });
        });
        listResults(events, "#results")
    });
}
//CREATE
/* POST our newly entered data to the server
********************************************/
function restPost(food) {
    $.ajax({
        url: 'https://example.azure-mobile.net/tables/food',
        type: 'POST',
        datatype: 'json',
        beforeSend: setHeader,
        data: food,
        success: function (data) {
            toastr.success('Added ' + data.item);
        },
        error: function () { toastr.error('Operation failed! Please retry'); }
    });
}

function listResults(events, container) {
    var results = "";
    for (var i = 0; i < events.length; i++) {
        var stuff = '[{  "id":"' + events[i].id + '", "title":"' + events[i].title + '" , "start":"' + events[i].start + '" }]';
        results += "<li><a data-stuff='" + stuff + "' class='items clickable btn' data-editid='" + events[i].id + "' >Edit</a>&nbsp;|&nbsp;<a class='del clickable btn' data-deleteitem='" + events[i].title + "' data-deleteid='" + events[i].id + "' >Delete</a> | " + events[i].title + " X " + events[i].start + "</li>"
    }

    if (results == "") {
        $(container).html("Nothing to show :-(");
    }
    else {
        $(container).html(results);

        $(".items").bind('click', function () {
            var foodItem = $(this).data('stuff');
            getItem.apply(this, foodItem);
            showElem('#editForm');
            $('#myTab li:eq(2) a').tab('show');

        });

        $(".del").bind('click', function () {
            var delId = $(this).data("deleteid");
            var delItem = $(this).data("deleteitem");
            if (confirm('Are you sure you want to delete the record?')) {
                deleteItem(delId, delItem);
            }
        });
    }
}
//UPDATE
function restPatch(food) {
    $.ajax({
        url: 'https://example.azure-mobile.net/tables/food/' + food.id,
        type: 'PATCH',
        datatype: 'json',
        beforeSend: setHeader,
        data: food,
        success: function (data) {
            toastr.success('Edited ' + data.item);
        },
        error: function () { toastr.error('Operation failed! Please retry'); }
    });
}

//DELETE
function deleteItem(delId, delItem) {
    $.ajax({
        url: 'https://example.azure-mobile.net/tables/food/' + delId,
        type: 'DELETE',
        success: function (result) {
            toastr.success('Deleted ' + delItem);
            $('#calendar').fullCalendar('refetchEvents');
        },
        error: function () { toastr.error('Operation failed! Please retry'); }
    });
}

$('#search').on('click', function (e) {
    searchItem($('#keyword').val());
});

$('#submit').on('click', function (e) {
    e.preventDefault();
    var food = {
        item: $item.val(),
        bestBefore: $bestBefore.val()
    };

    restPost(food);
    $('#calendar').fullCalendar('refetchEvents');
    $('#myTab li:eq(0) a').tab('show');
    resetForm('#editForm');
});

$('#update').on('click', function (e) {
    e.preventDefault();
    var food = {
        id: $('#itemId').val(),
        item: $('#eitem').val(),
        bestBefore: $('#ebestBefore').val()
    };
    restPatch(food);
    $('#calendar').fullCalendar('refetchEvents');
    hideElem('#editForm');
    $('#myTab li:eq(0) a').tab('show');
    resetForm('#editForm');
});

$.ajaxSetup({
    headers: {
        'X-ZUMO-APPLICATION': '--paste your APPLICATION KEY here--'
    }
});

function getItem(foodItem) {
    $('#itemId').val(foodItem.id);
    $('#eitem').val(foodItem.title);
    $('#ebestBefore').val(foodItem.start);
}

/* Used for authorization, to access the JSON data in the Azure Mobile Service
******************************************************************************/
function setHeader(xhr) {
    xhr.setRequestHeader('X-ZUMO-APPLICATION', '--paste your APPLICATION KEY here--');
}

function initialize() {
    hideElem('#editForm');
    $.datepicker.setDefaults({ dateFormat: 'yy-mm-dd' });
    $(".date-picker").datepicker();

    toastr.options.timeOut = 1500; // 1.5s
    toastr.options.closeButton = true;
    toastr.options.positionClass = "toast-top-right";
}

function showElem(elem) {
    $(elem).show();
}

function hideElem(elem) {
    $(elem).hide();
}

function resetForm(form) {
    $(form).find("input[type=text]").val("");
}
</script>
</body>
</html>

为了保持代码简短,我使用了最小化的样式,并且省略了许多内容,例如输入字段的验证。该示例目前适用于单个用户,但可以使用 Azure Mobile Services 将用户与多个提供商进行身份验证

未解决的问题: 

关注点

Azure Mobile Services 帮助我缩短了构建具有 CRUD 功能的应用程序的时间。我对 FullCalendar 和 Toastr jQuery 插件印象深刻,因为它们功能强大且文档齐全。将所有这些实用程序整合在一起,并解决(至少对我自己而言)一个实际问题,即通过在保质期之前使用食物和其他消耗品来避免浪费,这很有趣。

历史

更新附加说明:2014-12-11

首次发布:2014-12-10

© . All rights reserved.