邊做邊學jQuery系列15 - AJAX式內容管理介面

課程錄製時間: 2009年
AJAX式內容管理介面

【練習題】

在最後一個單元裡,我們將運用已學會的jQuery技巧,針對實用一點的題材做練習。講到AJAX式網站,相信大家都對Gmail式的網頁操作印象深刻吧? 整個操作過程中,完全沒有任何Postback的閃爍,就達到了檢視信箱、開啟郵件、編輯及寄送郵件、刪除郵件等全部操作。見賢思齊,好! 我們就把目標訂為利用jQuery開發一個完全沒有Postback的新增/修改/刪除資料管理介面。

我們先來假設一個"文章管理"的操作介面,並訂出規格需求如下:

  1. 包含清單、檢視及編輯三種操作階段。
  2. 具備新增、修改、刪除功能。
  3. 刪除時先勾選清單項目,再按刪除鈕完成。
  4. 支援全部選取,一次刪除功能。
  5. 清單需支援欄位排序功能。
  6. 編輯介面中,日期及分類以點選方式完成,不需輸入文字。
  7. 傳輸過程中顯示傳送中的狀態提示。
  8. 在畫面內嵌新增修改完成訊息,避免alert方式干擾操作。
  9. 介面全在純HTML網頁中完成,只另外開發一支ASPX做為資料處理之用。
  10. 不發生任何Postback,與後端溝通一律透過XMLHttpRequest達成。
  11. 程式碼力求精簡。

【操作介面設計概念】

由於這次的題材頗為複雜,程式碼較多,在此不以逐步解說,只一一巡覽各功能的操作方式並檢視程式設計時的重點,完整程式則包含在範例程式下載檔案裡。

首先看一下整體HTML的結構,我們打算在同一個網頁中放入所有的介面元素,如下圖所示。共分為三區,紅框為清單區、橘框為檢視區,但按下編輯鈕會轉化成可編輯的輸入欄位、綠框內為以Checkbox Tree方式呈現的分類選擇器。

在這個範例中,我們將所有的介面都放在同一個網頁裡,再透過show()、hide()來切換。實務上若介面較複雜,則可考慮將不同的部分放在不同的網頁檔案中,必要時再以jQuery.load()方式動態載入,以便於維護及管理。

【資料端】

