iT邦幫忙

2021 iThome 鐵人賽

DAY 27
3
Modern Web

ASP.NET Web Forms 入門 - 30天建立遊艇網頁專案後端及後台功能 C#系列 第 27

Day 27 - 依 Yachts - Specification 前台頁面分析拆解後,逐步建立 Specification 後台功能 - 換行跳脫字元 - ASP.NET Web Forms C#

=x= 🌵 建立 Specification Manager - Content Page 後台頁面。


Yachts 前台 Specification 頁面分析 :

https://ithelp.ithome.com.tw/upload/images/20211011/20139487QCDS6VRq25.jpg

📌 從 Yachts 頁面的 Specification 分頁內容裡可以觀察到 :

  1. 紅色區塊 : 每個遊艇的特殊部位標題及多個細項規格,並且有很多組。
  2. 綠色區塊 : 遊艇的特殊部位標題,對應個別的細項規格。
  3. 紫色區塊 : 單個細項規格內容允許換行


Specification Manager 後台頁面介紹 :

🧠 下半區塊 - 新增遊艇共用的特殊部位標題及列表。

https://ithelp.ithome.com.tw/upload/images/20211011/20139487Zq48uA9uPZ.jpg


🧠 左上區塊 - 型號下拉選單 + Layout & Deck Plan Image 組圖上傳。

🧠 右上區塊 - 遊艇部位標題下拉選單 + 部位細項細節添加。

https://ithelp.ithome.com.tw/upload/images/20211011/20139487QAUPLWazOy.jpg

  • 🌵 型號下拉選單用來決定是哪個型號的資料。

  • 🌵 細節標題下拉選單資料來源為上方下半區塊的表格。



Specification Manager 後台實作 :

資料庫資料表設定 :

🧠 細項規格分別對應遊艇型號及遊艇部位標題的 ID,並設定串聯刪除。

https://ithelp.ithome.com.tw/upload/images/20211011/20139487Ljcuf7eT9o.jpg

  • 🌵 Layout & Deck Plan Image 組圖為圖檔檔名 JSON 資料存在 Yachts 資料表內。

  • 🌵 遊艇部位標題因為是共用所以獨立一張表。


左上區塊頁面設計 :

🧠 型號下拉選單設定直接使用精靈設定並將值設為 id,頁面設計參考如下

<h6>Yacht Model :</h6>
<asp:DropDownList ID="DListModel" runat="server" DataSourceID="SqlDataSource1" DataTextField="yachtModel" DataValueField="id" AutoPostBack="True" Width="100%" Font-Bold="True" class="btn btn-outline-primary dropdown-toggle" OnSelectedIndexChanged="DListModel_SelectedIndexChanged"></asp:DropDownList>
<asp:SqlDataSource ID="SqlDataSource1" runat="server" ConnectionString="<%$ ConnectionStrings:TayanaYachtConnectionString %>" SelectCommand="SELECT [yachtModel], [id] FROM [Yachts]"></asp:SqlDataSource>
<hr />
<h6>Layout & Deck Plan Image :</h6>
<h6><span class="badge badge-pill badge-warning text-dark">* The maximum upload size at once is 10MB !</span></h6>
<div class="input-group my-3">
    <asp:FileUpload ID="imageUpload" runat="server" class="btn btn-outline-primary btn-block" AllowMultiple="True" />
    <asp:Button ID="UploadImgBtn" runat="server" Text="Upload" class="btn btn-primary" OnClick="UploadImgBtn_Click"/>
</div>
<hr />
<h6>Group Image List :</h6>
<asp:RadioButtonList ID="RadioButtonListImg" runat="server" class="my-3 mx-auto" AutoPostBack="True" OnSelectedIndexChanged="RadioButtonListImg_SelectedIndexChanged"></asp:RadioButtonList>
<asp:Button ID="DelImageBtn" runat="server" Text="Delete Image" type="button" class="btn btn-danger btn-sm" OnClientClick="return confirm('Are you sure you want to delete?')" Visible="False" OnClick="DelImageBtn_Click"/>


右上區塊頁面設計 :

🧠 部位標題下拉選單設定直接使用精靈設定並將值設為 id,頁面設計參考如下

