[搬運] 將 Visual Studio 的代碼片段導出到 VS Code

原文 : A Visual Studio to Visual Studio Code Snippet Converter
作者 : Rick Strahl
譯者 : 張蘅水

導語

和原文作者一樣,水弟我現在也是使用 VS Code 和 Rider 作爲主力開發工具,尤其是 VS Code 可以跨平臺,又有豐富的插件支持和多種編程語言支持。當我從 VS 轉移到以 VS Code 的開發過程中,遇到的最大問題就是代碼提示的不完善(被 VS 和 R# 調教壞了,總想按 tab 鍵)。當我看到原文作者通過從 VS 中導出代碼片段到 VS Code 時,瞬間被吸引到了。 雖然在不知不覺中用了 VS 自帶的代碼片段,但我從來沒有想過要自定義專屬的代碼片段,最多也是製作用於項目的模板(方便創建特定的類型文件)。雖然導出到 Rider 不是很完美,但 Rider 自帶了 R#,對這方面的需求還是很少的。


譯文:

Visual Studio 內置了非常好用的代碼片段工具,多年來我一直在使用它來創建大量有用的擴展片段,使我的日常開發更容易。我有很多 C# 代碼片段,但更多的是用於 HTML 、自定義的 Bootstrap 代碼片段,乃至複雜的 HTML 控件代碼段。偶爾也會用到 JavaScript 、XAML 甚至Powershell 。

在過去的幾年裏,我越來越多地使用其他工具與 Visual Studio 結合使用。特別是Visual Studio CodeJetBrains Rider

在多年的使用 Visual Studio 中,我已經累積了 130 多個代碼片段。每當我在其他開發環境中工作時 ( VS Code 或者 Rider),我真的很需要他們,特別是要寫一大段 HTML的時候,總是要去痛苦地去對應的文檔站點查找。使用代碼片段功能,只需幾次擊鍵就會自動填充我自定義的特定代碼,每天可節省大量時間。

所以我很需要代碼片段功能,有時我打開 Visual Studio 只是爲了找到需要的 HTML 的代碼片段,然後將它們粘貼回 VS Code 或 Rider。雖然繁瑣,但是仍然從文檔網站中複製代碼,然後手動修改代碼來得便捷。如果能在每個對應的開發環境中直接執行代碼片段的功能,那就太好了!

因此,在過去的幾個週末,我做了一個將 Visual Studio 中的代碼片段導出到 VS Code 中的小工具,同時儘量能導出到 JetBrains Rider 。

如果你感興趣,可以在GitHub上找到代碼:

另外說一句,這還只是一個菜鳥項目,並不能保證它支持所有類型的的代碼片段。只是我自己擁有的 137 個代碼片段都完美地移植到 VS Code,並且能夠運行。同時我還可以重新導出, 輕鬆地導出新創建的代碼片段,這樣就可以對比和更新了。

對於 Rider 而言,操作起來更爲複雜,因爲 Rider 有一種瘋狂的機制,可以將模板存儲在內部的單個配置文件中。它還爲 .NET相關的片段 (C#、VB、F#、Razor、ASP.NET )和 基於 Web ( html、css、js 等)的代碼片段使用了多個完全不同的存儲引擎。所以工具目前僅支持一次性導出 .NET 相關代碼段,因爲 Rider 中基於 GUID 的密鑰系統不允許在沒有 GUID 的情況下查找現有代碼段。後面我們再詳細介紹。

代碼片段轉換器

The Snippet Converter

你可以通過藉助 .NET 全局工具 (.NET Global SDK Tool ),使用 Nuget 下載和運行代碼片段轉換器:

dotnet tool install --global dotnet-snippetconverter

如果您不想安裝並只運行該工具,您可以克隆或下載Github倉庫,然後:

cd .\SnippetConverter\
dotnet run

安裝後, 可以通過指向文件夾或單個文件將 Visual Studio 中的代碼片段批量或單獨轉換爲 VS Code 支持的代碼片段。

snippetconverter ~2017 -r -d

或者,您可以像下面這張屏幕截圖那樣指定輸出文件:

image

有幾個選項可用於轉換單個片段和文件夾,使用前綴,遞歸文件夾,輸出生成文件的路徑等:

Syntax:
-------
SnippetConverter <sourceFileOrDirectory> -o <outputFile> 
                 --mode --prefix --recurse --display

Commands:
---------
HELP || /?          This help display           

Options:
--------
sourceFileOrDirectory  Either an individual snippet file, or a source folder
                       Optional special start syntax using `~` to point at User Code Snippets folder:
                       ~      -  Visual Studio User Code Snippets folder (latest version installed)
                       ~2017  -  Visual Studio User Code Snippets folder (specific VS version 2019-2012)                       

-o <outputFile>        Output file where VS Code snippets are generated into (ignored by Rider)   
                       Optional special start syntax using `~` to point at User Code Snippets folder:
                       %APPDATA%\Code\User\snippets\ww-my-codesnippets.code-snippets
                       ~\ww-my-codesnippets.code-snippets
                       if omitted generates `~\exported-visualstudio.code-snippets`
                       
-m,--mode              vs-vscode  (default)
                       vs-rider   experimental - (C#,VB.NET,html only)
-d                     display the target file in Explorer
-r                     if specifying a source folder recurses into child folders
-p,--prefix            snippet prefix generate for all snippets exported
                       Example: `ww-` on a snippet called `ifempty` produces `ww-ifempty`

Examples:
---------
# vs-vscode: Individual Visual Studio Snippet
SnippetConverter "~2017\Visual C#\My Code Snippets\proIPC.snippet" 
                 -o "~\ww-csharp.code-snippets" -d

# vs-vscode: All snippets in a folder user VS Snippets and in recursive child folers
SnippetConverter "~2017\Visual C#\My Code Snippets" -o "~\ww-csharp.code-snippets" -r -d

# vs-vscode: All the user VS Snippets and in recursive child folders
SnippetConverter ~2017\ -o "~\ww-all.code-snippets" -r -d

# vs-vscode: All defaults: Latest version of VS, all snippets export to  ~\visualstudio-export.code-snippets
SnippetConverter ~ -r -d --prefix ww-

# vs-rider: Individual VS Snippet
SnippetConverter "~2017\proIPC.snippet" -m vs-rider -d

# vs-rider: All VS Snippets in a folder
SnippetConverter "~2017\Visual C#\My Code Snippets" -m vs-rider -d

上面的用例應該足夠說明用途了。如果還想要了解更多信息,請接着往下看......

什麼是 VS Code 的代碼片段

如果您不熟悉或不使用代碼片段,那您並不是少數人。它們在 Visual Studio 中幾乎是一個隱藏的功能,這是一個恥辱,因爲它們是非常有用的生產力工具。不幸的是,Visual Studio 沒有任何有用的內置UI來創建這些片段,因此大多數開發人員都沒有充分利用此功能。Visual Studio 只能蹩腳地點擊 ** 工具 - > 代碼片段管理器 ** 菜單 ,除了一個查看器之外,它沒有其他管理功能,僅僅是查看哪些片段是可用的,沒有內置的方法來創建或編輯片段,甚至跳轉到並查看代碼片段。

但是,代碼片段僅僅只是位於用戶目錄的 Documents 文件夾下的 XML 文件。它們非常容易創建和更新,僅僅是原始的 XML 文件,用 VS Code 等文本編輯器去做代碼片段和高亮實在是非常簡單。儘管在 Visual Studio 中有一些提供 UI 操作的劣質插件,但它們往往比原始的代碼片段文件更麻煩。

創建新代碼段的最佳方法是複製現有代碼段並對其進行修改以滿足您的需求。

一般來說,代碼片段位於 (水弟我是直接用 Everything搜索的):

<Documents>\Visual Studio 2017\Code Snippets

每種語言技術都有自己的子文件夾進行分組,但僅僅是文件夾上的區分而已。代碼片段實際上通過 XML中的 Language 屬性確定它們適用的語言。

Visual Studio在此位置附帶了許多代碼段,您可以使用這些代碼段作爲新代碼段的模板進行學習。

<?xml version="1.0" encoding="utf-8"?>
<CodeSnippets xmlns="http://schemas.microsoft.com/VisualStudio/2005/CodeSnippet">
  <CodeSnippet Format="1.0.0">
    <Header>
      <Title>Property with INotifyPropertyChange raised</Title>
      <Description>Control Property with Attributes</Description>
      <SnippetTypes>
        <SnippetType>Expansion</SnippetType>
      </SnippetTypes>
      <Shortcut>proIPC</Shortcut>
    </Header>
    <Snippet>
      <References />
      <Imports />
      <Declarations>
        <Literal Editable="true">
          <ID>name</ID>
          <Type></Type>
          <ToolTip>Property Name</ToolTip>
          <Default>MyProperty</Default>
          <Function></Function>
        </Literal>        
        <Literal Editable="true">
          <ID>type</ID>
          <Type></Type>
          <ToolTip>Property Type</ToolTip>
          <Default>string</Default>
          <Function></Function>
        </Literal>
      </Declarations>
      <Code Language="csharp" Kind="method decl" Delimiter="$"><![CDATA[public $type$ $name$
{
    get { return _$name$; }
    set
    {
        if (value == _$name$) return;
        _$name$ = value;
        OnPropertyChanged(nameof($name$));
    }
}        
private $type$ _$name$;
]]></Code>
    </Snippet>
  </CodeSnippet>
</CodeSnippets>

一旦文件存在或更新了,Visual Studio 無需重啓,就能立即發現並使用。在相關的 (如 C#) 編輯器中,立馬就能看到智能提示中的代碼段:

智能提示

它插入對應的模板並允許您編輯在模板中聲明的 $expr$ 佔位符:

EditSnippetInVS.png

這個 C# 代碼片段示例, 是 VS 中最常見的語言。如您所見, XML文件中的 <Code> 節點定義模板文本,Shortcut 節點定義觸發提示的按鍵,你可以使用在 <Declaration> 節點中使用類似 $txt$ 佔位符來定義參數,同樣的佔位符在多個地方出現也能同步更改。

對於我來說,最有用和最常用的代碼片段時用於插入 HTML 代碼,特別是在定義 Bootstrap 結構或其他很難記住語法的自定義控件。我喜歡在瀏覽文檔網站後創建一個對應的片段,這樣就很方便使用。用多幾次,省下來的時間就賺翻了。花費幾分鐘設置模板可以節省大量時間去輸入重複代碼,尤其是您每次都要浪費時間查找相同的 Bootstrap 代碼時。😃

前綴以及代碼片段包

Visual Studio Marketplace中還有許多可用的代碼片段,您可以安裝使用一整套預設的代碼片段。例如,Bootstrap Snippet 包就內置了一堆以 bs- 爲前綴的代碼片段。

代碼片段包

即使您自己有專屬的代碼片段,最好爲您的代碼片段創建一個前綴,以便您可以在智能提示的海洋中輕鬆地找到它們。我一般使用 ww- 作爲大多數代碼片段的前綴。不幸的是,我自己沒有很好得遵循這個建議,還是有不少代碼片段沒有這麼做。

構建轉換器

因爲我在 Visual Studio 大量使用了代碼片段,所以我做了一個將 Visual Studio 中的代碼片段遷移到 VS Code 中的小工具,同時儘量能遷移到 JetBrains Rider 。

我想可能還有其他人需要用到,所以我把它作爲 .NET Global Tool 控制檯應用程序發佈,以便快速安裝:

dotnet tool install dotnet-snippetconverter

您需要.NET Core 2.1 SDK或更高版本才能運行它。

以下示例命令將代碼片段從 Visual Studio 遷移到 VS Code,稍後再討論遷移到 Rider 的事

安裝後,您可以使用以下命令快速將所有 Visual Studio 代碼片段轉換爲 VS Code 可以接受的格式。

snippetconverter ~ -r -d 

這將轉換最新安裝的 Visual Studio 版本(2017,2019等)中的所有代碼片段,並在位於%appdata%\Code\User\snippets 路徑的 VS Code 的代碼文件夾中創建單獨的 visualstudio-exported.code-snippets 文件夾。

您還可以導出特定 VS 版本的代碼片段:

snippetconverter ~2017 -r -d

或特定文件夾:

snippetconverter "~2017\Visual C#\My Code Snippets" -r -d -o "~\ww-csharp.code-snippets"

其中輸入和輸出文件夾選項中的路徑都是可選的,示例中的~ 是物理片段路徑的佔位符,會指向 Visual Studio(%Documents%\Visual Studio <year>\Code Snippets)和 VS Code(%appdata%\Code\User\Snippets\)中存放代碼片段的基本位置,因此您不必每次都指定完整路徑。您高興的話,也可以使用合格的全路徑。

最後,您還可以導出單個文件:

snippetconverter "~2017\Visual C#\My Code Snippets\proIPC.snippet" -d -o "~\ww-csharp.code-snippets"

如果 VS Code 中已存在該代碼片段,則會覆蓋更新,所以每次重新運行都會更新對應的代碼片段。

運行遷移工具後,在VS Code 中通過前綴或者快捷方式就可以立即使用:

在 Visual Studio 中多個佔位符輸入也是支持的:

同步代碼片段

目前只支持從Visual Studio 單向 遷移到到 VS Code。這意味着如果要保持 Visual Studio 和 VS Code 之間的代碼段同步,最好是在 Visual Studio 中創建代碼片段,然後通過此工具將它們遷移到 VS Code。

VS Code 中的代碼片段

我之前討論過 Visual Studio Snippets 的代碼片段格式,現在讓我們看看 VS Code 中又是什麼樣的。

  • 存放在 %AppData\Code\User\snippets
  • 使用 JSON 格式化
  • 命名爲 lang.json
  • 或者是 <name>.code-snippet 的命名格式
  • 可以包含一個或者多個代碼片段

轉換器之所以導出爲 .code-snippet 文件格式,是因爲使用 lang.json 很容易造成命名衝突。如果默認的 visualstudio-export.code-snippets 不能使用,則使用 -o 來指定輸出文件。

VS Code 代碼片段文件是 JSON,它們看起來像:

{
  "proipc": {
    "prefix": "proipc",
    "scope": "csharp",
    "body": [
      "public ${2:string} ${1:MyProperty}",
      "{",
      "    get { return _${1:MyProperty}; }",
      "    set",
      "    {",
      "        if (value == _${1:MyProperty}) return;",
      "        _${1:MyProperty} = value;",
      "        OnPropertyChanged(nameof(${1:MyProperty}));",
      "    }",
      "}        ",
      "private ${2:string} _${1:MyProperty};",
      ""
    ],
    "description": "Control Property with Attributes"
  },
  "commandbase-object-declaration": {
    "prefix": "commandbase",
    "scope": "csharp",
    "body": [
      "        public CommandBase ${1:CommandName}Command { get; set;  }",
      "",
      "        void Command_${1:CommandName}()",
      "        {",
      "            ${1:CommandName}Command = new CommandBase((parameter, command) =>",
      "            {",
      "              $0",
      "            }, (p, c) => true);",
      "        }",
      ""
    ],
    "description": "Create a CommandBase implementation and declaration"
  } 
}

VS Code 的代碼模板在概念上更簡單,只有模板,前綴和範圍,以及使用字符串插值和約定來確定如何定義佔位符。當然還有其他字段可以填充,但大多數值是可選的,對於從 Visual Studio 轉換過來的代碼片段用不到。

您可以在此處找到Visual Studio代碼段模板文檔:

但是實際上,自己手動創建模板,定義 JSON中的 body 屬性還是有難度的,因爲字符串可能只是一個字符串數組(yuk),也可能是一個可以輸入的類型。好在只是從 Visual Studio 代碼片段轉換,還是很容易生成對應的模板...

咦?導出到 Rider

轉換器某種程度上可以適配到 Rider,但功能有限。因爲Rider 使用令人抓狂的模式來存儲代碼片段,使用 GUID 來標識的 XML 文件。

%USERPROFILE%\.Rider2018.2\config\resharper-host\GlobalSettingsStorage.DotSettings

讓我們看看幾個導出的模板效果:

<root>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Reformat/@EntryValue">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Shortcut/@EntryValue">proipc</s:String>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Scope/=C3001E7C0DA78E4487072B7E050D86C5/@KeyIndexDefined">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Scope/=C3001E7C0DA78E4487072B7E050D86C5/Type/@EntryValue">InCSharpFile</s:String>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Text/@EntryValue">public $type$ $name$
{
    get { return _$name$; }
    set
    {
        if (value == _$name$) return;
        _$name$ = value;
        OnPropertyChanged(nameof($name$));
    }
}        
private $type$ _$name$;
    </s:String>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Field/=name/@KeyIndexDefined">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Field/=name/Expression/@EntryValue">complete()</s:String>
    <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Field/=name/Order/@EntryValue">0</s:Int64>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Field/=type/@KeyIndexDefined">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Field/=type/Expression/@EntryValue">complete()</s:String>
    <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=720E28E0ECFD4CA0B80F10DC82149BD4/Field/=type/Order/@EntryValue">1</s:Int64>

    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/@KeyIndexDefined">True</s:Boolean>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Applicability/=Live/@EntryIndexedValue">True</s:Boolean>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Reformat/@EntryValue">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Shortcut/@EntryValue">seterror</s:String>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/ShortenQualifiedReferences/@EntryValue">True</s:Boolean>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Scope/=C3001E7C0DA78E4487072B7E050D86C5/@KeyIndexDefined">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Scope/=C3001E7C0DA78E4487072B7E050D86C5/Type/@EntryValue">InCSharpFile</s:String>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Text/@EntryValue">      
        public string ErrorMessage {get; set; }

        protected void SetError()
        {
            this.SetError("CLEAR");
        }

        protected void SetError(string message)
        {
            if (message == null || message=="CLEAR")
            {
                this.ErrorMessage = string.Empty;
                return;
            }
            this.ErrorMessage += message;
        }

        protected void SetError(Exception ex, bool checkInner = false)
        {
            if (ex == null)
                this.ErrorMessage = string.Empty;

            Exception e = ex;
            if (checkInner)
                e = e.GetBaseException();

            ErrorMessage = e.Message;
        }
    </s:String>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Field/=busObject/@KeyIndexDefined">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Field/=busObject/Expression/@EntryValue">complete()</s:String>
    <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Field/=busObject/Order/@EntryValue">0</s:Int64>
    <s:Boolean x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Field/=NewLiteral/@KeyIndexDefined">True</s:Boolean>
    <s:String x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Field/=NewLiteral/Expression/@EntryValue">complete()</s:String>
    <s:Int64 x:Key="/Default/PatternsAndTemplates/LiveTemplates/Template/=E88A906D39C741C0A3B8095C5063DADE/Field/=NewLiteral/Order/@EntryValue">1</s:Int64>
</root>    

使用這種瘋狂的格式,無法分辨一組代碼片段的開始和結束的位置。每個代碼片段都有多個 Key,加上 GUID 標識,這使得匹配現有的代碼段來判斷是否存在的目的幾乎不可能實現。

據我所知,沒有找到任何相關鍵值配置的文檔,也沒有如何存儲的文檔。很有可能存在其他存儲選項,但看起來 Rider 並沒有爲代碼片段設置編輯功能。如果您有更好的開發人員文檔,請發表評論。

出於這個原因,Rider 導入是一次性的,如果您導出兩次相同的片段,它們就會翻倍。

爲了測試,我在 Rider 的導出文件中添加了一個標記鍵。然後,在我導入相同的代碼片段時,我會刪除了之前添加的代碼片段。很簡陋,也只是測試階段。如果相關的配置發生了變化,則可能會失效。

此格式僅適用於 Rider 支持的 .NET 特定代碼類型:.NET Languages,Razor 和包含 HTML 模板的 WebForms。其他格式( JavaScript、HTML 、CSS)則使用完全獨立的格式,我沒有精力在實現相關的功能。對於 Rider,我主要關心的是 C# 和 HTML 模板,能正常運行就好了。

只需導出特定文件夾,如 C# 文件夾或 HTML 代碼段,而不是批量導出整個代碼片段文件夾。

SnippetConverter "~2017\Visual C#\My Code Snippets" -m vs-rider -d
SnippetConverter "~2017\Code Snippets\Visual Web Developer\My HTML Snippets" -m vs-rider -d

摘要

正如我前面提到的,所有這些都是非常簡陋,但對於將我全部的代碼片段從 Visual Studio 導出到 Visual Studio Code 是完全夠用的。對於 Rider, C# 和 HTML 代碼片段導出也可以做到,但是其他類型(如 JavaScript、CSS)會出現異常。我只是當作個人工具,如果哪天有足夠的興趣的話,我會接着完善,但是很大程度是需要另外搞一個完全獨立的轉換器。

我沒有測試所有的 Visual Studio 支持的文件類型,即使是VS 內置的代碼片段也可能存在某些問題。保險一點,請不要批量導出所有代碼段,而是單獨導出每種類型的代碼片段。

我還是強烈建議使用前綴,因爲可以更容易地找到你的代碼片段,並保持它們不受影響。

現在這個工具對於我來說已經足夠了,但是我很想知道我是否是少數幾個投身到代碼片段轉換的人之一😃

相關資源

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章