邊做邊學 jQuery 系列 15- AJAX式內容管理介面
邊做邊學 jQuery 系列 15- AJAX式內容管理介面教學影片 > [!VIDEO https://www.microsoft.com/zh-tw/videoplayer/embed/600be8bf-af60-472e-aeec-9f8161431fb7] |
【練習題】
在最後一個單元裡,我們將運用已學會的jQuery技巧,針對實用一點的題材做練習。講到AJAX式網站,相信大家都對Gmail式的網頁操作印象深刻吧? 整個操作過程中,完全沒有任何Postback的閃爍,就達到了檢視信箱、開啟郵件、編輯及寄送郵件、刪除郵件等全部操作。見賢思齊,好! 我們就把目標訂為利用jQuery開發一個完全沒有Postback的新增/修改/刪除資料管理介面。
我們先來假設一個"文章管理"的操作介面,並訂出規格需求如下:
- 包含清單、檢視及編輯三種操作階段。
- 具備新增、修改、刪除功能。
- 刪除時先勾選清單項目,再按刪除鈕完成。
- 支援全部選取,一次刪除功能。
- 清單需支援欄位排序功能。
- 編輯介面中,日期及分類以點選方式完成,不需輸入文字。
- 傳輸過程中顯示傳送中的狀態提示。
- 在畫面內嵌新增修改完成訊息,避免alert方式干擾操作。
- 介面全在純HTML網頁中完成,只另外開發一支ASPX做為資料處理之用。
- 不發生任何Postback,與後端溝通一律透過XMLHttpRequest達成。
- 程式碼力求精簡。
【操作介面設計概念】
由於這次的題材頗為複雜,程式碼較多,在此不以逐步解說,只一一巡覽各功能的操作方式並檢視程式設計時的重點,完整程式則包含在範例程式下載檔案裡。
首先看一下整體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 = "http://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/
//參考: https://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":"http://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":"http://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");
}
【範例檔案下載】