<h6>Detail Title :</h6>
<asp:DropDownList ID="DListDetailTitle" runat="server" DataSourceID="SqlDataSource2" DataTextField="detailTitleSort" DataValueField="id" AutoPostBack="True" Width="100%" Font-Bold="True" class="btn btn-outline-primary dropdown-toggle" OnSelectedIndexChanged="DListDetailTitle_SelectedIndexChanged"></asp:DropDownList>
<asp:SqlDataSource ID="SqlDataSource2" runat="server" ConnectionString="<%$ ConnectionStrings:TayanaYachtConnectionString %>" SelectCommand="SELECT [detailTitleSort], [id] FROM [DetailTitleSort]"></asp:SqlDataSource>
<hr />
<h6>Add Detail :</h6>
<asp:TextBox ID="TboxDetail" runat="server" type="text" class="form-control" placeholder="Enter detail text" TextMode="MultiLine" Height="100px"></asp:TextBox>
<asp:Button ID="BtnAddDetail" runat="server" Text="Add Detail" class="btn btn-outline-primary btn-block mt-3" OnClick="BtnAddDetail_Click"/>
<hr />
<h6>Detail List :</h6>
<asp:RadioButtonList ID="RadioButtonListDetail" runat="server" class="my-3 mx-auto" AutoPostBack="True" RepeatDirection="Vertical" OnSelectedIndexChanged="RadioButtonListD_SelectedIndexChanged" Width="100%"></asp:RadioButtonList>
<asp:Button ID="BtnDelDetail" runat="server" Text="Delete Detail" type="button" class="btn btn-danger btn-sm" OnClientClick="return confirm('Are you sure you want to delete?')" Visible="False" OnClick="BtnDelDetail_Click"/>


左下區塊頁面設計 :

🧠 結合共用標題輸入功能與新增按鈕的頁面設計參考如下

<h6>Add New Title :</h6>
<div class="input-group mb-3">
  <asp:TextBox ID="TBoxAddNewTitle" runat="server" type="text" class="form-control" placeholder="Enter new title" ></asp:TextBox>
  <div class="input-group-append">
    <asp:Button ID="BtnAddNewTitle" runat="server" Text="Add" class="btn btn-outline-primary btn-block" OnClick="BtnAddNewTitle_Click" />
  </div>
</div>


右下區塊頁面設計 :

🧠 共用標題的 GridView 表格頁面設計參考如下

<asp:GridView ID="GridView1" runat="server" AutoGenerateColumns="False" DataKeyNames="id" DataSourceID="SqlDataSource3" BackColor="White" BorderColor="#CCCCCC" BorderStyle="None" BorderWidth="1px" CellPadding="3" Width="100%" OnRowDeleted="DeltedTitle" OnRowUpdated="UpdatedTitle">
  <Columns>
      <asp:CommandField ButtonType="Button" CancelText="Cancel" DeleteText="Delete" EditText="Edit" HeaderText="Edit" InsertText="Insert" NewText="New" SelectText="Select" ShowEditButton="True"  ControlStyle-CssClass='btn btn-primary btn-block' ControlStyle-BorderColor="#66CCFF" ControlStyle-BorderStyle="Solid" ControlStyle-BorderWidth="1px" ControlStyle-ForeColor="White" >
      <ControlStyle BorderColor="#66CCFF" BorderWidth="1px" BorderStyle="Solid" CssClass="btn btn-primary btn-block" ForeColor="White"></ControlStyle>
      </asp:CommandField>
      <asp:BoundField DataField="id" HeaderText="ID Number" InsertVisible="False" ReadOnly="True" SortExpression="id" >
      <ItemStyle HorizontalAlign="Center" />
      </asp:BoundField>
      <asp:BoundField DataField="detailTitleSort" HeaderText="Detail Title" SortExpression="detailTitleSort" />
      <asp:BoundField DataField="initDate" HeaderText="Creation Date" SortExpression="initDate" ReadOnly="True" InsertVisible="False" />
      <asp:TemplateField HeaderText="Delete" ShowHeader="False">
          <ItemTemplate>
              <asp:LinkButton ID="BtnDeleteTitle" runat="server" CommandName="Delete" Text="Delete" OnClientClick="return confirm('Are you sure you want to delete?')" CausesValidation="False"></asp:LinkButton>
          </ItemTemplate>
          <ControlStyle BorderColor="#66CCFF" BorderStyle="Solid" BorderWidth="1px" CssClass="btn btn-danger btn-block" ForeColor="White" />
      </asp:TemplateField>
  </Columns>
  <FooterStyle BackColor="White" ForeColor="#000066" />
  <HeaderStyle BackColor="#006699" Font-Bold="True" ForeColor="White" />
  <PagerStyle BackColor="White" ForeColor="#000066" HorizontalAlign="Left" />
  <RowStyle ForeColor="#000066" />
  <SelectedRowStyle BackColor="#669999" Font-Bold="True" ForeColor="White" />
  <SortedAscendingCellStyle BackColor="#F1F1F1" />
  <SortedAscendingHeaderStyle BackColor="#007DBB" />
  <SortedDescendingCellStyle BackColor="#CAC9C9" />
  <SortedDescendingHeaderStyle BackColor="#00547E" />
