從 Windows Phone 8 應用程式呼叫 Web API (C#)
演講者:Robert McMurray
在本教學課程中,您將學習如何建立一個完整的端對端場景,包括一個 ASP.NET Web API 應用程式,該應用程式向 Windows Phone 8 應用程式提供書籍目錄。
概觀
ASP.NET Web API 等 RESTful 服務會透過抽象伺服器端和用戶端應用程式的架構,簡化開發人員建立 HTTP 型應用程式的過程。 Web API 開發人員無需建立以通訊端為基礎的專有通訊協定,只需為其應用程式發布必需的 HTTP 方法 (例如:GET、POST、PUT、DELETE),而用戶端應用程式開發人員只需使用其應用程式所需的方法。
在本端對端教學課程中,您將學習如何使用 Web API 建立以下專案:
- 在本教學課程的第一節中,您將建立一個 ASP.NET Web API 應用程式,該應用程式支援所有建立、讀取、更新和刪除 (CRUD) 操作來管理書籍目錄。 此應用程式將使用 MSDN 中的範例 XML 檔案 (books.xml)。
- 在本教學課程的第二節中,您將建立一個互動式 Windows Phone 8 應用程式,用於從 Web API 應用程式擷取資料。
必要條件
- 已安裝 Windows Phone 8 SDK 的 Visual Studio 2013
- 已安裝 Hyper-V 的 64 位元系統上的 Windows 8 或更高版本
- 有關其他要求的清單,請參閱 Windows Phone SDK 8.0 下載頁面上的系統需求部分。
注意
如果您要在本機系統上測試 Web API 和 Windows Phone 8 專案之間的連接,則需要按照「將 Windows Phone 8 仿真器連接到本機電腦上的 Web API 應用程式」一文中的說明來設定測試環境。
步驟 1:建立 Web API 書店專案
這個端對端教學課程的第一步是建立一個支援所有 CRUD 操作的 Web API 專案;請注意,您將在本教學課程的步驟 2 中將 Windows Phone 應用程式專案新增至此解決方案。
開啟 Visual Studio 2013。
按一下「檔案」,然後按一下「新增」,然後按一下「專案」。
顯示「新專案」對話方塊時,依序展開「已安裝」、「範本」、「Visual C#」和「Web」。
按一下圖片可展開 醒目顯示 ASP.NET Web 應用程式,輸入 BookStore 作為專案名稱,然後按一下「確定」。
顯示「新 ASP.NET 專案」對話方塊時,選擇 Web API 範本,然後按一下「確定」。
按一下圖片可展開 當 Web API 專案開啟時,從專案中移除範例控制器:
- 展開方案總管中的 Controllers 資料夾。
- 以滑鼠右鍵按一下 ValuesController.cs 檔案,然後按一下「刪除」。
- 當提示確認刪除時,按一下「確定」。
將 XML 資料檔加入 Web API 專案;該文件包含書店目錄的內容:
以滑鼠右鍵按一下方案總管中的 App_Data 資料夾,然後按一下「新增」,然後按一下「新專案」。
顯示「新專案」對話方塊時,醒目顯示「XML 檔案」範本。
將檔案命名為 Books.xml,然後按一下「新增」。
開啟 Books.xml 檔案後,將檔案中的程式碼替換為 MSDN 上範例 books.xml 檔案中的 XML:
<?xml version="1.0" encoding="utf-8"?> <catalog> <book id="bk101"> <author>Gambardella, Matthew</author> <title>XML Developer's Guide</title> <genre>Computer</genre> <price>44.95</price> <publish_date>2000-10-01</publish_date> <description> An in-depth look at creating applications with XML. </description> </book> <book id="bk102"> <author>Ralls, Kim</author> <title>Midnight Rain</title> <genre>Fantasy</genre> <price>5.95</price> <publish_date>2000-12-16</publish_date> <description> A former architect battles corporate zombies, an evil sorceress, and her own childhood to become queen of the world. </description> </book> <book id="bk103"> <author>Corets, Eva</author> <title>Maeve Ascendant</title> <genre>Fantasy</genre> <price>5.95</price> <publish_date>2000-11-17</publish_date> <description> After the collapse of a nanotechnology society in England, the young survivors lay the foundation for a new society. </description> </book> <book id="bk104"> <author>Corets, Eva</author> <title>Oberon's Legacy</title> <genre>Fantasy</genre> <price>5.95</price> <publish_date>2001-03-10</publish_date> <description> In post-apocalypse England, the mysterious agent known only as Oberon helps to create a new life for the inhabitants of London. Sequel to Maeve Ascendant. </description> </book> <book id="bk105"> <author>Corets, Eva</author> <title>The Sundered Grail</title> <genre>Fantasy</genre> <price>5.95</price> <publish_date>2001-09-10</publish_date> <description> The two daughters of Maeve, half-sisters, battle one another for control of England. Sequel to Oberon's Legacy. </description> </book> <book id="bk106"> <author>Randall, Cynthia</author> <title>Lover Birds</title> <genre>Romance</genre> <price>4.95</price> <publish_date>2000-09-02</publish_date> <description> When Carla meets Paul at an ornithology conference, tempers fly as feathers get ruffled. </description> </book> <book id="bk107"> <author>Thurman, Paula</author> <title>Splish Splash</title> <genre>Romance</genre> <price>4.95</price> <publish_date>2000-11-02</publish_date> <description> A deep sea diver finds true love twenty thousand leagues beneath the sea. </description> </book> <book id="bk108"> <author>Knorr, Stefan</author> <title>Creepy Crawlies</title> <genre>Horror</genre> <price>4.95</price> <publish_date>2000-12-06</publish_date> <description> An anthology of horror stories about roaches, centipedes, scorpions and other insects. </description> </book> <book id="bk109"> <author>Kress, Peter</author> <title>Paradox Lost</title> <genre>Science Fiction</genre> <price>6.95</price> <publish_date>2000-11-02</publish_date> <description> After an inadvertant trip through a Heisenberg Uncertainty Device, James Salway discovers the problems of being quantum. </description> </book> <book id="bk110"> <author>O'Brien, Tim</author> <title>Microsoft .NET: The Programming Bible</title> <genre>Computer</genre> <price>36.95</price> <publish_date>2000-12-09</publish_date> <description> Microsoft's .NET initiative is explored in detail in this deep programmer's reference. </description> </book> <book id="bk111"> <author>O'Brien, Tim</author> <title>MSXML3: A Comprehensive Guide</title> <genre>Computer</genre> <price>36.95</price> <publish_date>2000-12-01</publish_date> <description> The Microsoft MSXML3 parser is covered in detail, with attention to XML DOM interfaces, XSLT processing, SAX and more. </description> </book> <book id="bk112"> <author>Galos, Mike</author> <title>Visual Studio 7: A Comprehensive Guide</title> <genre>Computer</genre> <price>49.95</price> <publish_date>2001-04-16</publish_date> <description> Microsoft Visual Studio 7 is explored in depth, looking at how Visual Basic, Visual C++, C#, and ASP+ are integrated into a comprehensive development environment. </description> </book> </catalog>
儲存並關閉 XML 檔案。
將書店模型加入 Web API 專案中;此模型包含書店應用程式的建立、讀取、更新和刪除 (CRUD) 邏輯:
以滑鼠右鍵按一下方案總管中的 Models 資料夾,然後按一下「新增」,然後按一下「類別」。
顯示「新增項目」對話方塊時,將類別檔案命名為 BookDetails.cs,然後按一下「新增」。
開啟 BookDetails.cs 檔案後,將檔案中的程式碼替換為以下內容:
using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; using System.Xml; using System.Xml.Linq; using System.Xml.XPath; using System.Web; namespace BookStore.Models { /// <summary> /// Define a class that will hold the detailed information for a book. /// </summary> public class BookDetails { [Required] public String Id { get; set; } [Required] public String Title { get; set; } public String Author { get; set; } public String Genre { get; set; } public Decimal Price { get; set; } public DateTime PublishDate { get; set; } public String Description { get; set; } } /// <summary> /// Define an interface which contains the methods for the book repository. /// </summary> public interface IBookRepository { BookDetails CreateBook(BookDetails book); IEnumerable<BookDetails> ReadAllBooks(); BookDetails ReadBook(String id); BookDetails UpdateBook(String id, BookDetails book); Boolean DeleteBook(String id); } /// <summary> /// Define a class based on the book repository interface which contains the method implementations. /// </summary> public class BookRepository : IBookRepository { private string xmlFilename = null; private XDocument xmlDocument = null; /// <summary> /// Define the class constructor. /// </summary> public BookRepository() { try { // Determine the path to the books.xml file. xmlFilename = HttpContext.Current.Server.MapPath("~/app_data/books.xml"); // Load the contents of the books.xml file into an XDocument object. xmlDocument = XDocument.Load(xmlFilename); } catch (Exception ex) { // Rethrow the exception. throw ex; } } /// <summary> /// Method to add a new book to the catalog. /// Defines the implementation of the POST method. /// </summary> public BookDetails CreateBook(BookDetails book) { try { // Retrieve the book with the highest ID from the catalog. var highestBook = ( from bookNode in xmlDocument.Elements("catalog").Elements("book") orderby bookNode.Attribute("id").Value descending select bookNode).Take(1); // Extract the ID from the book data. string highestId = highestBook.Attributes("id").First().Value; // Create an ID for the new book. string newId = "bk" + (Convert.ToInt32(highestId.Substring(2)) + 1).ToString(); // Verify that this book ID does not currently exist. if (this.ReadBook(newId) == null) { // Retrieve the parent element for the book catalog. XElement bookCatalogRoot = xmlDocument.Elements("catalog").Single(); // Create a new book element. XElement newBook = new XElement("book", new XAttribute("id", newId)); // Create elements for each of the book's data items. XElement[] bookInfo = FormatBookData(book); // Add the element to the book element. newBook.ReplaceNodes(bookInfo); // Append the new book to the XML document. bookCatalogRoot.Add(newBook); // Save the XML document. xmlDocument.Save(xmlFilename); // Return an object for the newly-added book. return this.ReadBook(newId); } } catch (Exception ex) { // Rethrow the exception. throw ex; } // Return null to signify failure. return null; } /// <summary> /// Method to retrieve all of the books in the catalog. /// Defines the implementation of the non-specific GET method. /// </summary> public IEnumerable<BookDetails> ReadAllBooks() { try { // Return a list that contains the catalog of book ids/titles. return ( // Query the catalog of books. from book in xmlDocument.Elements("catalog").Elements("book") // Sort the catalog based on book IDs. orderby book.Attribute("id").Value ascending // Create a new instance of the detailed book information class. select new BookDetails { // Populate the class with data from each of the book's elements. Id = book.Attribute("id").Value, Author = book.Element("author").Value, Title = book.Element("title").Value, Genre = book.Element("genre").Value, Price = Convert.ToDecimal(book.Element("price").Value), PublishDate = Convert.ToDateTime(book.Element("publish_date").Value), Description = book.Element("description").Value }).ToList(); } catch (Exception ex) { // Rethrow the exception. throw ex; } } /// <summary> /// Method to retrieve a specific book from the catalog. /// Defines the implementation of the ID-specific GET method. /// </summary> public BookDetails ReadBook(String id) { try { // Retrieve a specific book from the catalog. return ( // Query the catalog of books. from book in xmlDocument.Elements("catalog").Elements("book") // Specify the specific book ID to query. where book.Attribute("id").Value.Equals(id) // Create a new instance of the detailed book information class. select new BookDetails { // Populate the class with data from each of the book's elements. Id = book.Attribute("id").Value, Author = book.Element("author").Value, Title = book.Element("title").Value, Genre = book.Element("genre").Value, Price = Convert.ToDecimal(book.Element("price").Value), PublishDate = Convert.ToDateTime(book.Element("publish_date").Value), Description = book.Element("description").Value }).Single(); } catch { // Return null to signify failure. return null; } } /// <summary> /// Populates a book BookDetails class with the data for a book. /// </summary> private XElement[] FormatBookData(BookDetails book) { XElement[] bookInfo = { new XElement("author", book.Author), new XElement("title", book.Title), new XElement("genre", book.Genre), new XElement("price", book.Price.ToString()), new XElement("publish_date", book.PublishDate.ToString()), new XElement("description", book.Description) }; return bookInfo; } /// <summary> /// Method to update an existing book in the catalog. /// Defines the implementation of the PUT method. /// </summary> public BookDetails UpdateBook(String id, BookDetails book) { try { // Retrieve a specific book from the catalog. XElement updateBook = xmlDocument.XPathSelectElement(String.Format("catalog/book[@id='{0}']", id)); // Verify that the book exists. if (updateBook != null) { // Create elements for each of the book's data items. XElement[] bookInfo = FormatBookData(book); // Add the element to the book element. updateBook.ReplaceNodes(bookInfo); // Save the XML document. xmlDocument.Save(xmlFilename); // Return an object for the updated book. return this.ReadBook(id); } } catch (Exception ex) { // Rethrow the exception. throw ex; } // Return null to signify failure. return null; } /// <summary> /// Method to remove an existing book from the catalog. /// Defines the implementation of the DELETE method. /// </summary> public Boolean DeleteBook(String id) { try { if (this.ReadBook(id) != null) { // Remove the specific child node from the catalog. xmlDocument .Elements("catalog") .Elements("book") .Where(x => x.Attribute("id").Value.Equals(id)) .Remove(); // Save the XML document. xmlDocument.Save(xmlFilename); // Return a success status. return true; } else { // Return a failure status. return false; } } catch (Exception ex) { // Rethrow the exception. throw ex; } } } }
儲存並關閉 BookDetails.cs 檔案。
將書店控制器新增至 Web API 專案:
以滑鼠右鍵按一下方案總管中的 Controllers 資料夾,然後按一下「新增」,然後按一下「控制器」。
顯示「新增 Scaffold」對話方塊時,醒目顯示「Web API 2 控制器 - 空白」,然後按一下「新增」。
顯示「新增控制器」對話方塊時,將控制器命名為 BooksController,然後按一下「新增」。
打開 BooksController.cs 檔案後,將檔案中的程式碼替換為以下內容:
using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Net.Http; using System.Web.Http; using BookStore.Models; namespace BookStore.Controllers { public class BooksController : ApiController { private BookRepository repository = null; // Define the class constructor. public BooksController() { this.repository = new BookRepository(); } /// <summary> /// Method to retrieve all of the books in the catalog. /// Example: GET api/books /// </summary> [HttpGet] public HttpResponseMessage Get() { IEnumerable<BookDetails> books = this.repository.ReadAllBooks(); if (books != null) { return Request.CreateResponse<IEnumerable<BookDetails>>(HttpStatusCode.OK, books); } else { return Request.CreateResponse(HttpStatusCode.NotFound); } } /// <summary> /// Method to retrieve a specific book from the catalog. /// Example: GET api/books/5 /// </summary> [HttpGet] public HttpResponseMessage Get(String id) { BookDetails book = this.repository.ReadBook(id); if (book != null) { return Request.CreateResponse<BookDetails>(HttpStatusCode.OK, book); } else { return Request.CreateResponse(HttpStatusCode.NotFound); } } /// <summary> /// Method to add a new book to the catalog. /// Example: POST api/books /// </summary> [HttpPost] public HttpResponseMessage Post(BookDetails book) { if ((this.ModelState.IsValid) && (book != null)) { BookDetails newBook = this.repository.CreateBook(book); if (newBook != null) { var httpResponse = Request.CreateResponse<BookDetails>(HttpStatusCode.Created, newBook); string uri = Url.Link("DefaultApi", new { id = newBook.Id }); httpResponse.Headers.Location = new Uri(uri); return httpResponse; } } return Request.CreateResponse(HttpStatusCode.BadRequest); } /// <summary> /// Method to update an existing book in the catalog. /// Example: PUT api/books/5 /// </summary> [HttpPut] public HttpResponseMessage Put(String id, BookDetails book) { if ((this.ModelState.IsValid) && (book != null) && (book.Id.Equals(id))) { BookDetails modifiedBook = this.repository.UpdateBook(id, book); if (modifiedBook != null) { return Request.CreateResponse<BookDetails>(HttpStatusCode.OK, modifiedBook); } else { return Request.CreateResponse(HttpStatusCode.NotFound); } } return Request.CreateResponse(HttpStatusCode.BadRequest); } /// <summary> /// Method to remove an existing book from the catalog. /// Example: DELETE api/books/5 /// </summary> [HttpDelete] public HttpResponseMessage Delete(String id) { BookDetails book = this.repository.ReadBook(id); if (book != null) { if (this.repository.DeleteBook(id)) { return Request.CreateResponse(HttpStatusCode.OK); } } else { return Request.CreateResponse(HttpStatusCode.NotFound); } return Request.CreateResponse(HttpStatusCode.BadRequest); } } }
儲存並關閉 BooksController.cs 檔案。
建立 Web API 應用程式以檢查錯誤。
步驟 2:新增 Windows Phone 8 書店目錄專案
這個端對端情境的下一步是為 Windows Phone 8 建立目錄應用程式。 這個應用程式將使用 Windows Phone Databound 應用程式範本作為預設使用者介面,並將使用您在本教學課程的步驟 1 中建立的 Web API 應用程式作為資料來源。
以滑鼠右鍵按一下方案總管中的 BookStore 解決方案,然後按一下「新增」,然後按一下「新專案」。
顯示「新專案」對話方塊時,依序展開「已安裝」、「Visual C#」和「Windows Phone」。
醒目顯示 Windows Phone Databound 應用程式,輸入 BookCatalog 作為名稱,然後按一下「確定」。
將 Json.NET NuGet 套件新增至 BookCatalog 專案:
- 在方案總管中以滑鼠右鍵按一下 BookCatalog 專案的「參考」,然後按一下「管理 NuGet 套件」。
- 顯示「管理 NuGet 套件」對話方塊時,展開「線上」區段,並醒目顯示 nuget.org。
- 在搜尋欄位中輸入 Json.NET,然後按一下搜尋圖示。
- 在搜尋結果中醒目顯示 Json.NET,然後按一下「安裝」。
- 安裝完成後,按一下「關閉」。
將 BookDetails 模型加入 BookCatalog 專案中;這包含書店類別的一般模型:
在方案總管中以滑鼠右鍵按一下 BookCatalog 專案,然後按一下「新增」,然後按一下「新資料夾」。
將新的資料夾命名為模型。
以滑鼠右鍵按一下方案總管中的 Models 資料夾,然後按一下「新增」,然後按一下「類別」。
顯示「新增項目」對話方塊時,將類別檔案命名為 BookDetails.cs,然後按一下「新增」。
開啟 BookDetails.cs 檔案後,將檔案中的程式碼替換為以下內容:
using System; using System.Text; namespace BookCatalog.Models { /// <summary> /// Define a class that will hold the detailed information for a book. /// </summary> public class BookDetails { public String Id { get; set; } public String Title { get; set; } public String Author { get; set; } public String Genre { get; set; } public Decimal Price { get; set; } public DateTime PublishDate { get; set; } public String Description { get; set; } } }
儲存並關閉 BookDetails.cs 檔案。
更新 MainViewModel.cs 類別以包含與 BookStore Web API 應用程式通訊的功能:
在方案總管中展開 ViewModels 資料夾,然後按兩下 MainViewModel.cs 檔案。
開啟 MainViewModel.cs 檔案後,將檔案中的程式碼替換為以下內容;請注意,您需要使用 Web API 的實際 URL 更新
apiUrl
常數的值:using System; using System.Collections.ObjectModel; using System.ComponentModel; using System.Net; using System.Net.NetworkInformation; using BookCatalog.Resources; using System.Collections.Generic; using Newtonsoft.Json; using BookCatalog.Models; namespace BookCatalog.ViewModels { public class MainViewModel : INotifyPropertyChanged { const string apiUrl = @"http://www.contoso.com/api/Books"; public MainViewModel() { this.Items = new ObservableCollection<ItemViewModel>(); } /// <summary> /// A collection for ItemViewModel objects. /// </summary> public ObservableCollection<ItemViewModel> Items { get; private set; } public bool IsDataLoaded { get; private set; } /// <summary> /// Creates and adds a few ItemViewModel objects into the Items collection. /// </summary> public void LoadData() { if (this.IsDataLoaded == false) { this.Items.Clear(); this.Items.Add(new ItemViewModel() { ID = "0", LineOne = "Please Wait...", LineTwo = "Please wait while the catalog is downloaded from the server.", LineThree = null }); WebClient webClient = new WebClient(); webClient.Headers["Accept"] = "application/json"; webClient.DownloadStringCompleted += new DownloadStringCompletedEventHandler(webClient_DownloadCatalogCompleted); webClient.DownloadStringAsync(new Uri(apiUrl)); } } private void webClient_DownloadCatalogCompleted(object sender, DownloadStringCompletedEventArgs e) { try { this.Items.Clear(); if (e.Result != null) { var books = JsonConvert.DeserializeObject<BookDetails[]>(e.Result); int id = 0; foreach (BookDetails book in books) { this.Items.Add(new ItemViewModel() { ID = (id++).ToString(), LineOne = book.Title, LineTwo = book.Author, LineThree = book.Description.Replace("\n", " ") }); } this.IsDataLoaded = true; } } catch (Exception ex) { this.Items.Add(new ItemViewModel() { ID = "0", LineOne = "An Error Occurred", LineTwo = String.Format("The following exception occured: {0}", ex.Message), LineThree = String.Format("Additional inner exception information: {0}", ex.InnerException.Message) }); } } public event PropertyChangedEventHandler PropertyChanged; private void NotifyPropertyChanged(String propertyName) { PropertyChangedEventHandler handler = PropertyChanged; if (null != handler) { handler(this, new PropertyChangedEventArgs(propertyName)); } } } }
儲存並關閉 MainViewModel.cs 檔案。
更新 MainPage.xaml 檔案以自訂應用程式名稱:
按兩下方案總管中的 MainPage.xaml 檔案。
開啟 MainPage.xaml 檔案後,找到以下幾行程式碼:
<StackPanel Grid.Row="0" Margin="12,17,0,28"> <TextBlock Text="MY APPLICATION" Style="{StaticResource PhoneTextNormalStyle}"/> <TextBlock Text="page name" Margin="9,-7,0,0" Style="{StaticResource PhoneTextTitle1Style}"/> </StackPanel>
將這些行替換為以下內容:
<StackPanel Grid.Row="0" Margin="12,17,0,28"> <TextBlock Text="Book Store" Style="{StaticResource PhoneTextTitle1Style}"/> <TextBlock Text="Current Catalog" Margin="9,-7,0,0" Style="{StaticResource PhoneTextTitle2Style}"/> </StackPanel>
儲存並關閉 MainPage.xaml 檔案。
更新 DetailsPage.xaml 檔案以自訂顯示的項目:
按兩下方案總管中的 DetailsPage.xaml 檔案。
開啟 DetailsPage.xaml 檔案後,找到以下幾行程式碼:
<StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28"> <TextBlock Text="MY APPLICATION" Style="{StaticResource PhoneTextNormalStyle}"/> <TextBlock Text="{Binding LineOne}" Margin="9,-7,0,0" Style="{StaticResource PhoneTextTitle1Style}"/> </StackPanel>
將這些行替換為以下內容:
<StackPanel x:Name="TitlePanel" Grid.Row="0" Margin="12,17,0,28"> <TextBlock Text="Book Store" Style="{StaticResource PhoneTextTitle1Style}"/> <TextBlock Text="{Binding LineOne}" Margin="9,-7,0,0" Style="{StaticResource PhoneTextTitle2Style}"/> </StackPanel>
儲存並關閉 DetailsPage.xaml 檔案。
建立 Windows Phone 應用程式以檢查錯誤。
第 3 步:測試端對端解決方案
如本教學課程的先決條件部分所述,當您在本機系統上測試 Web API 和 Windows Phone 8 專案之間的連接時,您需要按照「將 Windows Phone 8 仿真器連接到電腦上的 Web API 應用程式」一文中的說明進行操作。
設定測試環境後,您需要將 Windows Phone 應用程式設定為啟動專案。 若要這樣做,請在方案總管中醒目顯示 BookCatalog 應用程式,然後按一下「設定為啟動專案」:
按一下圖片可展開 |
當您按 F5 時,Visual Studio 將啟動 Windows Phone 仿真器,在從 Web API 擷取應用程式資料時,它將顯示「請稍候」訊息:
按一下圖片可展開 |
如果一切順利,您應該會看到顯示的目錄:
按一下圖片可展開 |
如果您點擊任何書名,應用程式將顯示該書籍的描述:
按一下圖片可展開 |
如果應用程式無法與您的 Web API 通訊,將顯示錯誤訊息:
按一下圖片可展開 |
如果您點擊錯誤訊息,將顯示有關該錯誤的任何其他詳細資訊:
按一下圖片可展開 |