在開始談介面設計前,我們先看一下資料端如何供應網頁資料。

    //資料物件,用來作JSON轉換用
    public class PostItem
    {
        public string Id; //識別碼
        public string Date; //日期
        public string Category; //分類
        public string Title; //標題
        public string Hyperlink; //超連結
        public string Abstract; //摘要
        //直接將DataRow轉成資料物件,可選擇含不含摘要
        //故可同時作為清單及編輯兩用
        public PostItem(DataRow data, bool includeAbstract)
        {
            Id = data["Id"].ToString();
            Date = data["Date"].ToString();
            Category = data["Category"].ToString();
            Title = data["Title"].ToString();
            Hyperlink = data["Hyperlink"].ToString();
            if (includeAbstract) 
                Abstract = data["Abstract"].ToString();
        }
        //配合新增資料的邏輯
        public PostItem(string id)
        {
            Id = id;
            Date = DateTime.Today.ToString("yyyy/MM/dd");
            Category = "jQuery";
            Title = "新文章";
            Hyperlink = "https://blog.darkthread.net";
            Abstract = "請輸入摘要...";
        }
    }

    protected void Page_Load(object sender, EventArgs e)
    {
        //傳回結果可能包含使用者輸入結果,限定POST
        //減少被當作<script src="..." />進行XSS攻擊的風險
        //但實務應用時最好再加上如token等機制保護
        //參考: http://encosia.com/2008/03/27/using-jquery-to-consume-aspnet-json-web-services/
        //參考: http://weblogs.asp.net/scottgu/archive/2007/04/04/json-hijacking-and-how-asp-net-ajax-1-0-mitigates-these-attacks.aspx
        if (Request.HttpMethod != "POST")
            Response.End();

        //為簡化程式碼,省略傳入參數檢查
        DataTable dt = getDataStore();
        string mode = Request["mode"];
        System.Web.Script.Serialization.JavaScriptSerializer jss = 
            new System.Web.Script.Serialization.JavaScriptSerializer();
        string id = Request["id"] ?? "";
        //傳回文章清單
        if (mode == "list")
        {
            List<PostItem> lst = new List<PostItem>();
            foreach (DataRow r in dt.Rows)
                lst.Add(new PostItem(r, false));
            Response.Write(jss.Serialize(lst));
        }
        //讀取特定文章
        else if (mode == "view")
        {
            PostItem pi = null;
            if (id == "new") //新增
                pi = new PostItem(id);
            else
                pi = new PostItem(dt.Rows.Find(id), true);
            Response.Write(jss.Serialize(pi));
        }
        //更新文章內容
        else if (mode == "update")
        {
            bool isNew = (id == "new");
            DataRow r = isNew ? dt.NewRow() : dt.Rows.Find(id);
            //若是新增,指定PK
            if (isNew)
                //找出目前最大的編號(針對模擬資料的寫法)
                r["Id"] =
                    Convert.ToInt32(dt.Rows[dt.Rows.Count - 1]["Id"]) + 1;
            foreach (DataColumn c in dt.Columns)
            {
                //識別碼不能修改
                if (c.ColumnName == "Id") continue;
                r[c] = Request.Form[c.ColumnName];
            }
            //新增時,將資料加入DataTable
            if (isNew) dt.Rows.Add(r);
        }
        //刪除文章
        else if (mode == "del")
        {
            //傳入的delid會是guid1,guid2,guid3的格式
            foreach (string did in Request["did"].Split(','))
            {
                DataRow r = dt.Rows.Find(did);
                if (r != null)
                    dt.Rows.Remove(r);
            }
        }
        //System.Threading.Thread.Sleep(2000);
        Response.End();
    }

我們決定以JSON方式作為文章清單及內容的傳送格式,因此宣告了一個PostItem物件,之後透過System.Web.Script.Serialization.JavaScriptSerializer.Serialize(object)就可以輕鬆將物件轉換成JSON字串。清單時傳回List<PostItem>,會轉成以下匿名物件陣列的格式:

[ {"Id":"0","Date":"2008/03/07","Category":"jQuery","Title":"jQuery, I LOVE YOU~~~","Hyperlink":"https://blog.darkthread.net/blogs/darkthreadtw/archive/2008/03/07/jquery-intro.aspx","Abstract":null} , {"Id":"1","Date":"2008/05/16","Category":"jQuery","Title":"TIPS-神奇的jQuery XML查詢魔法","Hyperlink":"https://blog.darkthread.net/blogs/darkthreadtw/archive/2008/05/16/use-jquery-to-parse-xml.aspx","Abstract":null} ,{"Id":"2","Date":"2008/08/26","Category":"IE,jQuery", ....

不過由於我們的輸出結果格式為可執行的Script、內容又可能由使用者輸入決定,為了避免被注射稙入有害的Script,再以<script src="...">引用形成XSS漏洞,在一開始時加上限制POST方式存取。然而在正式環境裡,可能還需要加上更多認證機制以杜絕不當存取。此處為避免範例失焦暫且忽略,但在開發為正式運用時務必要加上。

在Page_Load()裡依Request["mode"]為list、view、update、del分別完成傳回清單內容、傳回文章內容、更新文章內容、刪除指定文章四項工作,資料的部分則以一個DataTable物件替代之。由於來往傳輸都限定在JSON及單純的QueryString或Form參數格式,由於與前端的溝通都只限於標準的HTTP傳輸及JSON格式,這支後端資料程式也可用任何網頁語言如JSP、PHP改寫之。

【清單介面】

清單部分的版型為上方兩個span做的按鈕配合一個table,外層包覆了一個div,以便整塊隱藏。

<div id="dvList">
<div style="margin-left: 10px;">
<span class="spanBtn" id="btnDel">刪除</span>
<span class="spanBtn" id="btnAdd">新增</span>
</div>
<table id="list" class="tablesorter">
<thead>
    <tr>
    <th style="width: 20px;"><input type="checkbox" id="cbxSelAll" /></th>
    <th style="width: 80px;">日期</th>
    <th style="width: 120px;">分類</th>
    <th style="width: 600px;">標題</th>
    </tr>
</thead>
<tbody>
</tbody>
</table>
</div>

我們用以下的方式呼叫WebApi.aspx,取得文章清單,再一一轉化成table中的tr。

            //載入清單
            function loadList() {
                $.post("WebApi.aspx?mode=list", null, bindList, "json");
            }
            //將傳回的List<PostItem>寫入Table
            function bindList(lst) {
                //清空原有的資料
                $("#list tbody").empty();
                //傳回結果應為[{ ... }, { ... } ... ] JSON轉成的物件陣列
                for (var i = 0; i < lst.length; i++) {
                    var pi = lst[i];
                    $("<tr>" +
                    "<td><input type='checkbox' /></td>" +
                    "<td class='viewLink' pid='" + pi.Id + "'>" + pi.Date + "</td>" +
                    "<td>" + pi.Category + "</td>" +
                    "<td class='extLink'><a href='" + pi.Hyperlink + "' target='_blank'>" + pi.Title + "</a></td>" +
                    "</tr>").appendTo("#list tbody");
                }
                addZebraColor();
                //更新資料以便排序
                $("#list").trigger("update");
            }
            //加上間隔色
            function addZebraColor() {
                $("#list tbody tr").removeClass("altRow").filter(":odd").addClass("altRow");
            }
            //利用live()加上檢視功能
            $(".viewLink").live("click", function() {
                $("#dvList,#dvViewer").toggle();
                loadPost($(this).attr("pid"));
            });

在新增資料時,我們在pi.Date所在的td設定了class="viewLink",讓使用者可以點選它檢視文章內容,由於每次查詢時會先清除所有清單項目再重新產生, 為了避免每次產生完要重新設定一次.viewLink的onclick事件,我們用jQuery.live()巧妙地一網打盡所有未來才出現的.viewLink。

 我們用了tablesorter Plugin提供表格排序的功能, $("#list").tablesorter({ headers: { 0: { sorter: false}} }).bind("sortEnd", addZebraColor);,每次更新完資料要額外觸發tablesorter自訂的update事件,以便更新排序資料,同時設定間隔色的部分也抽出成函數,在重建清單及重新排序事件(sortEnd)中呼叫。

另外,我們在每一列最前方放了一個checkbox,用來勾選欲刪除項目。標題列也有一個checkbox可以用來全選/全不選,這段程式碼用jQuery寫格外簡單:

            //全選功能
            $("#cbxSelAll").click(function() {
                var chk = this.checked;
                $("#list tr :checkbox").each(function() { this.checked = chk; });
            });	
	

【文章檢視】

文章檢視的部分,我們一樣用table來呈現,但這個table同時用來檢視及編輯,於是上方的按鈕分為兩組,grpViewer是檢視時使用、 grpEditor則是編輯階段採用。

<div id="dvViewer" style="display:none;">
<table id="viewer">
    <tr><td colspan="2" style="padding: 0px;">
    <div id="dvMenu">
        <span id="grpViewer">
            <span id="btnBack" class="spanBtn">回清單</span>
            <span id="btnEdit" class="spanBtn">編輯</span>
        </span>    
        <span id="grpEditor" style="display:none;">
            <span id="btnSave" class="spanBtn">儲存</span>
            <span id="btnCancel" class="spanBtn">取消</span>
        </span>
    </div>
    </td></tr>
    <tr><td class="hdr">日期</td><td id="fldDate"></td></tr>
    <tr><td class="hdr">分類</td><td id="fldCategory"></td></tr>
    <tr><td class="hdr">標題</td><td id="fldTitle"></td></tr>
    <tr><td class="hdr">連結</td><td id="fldHyperlink"></td></tr>
    <tr><td class="hdr">內文</td><td id="fldAbstract"></td></tr>
</table>
</div>

table中欄位名稱後方的td都有標上id,大家應該猜出要怎麼將資料塞入了吧?

            //載入資料
            function loadPost(id) {
                $.post("WebApi.aspx", { mode: "view", id: id }, bindPost, "json");
            }
            function bindPost(pi) {
                for (var f in pi) {
                    $("#fld" + f).empty().append(
                    "<span>" + pi[f] + "</span>");
                }
                $("#viewer").attr("pid", pi.Id);
                //新增資料時,自動進入編輯模式
                if (pi.Id == "new") goEditMode();
            }
            //回清單鈕
            $("#btnBack").click(function() {
                $("#dvList,#dvViewer").toggle();
            });
            //編輯鈕
            $("#btnEdit").click(function() {
                goEditMode();
            });
            var fldSetting = [
                { Name: "Date", Width: 70 },
                { Name: "Category", Width: 250 },
                { Name: "Title", Width: 300 },
                { Name: "Hyperlink", Width: 600 },
                { Name: "Abstract", Width: 600 }
            ];
            //切換到編輯模式
            function goEditMode() {
                $("#grpViewer,#grpEditor").toggle();
                $.each(fldSetting, function() {
                    var elemType = (this.Name == "Abstract") ? "textarea" : "input";
                    var span = $("#fld" + this.Name + " > span");
                    var elem = $("<" + elemType + " />").val(span.text())
                    .width(this.Width).insertAfter(span);
                    span.hide();
                    //額外邏輯
                    switch (this.Name) {
                        case "Abstract":
                            elem.height(300);
                            break;
                        case "Date":
                            elem.datepicker({ dateFormat: "yy/mm/dd" });
                            break;
                        case "Category":
                        //限定不能輸入,只能選取
                        elem.attr("readonly", "readonly").click(function() {
                            //觸發後方的連結鈕
                            $(this).next().click();
                        });
                        //將選取內容對應到checktree上                        
                            $("#ulCatgTree").setCheckTreeValues(elem.val().split(','));
                            //將真的輸入方格隱藏,另外顯示<a>以啟動thickbox
                            elem.after(
                            "<a href='#TB_inline?height=200&width=200&inlineId=dvCatgPicker&modal=true' class='thickbox'>設定</a>"
                            );
                            tb_init("a.thickbox");
                            break;
                    }
                });
            }
            //儲存鈕
            $("#btnSave").click(function() {
                var pi = goViewMode();
                $.ajax({
                    url: "WebApi.aspx?mode=update",
                    type: "POST",
                    data: pi,
                    success: function() {
                        showNotification("資料已更新!");
                        //重新載入清單
                        loadList();
                        //顯示清單
                        btnBack.click();
                    },
                    beforeSend: function() {
                        showNotification("資料更新中...");
                    }
                });

            });
            //放棄編輯
            $("#btnCancel").click(function() {
                goViewMode();
            });
            //回到檢視模式
            function goViewMode() {
                var pi = { Id: $("#viewer").attr("pid") };
                $.each(fldSetting, function() {
                    var span = $("#fld" + this.Name + " > span");
                    //將欄位值反應回span
                    span.text(span.next().val());
                    pi[this.Name] = span.text();
                    //將編輯用的元素移除
                    span.siblings().remove().end().show();
                });
                $("#grpViewer,#grpEditor").toggle();
                return pi;
            }            

是的,由WebApi.aspx?mode=view取回文章內容,我們跑一個迴圈,逐一以匿名物件的屬性名稱去找出對應的td,將資料塞入<span>顯示內容就大功告成。比較有趣的部分是如何按一下編輯鈕就將它轉成編輯介面? 在goEditMode()中,我們在各欄位值以after()方式新增input、textarea,再將原有的span隱藏,另外針對日期及分類我們再加上額外的選擇器邏輯。goViewMode()裡做了逆向工程,將加上的input、textarea一一移除,span還原。這樣子,我們可以讓檢視與編輯介面共用同一個table配置,讓程式更簡短更好維護。

【選擇器】

日期選擇器的部分採用的是jQuery UI裡的現成widget,只需elem.datepicker({ dateFormat: "yy/mm/dd" });一行程式就可搞定。

類別選擇器的部分則選用checktree這個Plugin,另外再做了些修改,加上setCheckTreeValues()、getCheckTreeValues()兩個函數,以便將結果對應到checkbox上及讀取選取結果為字串陣列。程式碼如下:

<div id="dvCatgPicker" style="width:150px; height:200px; padding: 5px; overflow: auto; text-align:left; display:none;">
<p><b>分類選擇:</b></p>
<ul id="ulCatgTree" class="tree" style="border: solid 1px gray;">
</ul>
<p style="text-align: center;"><span class="spanBtn" id="btnSetCatg">確定</span></p>
</div>
<script type="text/javascript">
    $(function() {
        //載入分類選擇器的項目
        var catgs = "ASP.NET,IE,Javascript,jQuery".split(",");
        $.each(catgs, function() {
            $("<li><input type='checkbox' value='" + this + "'>" +
                  "<label>" + this + "</label></li>")
                  .appendTo("#dvCatgPicker ul");
        });
        $("#ulCatgTree").checkTree();
        $("#btnSetCatg").click(function() {
            $("#fldCategory input").val($("#ulCatgTree").getCheckTreeValues().join(","));
            tb_remove();
        });
    });
</script>

【新增與刪除】

刪除資料時要蒐集所有checked的checkbox,並由其下一個td取得文章的Id,當成POST時的Form參數送出。 而新增文章資料可以歸納成一種特殊的編輯過程,Id的部分採用new以為識別,會在存入資料表時才賦與真正的值。程式碼如下:

            //刪除資料
            $("#btnDel").click(function() {
                var dids = [];
                $("#list tbody :checked").each(function() {
                    dids.push($(this).parent().next().attr("pid"));
                });
                if (confirm("確定要刪除這" + dids.length + "筆資料?")) {
                    $.post("WebApi.aspx", { mode: "del", did: dids }, function() {
                        showNotification("刪除完成!");
                        loadList();
                    });
                }
            });
            //新增資料
            $("#btnAdd").click(function() {
                $("#dvList,#dvViewer").toggle();
                loadPost("new");
            });

【錦上添花】

最後,來加一點人性化的訊息提示,在資料傳輸期間,我們希望在網頁上顯示資料傳輸中的訊息,資料更新或刪除後也希望給些提示。 在頁面上建立了一個div,利用上回提過的ajaxSend()、ajaxComplete(),就可在每次有AJAX傳輸時,顯示資料傳輸中,同時我們也一併停用 網頁上所有的span按鈕,防止重覆動作。

<div id="dvStatus">
<img src="loading.gif" alt="Loading" /><span id="spnLoading">資料傳輸中,請稍候...</span>
</div>
            $("#dvStatus").ajaxSend(function() {
                $(this).css("visibility", "visible");
                $(".spanBtn").css("color", "gray").attr("disabled", "disabled");
            }).ajaxComplete(function() {
                $(this).css("visibility", "hidden");
                $(".spanBtn").css("color", "").removeAttr("disabled");
            });

資料更新與刪除的提示,是用在先前程式碼裡已出現過showNotification(),它會在左上角顯示一塊紅底白字的訊息,三秒後淡出,讓jQuery的動畫效果也上場秀一下,一個純HTML+AJAX的內容管理介面雛型就完成囉!

            //在左上角顯示訊息3秒
            function showNotification(msg) {
                $("#dvNotification").text(msg).stop(true, true)
                .animate({ opacity: 1 }, "fast")
                .animate({ opacity: 1 }, 3000)
                .animate({ opacity: 0 }, "slow");
            }

 



【範例檔案下載】

歡迎推文分享:



 
RSS
【工商服務】
OrcsWeb: Windows Server Hosting
twMVC

關於作者

一個醉心技術又酷愛分享的Coding魔人,十年的IT職場生涯,寫過系統、管過專案, 也帶過團隊,最後還是無怨無悔地選擇了技術鑽研這條路,近年來則以做一個"有為的中年人"自許。