</asp:GridView>
<asp:SqlDataSource ID="SqlDataSource3" runat="server" ConnectionString="<%$ ConnectionStrings:TayanaYachtConnectionString %>" SelectCommand="SELECT * FROM [DetailTitleSort]" DeleteCommand="DELETE FROM [DetailTitleSort] WHERE [id] = @id" UpdateCommand="UPDATE [DetailTitleSort] SET [detailTitleSort] = @detailTitleSort WHERE [id] = @id">
  <DeleteParameters>
      <asp:Parameter Name="id" Type="Int32" />
  </DeleteParameters>
  <UpdateParameters>
      <asp:Parameter Name="detailTitleSort" Type="String" />
      <asp:Parameter Name="id" Type="Int32" />
  </UpdateParameters>
</asp:SqlDataSource>


後置程式碼方法內容 :

1. 在 Page_Load 加入畫面資料讀取及渲染等相關方法如下

//宣告 List<T> 方便用 Add 依序添加資料
private List<ImagePath> savePathList = new List<ImagePath>();
protected void Page_Load(object sender, EventArgs e)
{
    if (!IsPostBack) {
        DListModel.DataBind(); //先取得型號預設選取值
        DListDetailTitle.DataBind(); //先取得細節標題預設選取值
        loadImageList(); //取得 Layout 組圖
        loadDetailList(); //取得標題細節
    }
}


2. 建立 Layout & Deck Plan Image 組圖上傳管理 loadImageList(); 方法邏輯內容如下

#region Group Image List
private void loadImageList()
{
    //依型號取得組圖圖片資料
    string selectModel_id = DListModel.SelectedValue;
    SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
    string sqlLoad = "SELECT layoutDeckPlanImgPathJSON FROM Yachts WHERE id = @selectModel_id";
    SqlCommand command = new SqlCommand(sqlLoad, connection);
    command.Parameters.AddWithValue("@selectModel_id", selectModel_id);
    connection.Open();
    SqlDataReader reader = command.ExecuteReader();
    if (reader.Read()) {
        //將特殊符號解碼
        string loadJson = HttpUtility.HtmlDecode(reader["layoutDeckPlanImgPathJSON"].ToString());
        //反序列化JSON格式
        savePathList = JsonConvert.DeserializeObject<List<ImagePath>>(loadJson);
    }
    connection.Close();

    //渲染圖片選項
    if (savePathList?.Count > 0) {
        foreach (var item in savePathList) {
            ListItem listItem = new ListItem($"<img src='/Tayanahtml/upload/Images/{item.SavePath}' alt='thumbnail' class='img-thumbnail' width='250px'/>", item.SavePath);
            RadioButtonListImg.Items.Add(listItem);
        }
    }

    DelImageBtn.Visible = false; //刪除鈕有選擇圖片時才顯示
}

protected void UploadImgBtn_Click(object sender, EventArgs e)
{
    //有選擇檔案才執行
    if (imageUpload.HasFile) {
        //取得上傳檔案大小 (限制 10MB)
        int fileSize = imageUpload.PostedFile.ContentLength;
        if (fileSize < 1024 * 1000 * 10) {
            //先讀取資料庫原有資料
            loadImageList();
            string savePath = Server.MapPath("~/Tayanahtml/upload/Images/");
            //添加圖檔資料
            foreach (HttpPostedFile postedFile in imageUpload.PostedFiles) {
                //儲存圖片檔案及圖片名稱
                //檢查專案資料夾內有無同名檔案,有同名就加流水號
                DirectoryInfo directoryInfo = new DirectoryInfo(savePath);
                string fileName = postedFile.FileName;
                string[] fileNameArr = fileName.Split('.');
                int count = 0;
                foreach (var fileItem in directoryInfo.GetFiles()) {
                    if (fileItem.Name.Contains(fileNameArr[0])) {
                        count++;
                    }
                }
                fileName = fileNameArr[0] + $"({count + 1})." + fileNameArr[1];
                postedFile.SaveAs(savePath + "temp" + fileName);
                savePathList.Add(new ImagePath { SavePath = fileName });

                //壓縮圖檔
                var image = NetVips.Image.NewFromFile(savePath + "temp" + fileName);
                if (image.Width > 672 * 2) {
                    var newImg = image.Resize(0.5);
                    while (newImg.Width > 672 * 2) {
                        newImg = newImg.Resize(0.5);
                    }
                    newImg.WriteToFile(savePath + fileName);
                }
                else {
                    postedFile.SaveAs(savePath + fileName);
                }
                File.Delete(savePath + "temp" + fileName);
            }

              //依遊艇型號更新資料
              string selectModel_id = DListModel.SelectedValue;
              //將 List<T> 資料轉為 JSON 格式字串
              string savePathJsonStr = JsonConvert.SerializeObject(savePathList);
              SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
              string sql = "UPDATE Yachts SET layoutDeckPlanImgPathJSON = @layoutDeckPlanImgPathJSON WHERE id = @selectModel_id";
              SqlCommand command = new SqlCommand(sql, connection);
              command.Parameters.AddWithValue("@layoutDeckPlanImgPathJSON", savePathJsonStr);
              command.Parameters.AddWithValue("@selectModel_id", selectModel_id);
              connection.Open();
              command.ExecuteNonQuery();
              connection.Close();

              //渲染畫面
              RadioButtonListImg.Items.Clear();
              loadImageList();
          }
          else {
              Response.Write("<script>alert('*The maximum upload size is 10MB!');</script>");
          }

      }
}

// Layout 圖片 JSON 資料
public class ImagePath
{
    public string SavePath { get; set; }
}

protected void RadioButtonListImg_SelectedIndexChanged(object sender, EventArgs e)
{
    DelImageBtn.Visible = true;
}

protected void DelImageBtn_Click(object sender, EventArgs e)
{
    //依選取項目刪除 List<T> 資料
    loadImageList(); //先取得 List<T> 資料
    string selImageStr = RadioButtonListImg.SelectedValue;
    string savePath = Server.MapPath("~/Tayanahtml/upload/Images/");
    File.Delete(savePath + selImageStr);
    for (int i = 0; i < savePathList.Count; i++) {
        if (savePathList[i].SavePath.Equals(selImageStr)) {
            savePathList.RemoveAt(i);
        }
    }

    //將 List<T> 資料轉為 JSON 格式字串
    string savePathJsonStr = JsonConvert.SerializeObject(savePathList);
    string selectModel_id = DListModel.SelectedValue;

    //依選取型號更新圖檔資料
    SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
    string sql = "UPDATE Yachts SET layoutDeckPlanImgPathJSON = @layoutDeckPlanImgPathJSON WHERE id = @selectModel_id";
    SqlCommand command = new SqlCommand(sql, connection);
    command.Parameters.AddWithValue("@layoutDeckPlanImgPathJSON", savePathJsonStr);
    command.Parameters.AddWithValue("@selectModel_id", selectModel_id);
    connection.Open();
    command.ExecuteNonQuery();
    connection.Close();

    //渲染畫面
    RadioButtonListImg.Items.Clear();
    loadImageList();
}
#endregion
  • 🌵 可以發現有#region#endregion,因為前面相簿建立好後就可以整組沿用。


3. 建立取得標題細節內容方法 loadDetailList(); 方法邏輯內容如下

private void loadDetailList()
{
    //取得 Model 代表 id
    string selectModel_id = DListModel.SelectedValue;
    //取得 Title 代表 id
    string selectTitle_id = DListDetailTitle.SelectedValue;

    //依遊艇型號 id 及標題 id 取得 Detail
    SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
    string sql = "SELECT detail FROM Specification WHERE yachtModel_ID = @selectModel_id AND detailTitleSort_ID = @selectTitle_id";
    SqlCommand command = new SqlCommand(sql, connection);
    command.Parameters.AddWithValue("@selectModel_id", selectModel_id);
    command.Parameters.AddWithValue("@selectTitle_id", selectTitle_id);
    connection.Open();
    SqlDataReader reader = command.ExecuteReader();
    while (reader.Read()) {
        string detail = reader["detail"].ToString();
        //將轉成字元實體的編碼轉回 HTML 標籤語法渲染
        ListItem listItem = new ListItem(HttpUtility.HtmlDecode(detail), detail);
        RadioButtonListDetail.Items.Add(listItem);
    }
    connection.Close();

    BtnDelDetail.Visible = false; //刪除鈕有選擇項目時才顯示
}
  • 🌵 讀取資料條件設為遊艇型號 ID + 部位標題 ID。

  • 🌵 這裡需要進行 HtmlDecode 是因為細項資料包含 <br> 換行標籤。



分別建立按鈕事件功能 :

1. 先建立下半區塊的共用的部位標題名稱新增按鈕 Add 的 OnClick 事件邏輯如下

protected void BtnAddNewTitle_Click(object sender, EventArgs e)
{
    //取得輸入標題字串
    string newTitleStr = TBoxAddNewTitle.Text;
    //1.連線資料庫
    SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
    //2.sql語法
    string sql = "INSERT INTO DetailTitleSort (detailTitleSort) VALUES(@newTitleStr)";
    //3.創建command物件
    SqlCommand command = new SqlCommand(sql, connection);
    //4.參數化
    command.Parameters.AddWithValue("@newTitleStr", newTitleStr);
    //5.資料庫連線開啟
    connection.Open();
    //6.執行sql (新增刪除修改)
    command.ExecuteNonQuery(); //單純執行無回傳值
    //7.資料庫關閉
    connection.Close();
    //畫面渲染
    GridView1.DataBind();
    DListDetailTitle.DataBind();
    //下拉選單改為選取最新項
    DListDetailTitle.SelectedIndex = DListDetailTitle.Items.Count - 1;
    //清空輸入欄位
    TBoxAddNewTitle.Text = "";
}


2. 建立共用的部位標題名稱列表 GridView 刪除按鈕 OnRowDeleted 事件刷新如下

protected void DeltedTitle(object sender, GridViewDeletedEventArgs e)
{
    //刷新下拉選單
    DListDetailTitle.DataBind();
    //刷新細節項目
    RadioButtonListDetail.Items.Clear();
    RadioButtonListDetail.DataBind();
    loadDetailList();
}


3. 建立共用的部位標題名稱列表 GridView 更新按鈕 OnRowUpdated 事件刷新如下

protected void UpdatedTitle(object sender, GridViewUpdatedEventArgs e)
{
    //刷新下拉選單
    DListDetailTitle.DataBind();
    //刷新細節項目
    RadioButtonListDetail.Items.Clear();
    RadioButtonListDetail.DataBind();
    loadDetailList();
}


4. 建立部位細項細節新增按鈕 Add Detail 的 OnClick 事件邏輯如下

protected void BtnAddDetail_Click(object sender, EventArgs e)
{
    //取得新增 Detail
    string newDetailStr = TboxDetail.Text;
    //將換行跳脫字元改成 HTML 換行標籤
    newDetailStr = newDetailStr.Replace("\r\n", "<br>");

    //依取得下拉選項的值 (id) 存入 Detail 資料
    string selectModel_id = DListModel.SelectedValue;
    string selectTitle_id = DListDetailTitle.SelectedValue;
    SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
    string sql = "INSERT INTO Specification (yachtModel_ID, detailTitleSort_ID, detail) VALUES (@selectModel_id, @selectTitle_id, @detail)";
    SqlCommand command = new SqlCommand(sql, connection);
    command.Parameters.AddWithValue("@selectModel_id", selectModel_id);
    command.Parameters.AddWithValue("@selectTitle_id", selectTitle_id);
    //特殊符號要轉成字元實體才能正常存進資料庫
    command.Parameters.AddWithValue("@detail", HttpUtility.HtmlEncode(newDetailStr));
    connection.Open();
    command.ExecuteNonQuery(); 
    connection.Close();

    //將改成 HTML 換行標籤資料加入選項渲染畫面
    ListItem listItem = new ListItem(newDetailStr, newDetailStr);
    RadioButtonListDetail.Items.Add(listItem);
    TboxDetail.Text = "";
}
  • 🌵 這裡需要將換行的跳脫字元r\n換成<br>換行標籤。

  • 🌵 想看跳脫字元,可以安裝 Notepad++,將文字貼到記事本用 Notepad++開啟。


5. 建立部位細項細節刪除按鈕 Delete Detail 的 OnClick 事件邏輯如下

protected void BtnDelDetail_Click(object sender, EventArgs e)
{
    //依選取資料刪除 Detail 資料
    string selectModel_id = DListModel.SelectedValue;
    string selectTitle_id = DListDetailTitle.SelectedValue;
    string selectDetailStr = RadioButtonListDetail.SelectedValue;
    SqlConnection connection = new SqlConnection(WebConfigurationManager.ConnectionStrings["TayanaYachtConnectionString"].ConnectionString);
    string sql = "DELETE FROM Specification WHERE yachtModel_ID = @selectModel_id AND detailTitleSort_ID = @selectTitle_id AND detail = @selectDetailStr";
    SqlCommand command = new SqlCommand(sql, connection);
    command.Parameters.AddWithValue("@selectModel_id", selectModel_id);
    command.Parameters.AddWithValue("@selectTitle_id", selectTitle_id);
    command.Parameters.AddWithValue("@selectDetailStr", selectDetailStr);
    connection.Open();
    command.ExecuteNonQuery();
    connection.Close();

    //渲染畫面
    RadioButtonListDetail.Items.Clear();
    loadDetailList();
}


6. 建立部位細項細節選取改變 SelectedIndexChanged 事件邏輯如下

protected void RadioButtonListD_SelectedIndexChanged(object sender, EventArgs e)
{
    //顯示細節項目刪除按鈕
    BtnDelDetail.Visible = true;
}


7. 建立部位標題下拉選單選取改變 SelectedIndexChanged 事件邏輯如下

protected void DListDetailTitle_SelectedIndexChanged(object sender, EventArgs e)
{
    //刷新細節選項
    RadioButtonListDetail.Items.Clear();
    loadDetailList();
}


8. 建立遊艇型號下拉選單選取改變 SelectedIndexChanged 事件邏輯如下

protected void DListModel_SelectedIndexChanged(object sender, EventArgs e)
{
    //刷新全部選項
    RadioButtonListImg.Items.Clear();
    RadioButtonListDetail.Items.Clear();
    loadImageList();
    loadDetailList();
}


9. 模擬頁面後測試功能,Specification Manager 後台完成 ~



本日總結 :

📢 本日後台頁面,因為主要內容為細項規格,但由於細項規格是由兩個下拉選單所決定,在取值跟畫面的更新時,要特別注意是否刷新,另外就是多行文字記得要將跳脫字元,轉換為 HTML 標籤,這樣才能正常呈現換行的效果。

  • 明日將介紹製作前台 Specification - Content Page 後端相關細節。

上一篇
Day 26 - 將 Yacht 後台儲存資料提取後,送至前台渲染 OverView 版面內容區塊 - 前台 Content Page 製作 - ASP.NET Web Forms C#
下一篇
Day 28 - 將 Specification 後台儲存資料提取後,送至前台渲染 Specification 版面內容區塊 - MSSQL 查詢精靈 - ASP.NET Web Forms C#
系列文
ASP.NET Web Forms 入門 - 30天建立遊艇網頁專案後端及後台功能 C#30

尚未有邦友留言

立即登入留言