【LibUIDK界面庫系列文章】使用RichEdit製作QQ聊天記錄控件



作者:劉樹偉
前段時間使用LibUIDK界面庫爲客戶定製一個IM軟件界面,需要把聊天記錄以氣泡形式展示出來。目前,國內IM軟件顯示聊天氣泡主流的分爲RichEdit和網頁兩派。不論從穩定性、兼容性、性能、體積、用戶體驗方面,RichEdit都完勝網頁。不過,RichEdit開發氣泡效果,難度非常大,非一般公司和個人可以解決,現在國內編程都走快餐經濟,很少有人能靜下心來,花幾個月時間研究這個。RichEdit顯示氣泡,原理極其簡單,但具體開發的時候,坑實在是太多。所以,我寫下本文,讓大家有個參考,少走彎路。


RichEdit分有句柄和無句柄兩種模式,它們原理一樣,本文以帶句柄的RichEdit講解。並且爲了兼容性,我們也不假定RichEdit的版本,也就是說,本文的方法,適配從xp到win10各版本的RichEdit,這樣,用戶在使用本文方法製作出的聊天控件發佈的時候,不需要附帶RichEdit的DLL。



零、題外話:


製作IM軟件的聊天記錄控件,目前有兩個陣營:一種是使用傳統RichEdit插入OLE控件實現;另一種是使用CEF實現。這裏,我們只討論RichEdit插入OLE的方案。


 


OLECOMActiveX的關係:


OLE技術以COM規範爲基礎,OLE技術只是COM規範的一個應用而已。COM是組件程序與客戶程序之間進行通信的一種規範。這裏有件有趣的事情,雖然OLE是基於COM的,但OLE卻早於COM,是不是很費解?其實是這樣的:在早期,沒有COM,只有OLE,在OLE發展的過程中,產生了COM,或者說,把OLE的規範,抽象出來產生了OLE。這就類似於我們準備寫個按鈕控件類CMyButton,隨着CMyButton的發展,我們發現,可以把CMyButton某些東西提取出來,生成一個控件基類CMyControl,因爲以後我們可能要寫個從CMyControl派生的CMyComboBox。所以,雖然CMyButton是從CMyControl派生的,但CMyButtonCMyControl出現的早,也是可能的(真實的情況是:OLE 1.0時,組件程序與客戶程序之間進行通信是使用的DDE,即動態數據交換機制,到了OLE 2.0時,放棄了DDE,採用了COM)。實際上,COMOLE抽象出來後,確實出現了一系列以COM爲基礎的新技術,它們統稱爲ActiveX技術。


 


組件、接口、方法


COM世界中,C++的類,在COM中叫接口,而C++的成員函數,叫接口方法。C++類所在的DLL,叫組件。所以,一個組件可以包含多個接口。


 


下面所講的方法,無特別說明,都指帶句柄的CRichEditCtrl


 


術語:


cp: char position:字符位置索引


格式:無特別說明,這裏的格式僅指段落的左縮進、對齊方式、行間距。


一、RichEdit用到的COM接口


    1. IRichEditOle

      IRichEditOleRichEdit中一個非常重要的COM接口,大部分其它接口都與它有直接或間接的關係,很多接口由IRichEditOle查詢得到,往RichEdit中插入OLE對象,也是通過這個接口。

       

      IRichEditOle *pRichEditOle = GetIRichEditOle();

       

    2. ITextServices

      RichEdit有兩種封裝,一種是基於HWND的傳統方式,另一種是無句柄的windowless方式,但內部實現,只有一種。那就是基於ITextServices的實現。

      創建windowlessRichedit,其實就是創建Text Services。可由API CreateTextServices完成:

      ITextServices *m_pserv; // 類成員

       

      CComPtr<IUnknown>pUnk = NULL;

      if(CreateTextServices != NULL)

      {

      HRESULThr = CreateTextServices(NULL, this, &pUnk);

      }

       

               HRESULT hr =pUnk->QueryInterface(IID_ITextServices, (void **)&m_pserv);

       

      對於基於HWNDRichEdit,在創建好之後,可以通過IRichEditOle接口查詢得到:

      HRESULT hr =pRichEditOle->QueryInterface(IID_ITextServices, (void**)&m_pTextServices);

       

      由於ITextServices接口很常用,可以保存成類成員,不必每次獲取。

       

    3. ITextDocument

      ITextDocument接口一般不單獨使用。常使用它的Range方法,得到一段範圍的ITextRange接口。

       

      通過IRichEditOle接口,可以查詢到ITextDocument:

      HRESULT hr =pRichEditOle->QueryInterface(guid, (void **)&m_pTextDocument);

       

    4. ITextRange

      在設置IM控件文本對齊、縮進等格式時,要用到ITextRange。需要注意的是,RangeSelection是不同的概念。Range並不會選擇文本,它只是標記了一個範圍;而Selection是真實的選中了一段文本,文本背景Highlight加亮顯示。Selection是通過SetSel或鼠標框選。而Range是通過ITextDocument::Range方法。我們在代碼中,對段落進行格式化,建議少使用SetSel,而應該用Range代替。比如用戶使用鼠標,選中了IM中的一段內容,準備複製到剪貼板,但如果使用SetSel選中了其它一個段落準備設置它的段落格式,就會把用戶選中的內容清掉,而Range就不會。

       

      通過ITextRange::GetPoint方法,還可以得到指定cp的座標(四個角的座標)。但經過測試,只有在RichEdit可視範圍內的cp,可以得到座標。但高版本的RichEdit,支持額外的GetPoint參數(vs2008 MSDN中,並沒有額外參數的介紹,在MSDN官網,可以查到),可以得到不可視範圍內cp的座標。還有一種方法,可以得到任意cp的左上角座標,那就是PosFromChar(GetCharPos,兩者內部實現一樣)。爲了兼容性,建議採用PosFromChar來得到cp座標。下面是得到cp座標的詳細介紹:


 


RichEdit中指定cp的座標,有幾種方法:


 1.使用PosFromCharGetCharPos(兩者內部都是調用EM_POSFROMCHAR),可以得到指定cp左上角座標。通過指定cp的下一個cp和下一行cp,可以得到指定cp的完整座標(注意,當整個RichEdit就一個字符時,仍然可以通過PosFromChar(1),得到這個字符右上角座標,雖然並沒有cp1的字符)


 2.通過使用FormatRange,可以模擬輸出,從而得到所需要的高度座標(寬度自己指定)


 3.通過ITextRange::GetPoint,可以得到選中範圍完整座標。它與方法一在微軟RichEdit內部都是由CDisplayML::PointFromTp實現。不過使用這個接口有個注意事項,在VS2008sp1本地MSDN介紹中,ITextRange::GetPoint第一個參數只有tomStarttomEndTA_TOPTA_BASELINETA_BOTTOMTA_LEFTTA_CENTERTA_RIGHT的組合。但這樣只能得到可視範圍內cp的座標。cp滾動到可見範圍外後,得到的是0


 不過。在線的MSDN上,提供了一些額外的參數,可以得到可視範圍外的座標,所有參數如下:


 None                                   0             無選項。


 IncludeInset                     1             將左側和頂部嵌入添加到矩形的左側和頂部座標,並從座標的右側和底部除去右側和底部嵌入。


 Start                                   32             文本範圍的開始位置。


 ClientCoordinates         256           返回工作區座標而不是屏幕座標。


 AllowOffClient                  512        允許工作區之外的點。


 Transform                         1024      使用宿主應用程序提供的世界轉換的變換座標。


 NoHorizontalScroll        65536    水平滾動已禁用。


 NoVerticalScroll               262144 垂直滾動已禁用。


 所以ITextRange::GetPoint的第一個參數,只要或上512,就可以得到可視區外的座標了。不過。這些新的屬性,不支持舊版本的RichEdit.


 


 


    1. ITextPara

      這個接口,是用來設置指定Range的格式的,包括對齊、縮進、增加行高等。在製作IM控件時,在氣泡模式中,對於自己發送的消息,要顯示到RichEdit右側。這個效果,就可以通過對指定Range,調用ITextPara接口,來設置左縮進完成。

      ITextPara接口,由ITextRange:: GetPara返回:

      CComPtr<ITextPara> pTextPara = NULL;

      pTextRange->GetPara(&pTextPara);

      pTextPara->SetIndents(0,GetXFPPTSFromPixel(50), 0);

               pTextRange->SetPara(pTextPara);

       

      這樣,就會把Range所在段落的左縮進,設置爲50像素。

       

      在使用ITextPara接口時,常常會碰到一個新的座標單位:floating-point points(簡稱FPPTS,1 FPPTS等於20分之1緹(twips,而緹與像素的換算關係爲:

      X緹等於像素乘以1440再除以DPI值。

      所以,FPPTS與像素的換算關係爲:

      X FPPTS等於像素乘以72再除以DPI值。


二、防閃爍


創建一個RichEdit控件,在父窗口縮放帶着RichEdit一起縮放時,會明顯感覺到內容在閃爍。我們採用常規的防閃爍方案(clip children, WM_ERASEBKGROUD, SetRedraw等)似乎作用不大。由於RichEdit內部是通過ITextServices::TxDraw繪製,所以,我們把RichEdit設置爲透明(加上WS_EX_TRANSPARENT擴展風格),然後直接調用TxDraw,把內容畫到父窗口上。


 


這裏有很多注意事項。下面分別講述:


  • 要處理WM_PAINT消息,直接return 0


LRESULT CSkinRichEditCtrl::WindowProc(UINTmessage, WPARAM wParam, LPARAM lParam)


{


        if(message == WM_PAINT)


        {


                  //CPaintDC dc(this);不能刪除,否則會導致不停的收到WM_PAINT消息


                  CPaintDCdc(this);


                  return0;


        }


        elseif (message == WM_ERASEBKGND)


        {


                  returnTRUE;


        }


 


        returnCRichEditCtrl::WindowProc(message, wParam, lParam);


}


 


  • TxDraw並不是把WM_PAINT的內容顯示到另外的DC上。TxDrawWM_PAINT是獨立的。RichEdit本身在WM_PAINT中,通過TxDraw繪製到RichEdit上。但你仍然可以自己調用TxDraw,指定與RichEdit不同的寬度,繪製到另外的DC上,兩份繪製,呈現的結果是不同的,由於兩份繪製傳入的寬度不同,會導致顯示出來的折行位置不同。並且,即使是自己調用TxDraw,,似乎也會引起滾動位置的重新計算。所以,爲了讓自己調用的TxDraw顯示的內容,與RichEdit內部顯示的內容完全吻合。TxDraw的座標必須傳客戶區座標(一定不要傳非客戶區座標)。


 


  • RichEdit設置段落格式,一定要在WM_SIZE中處理,不能爲了性能考慮,放到繪製的時候。也不能放到WM_VSCROLL中。這是因爲,調用pTextRange->SetPara之類的方法設置文本格式,會導致重新計算滾動信息。比如重新設置了行高、設置了縮進等。重新計算滾動信息後,又會導致刷新,這樣,在滾動RichEdit時,可能導致按行滾動,而不是按像素滾動。也可能上下跳動。所以,對於RichEdit格式的設置,全部放到插入數據時和WM_SIZE中。


三、氣泡


當代的IM聊天控件,大多支持氣泡模式。很多人可能認爲,氣泡、頭像應該都是OLE對象。也想過氣泡、頭像這些元素,可能是畫到父窗口上的,作爲RichEdit背景透上來的,然後處理RichEdit縮放、滾動等消息,讓它們與消息內容連動。大部分人想過之後,會馬上下意識的Pass掉方案2,直觀認爲這種技巧性的方案,不是解決問題的常規方案。然後沿着方案1這個思路往下研究,會發現根本走不通。其實最終解決問題的方案,正是方案2


 


在製作IM軟件聊天記錄時,對方發送的消息,永遠顯示到RichEdit左側,且左對齊。而自己發送的消息,分兩種情況:


1.RichEdit寬度小於某個值時,自己發送的消息,顯示到右側。


2.RichEdit寬度大於某個值時,自己發送的消息,顯示到左側,與別人發送的消息一樣顯示。


不論哪種情況,消息內容,在氣泡內都是左對齊的(觀察一條消息包含多個段落)。由於消息內容永遠是左對齊,當自己發送的消息顯示到右側時,可以通過設置消息段落格式的左縮進,讓消息顯示到右側。


 


爲了在左右兩邊顯示頭像,需要把文字往中間擠,這可以通過調用SetRectRichEdit指定顯示區域,爲左右兩側留出一些空間。如下圖:




 


我們爲RichEdit左右各設置40像素的邊距,這樣,文本就被限制在上圖左右紅色垂直線之間顯示了。因爲圖像不是RichEdit的內容,而是畫到RichEdit父窗口背景上的,所以,這樣設置,並不影響頭像畫到左右縮進線之外。


 


不過這種方式,消息發送者和消息內容,是對齊的,觀察QQIM控件,是不對齊的,所以爲了以後靈活佈局,採用按段落,逐條消息分開設置的方案。


 


在第一章中提到,設置RichEdit的段落格式,一定要放到插入數據時和WM_SIZE中。而獲取氣泡、頭像的座標,放到顯示的時候,這時候,拿到的座標纔是最精確的。在獲取氣泡、頭像座標時,一定千萬不要再設置段落格式


 


3.1內容佈局


所謂的內容佈局,僅指對消息發送者設置對齊方式和對消息內容進行左縮進設置兩項。


 


對於別人發的消息,永遠在RichEdit左側,只需要對消息發送者和消息內容,進行左縮進設置,以留出頭像空間。


 


對於自己發的消息,根據是否是氣泡模式,且RichEdit的寬度是否大於某個設置的值,會有顯示在左側和右側兩種可能。當顯示在左側時,與別人發的消息同等對待;當顯示在右側時,需要把消息發送者設置成右對齊、爲消息內容設置左縮進,從而把消息擠到右側。


插入時初始佈局


爲了使消息初始顯示的時候,就有正確的佈局,需要在插入的時候,就指定好消息的格式。


 


對內容佈局,主要是通過ITextPara這個接口。在第一章中有詳細介紹。


 


爲了佈局內容,及計算頭像氣泡的座標,需要在插入消息的時候,記錄消息插入RichEdit後的一些字符的位置cp。首先,要記錄整個消息(從發送者開始)的起始cp,這個cp是相對於整個RichEdit的絕對m_nMsgStartCp。然後記錄消息正文開始相對於m_nMsgStartCpcp: m_nMsgContentStartCp。最近記錄消息結束位置相對m_nMsgStartCp的結束cp:m_nMsgEndCp。整個消息,只有起始cp是絕對cp。這是因爲,IM控件有個功能,就是滾動到最上端後,有個“查看更多消息”。點擊之後,會在原有消息之前,再插入一批新的消息,這時候,原有的消息中記錄的cp,都要更新。採用上述方法,只更新起始cp即可。


運行時動態佈局


在之後運行過程中,當RichEdit尺寸變化後,消息需重新佈局。爲了減小各種莫名其妙的問題產生,我們只在WM_SIZE中,對消息進行重新佈局。


 


對於別人發送的消息,由於永遠在左側顯示,在插入的時候,已指定了格式,所以,在插入消息後的任何時間,都不需要再重新設置。所以,所謂的運行時動態佈局,僅佈局自己發送的消息。


 


由於在RichEdit尺寸發生變化時,不可見的消息內容,也可能發生折行位置cp的改變。所以,需要對所有自己發送的消息,進行格式重置。這需要進行兩次循環。


第一次,把所有自己發送的消息的左縮進,重置成消息在左側顯示時的左縮進值。第二次循環,根據當前的設置,計算自己發送的消息佔用的寬度,結合RichEdit寬度,計算出消息的左縮進值。


3.2頭像、氣泡座標獲取


下面講解得到氣泡座標的算法:


一條消息,可能包含一個或多個段落(以\n分隔),每個段落的文本,也可能很長,也可能很短。比如某條消息,只有一個很短的段落,一行即可顯示完整。這樣,我們通過《RichEdit雜項.txt》中的方法,很容易計算這段文本的RECT。而如果另一條消息的文本很長,就需要換行顯示,如果這條消息還包含多個段落,計算起來就更加複雜了。如果這條消息我們自己發送的,需要顯示到RichEdit右側,那就難上加難了。


我們一步步分析,把複雜問題分解成簡單可解的問題。


別人發送的消息,永遠顯示在左側,自己發送的消息,根據RichEdit的寬度,可能顯示在左側或右側。當顯示到左側時,與別人發送的消息同樣處理。所以,我們分消息顯示到左側和右側,分別處理。


消息顯示到左側


我們如何判斷所有段落都能在一行顯示(即有N個段落,顯示N行)完,還是某個或某些段落有換行呢?在插入消息的時候,我們可以查找\n的數量,這個數量+1,就是消息的段落數。把這個段落數,記錄到消息結構體CIMMsgm_nMsgParagraphCount成員中。在插入消息的時候,也把消息文本起止位置的cp(char position)分別記錄到CIMMsgm_nMsgContentStartCpm_nMsgEndCp成員中。這樣,通過LineFromChar就可以得到消息起止字符所在的行的索引。通過行索引差值與m_nMsgParagraphCount比較,就可以知道,是否有某個(些)段落有換行。


 


某個段落有換行


當某個段落有換行時,情況變得比較容易了。因爲既然有換行,說明某一行的文字寬度是佔滿RichEdit文字可顯示寬度的,即RichEdit的寬度減左右縮進。這樣,我們就得到了氣泡座標的leftright值:


rcQiPao.left =rcInset.left; // rcInsetRichEdit的文本縮進


rcQiPao.right =rcClient.right – rcInset.right; // rcClientRichEdit的座標


我們只需要得到消息佔用的高度即可。通過GetCharPos,可以得到消息第一個字符的左上角座標,我們只用它的上座標:


rcQiPao.top =ptFirstChar.y; // ptFirstChar是通過GetCharPos得到的第一個字符的座標


得到最後一個字符的bottom座標,方法有些不同。可以通過ITextRange來實現,詳見《RichEdit雜項.txt》。


  1. 通過最後一個字符的cp,得到其所在的行

  2. 通過LineIndex得到此行第一個字符的cp

  3. 通過LineLength得到此行的文本長度,從而得到此行最後一個字符的cp

  4. 通過ITextDocument::Range,選中最後一行,返回ITextRange

  5. 通過ITextRange::GetPoint(tomEnd| TA_BOTTOM | TA_RIGHT, &lXEnd, &lYEnd);得到最後一個字符的相對於屏幕的右下座標CPoint(lXEnd,lYEnd)

  6. 通過調用RichEditScreenToClient,得到相對於RichEdit的座標。

    這樣,氣泡的座標就得到了。

     


當所有段落都能在一行顯示(即有N個段落,顯示N行)


當所有段落都能在一行顯示時,要分別計算每行的rect。然後計算所有rect最大外切矩形。這個外切矩形,就是氣泡的座標。


求每行的rect方法與上面某個段落有換行裏介紹的一樣。即上面的步驟b-f。可以把它封裝成一個接口:


int CWLRichEditCtrl::GetLineRect(intnLineIndex, LPRECT lprcLine, LINE_RECT eLineRect)


消息顯示到右側


某個段落有換行


當某個(些)段落有換行時,情況和顯示到左側完全相同。


當所有段落都能在一行顯示(即有N個段落,顯示N行)


  1. 先按顯示到左側處理,求到氣泡的Rect

  2. 然後用rcClient.Widht() – rcInset.left – rcInset.right – rcQiPao.Width(),求得氣泡需要的左縮進量。

     

     

     

     

     

     


雜記:


  1. 當自己發送的消息,需要顯示到RichEdit右側,在計算氣泡座標時。要先設置這條消息的段落格式,左縮進設置爲0,計算所需要的寬度和高度。如果所需要的寬度,小於RichEdit的寬度,則重新設置段落的左縮進爲“RichEdit的寬度減需要的寬度,達到氣泡右側顯示的目的。這裏有個注意事項:設置段落格式,可以通過SetParaFormat。前提是要使用SetSel,選中所要設置的段落,但這會導致兩個問題:第一個是如果你已經用鼠標選中了RichEdit中的某部分內容,在調用SetSel時,會把你選中的狀態清掉。二是在RichEdit窗口左右縮放時,會導致嚴重的閃爍問題,並且會看到你發的消息,在RichEdit中,左右來回顯示(這是因爲你一會設置左縮進爲0,一會又設置成另一個值)。解決這兩個問題的方法是:不使用SetParaFormat,而是使用ITextPara

    CComPtr<ITextDocument> pTextDocument =NULL;

    IMRE_CALL_FUN_RETURN(pTextDocument,GetITextDocument());

     

    ITextRange *pTextRange = NULL;

    pTextDocument->Range(pMsg->GetMsgContentStart(),pMsg->GetMsgEnd(), &pTextRange);

     

    #ifdef _DEBUG

    BSTR bstr;

    pTextRange->GetText(&bstr);

    #endif // _DEBUG

     

    CComPtr<ITextPara> pTextPara = NULL;

    pTextRange->GetPara(&pTextPara);

    pTextPara->SetIndents(0, 0, 0);

    pTextRange->SetPara(pTextPara);

     

    這裏,SetIndents的單位是floating-pointpoints,類型爲float,可以爲正值,也可以爲負值。Floating-point points單位與緹的換算公式爲:

    #defineTWIPS_TO_FPPTS(x) (((float)(x)) * (float)0.05)

    而像素與緹的換算公式爲:

    intLibUIDK::GetXTwipsFromPixel(int nPixel)

    {

          HDC hDCN =::GetDC(::GetDesktopWindow());

          int nXDPI =GetDeviceCaps(hDCN, LOGPIXELSX);

          ::ReleaseDC(::GetDesktopWindow(),hDCN);

     

          if (nXDPI == 0)

          {

                   nXDPI = 96;

          }

     

          int nRet = MulDiv(nPixel,1440, nXDPI);

     

          return nRet;

    }

    所以,像素與floating-point points的換算公式爲:

    // 1 FPPTS equalto 1/20 twips

    floatLibUIDK::GetXFPPTSFromPixel(int nPixel)

    {

          HDC hDCN =::GetDC(::GetDesktopWindow());

          int nXDPI =GetDeviceCaps(hDCN, LOGPIXELSX);

          ::ReleaseDC(::GetDesktopWindow(),hDCN);

     

          if (nXDPI == 0)

          {

                   nXDPI = 96;

          }

     

          float nRet =(float)MulDiv(nPixel, 72, nXDPI); // 72 = 1440 * 0.05

     

          return nRet;

    }

     

    只要設置SetIndents的中間參數,就可以設置左縮進。

     

     


四、RichEdit中插入格式化文本


插入格式化文本,有兩個重要的結構體:CHARFORMA2TPARAFORMAT2。前者設置字符格式,後者設置段落格式。


typedef struct _charformat2 {

   UINT cbSize; //必須被初始化爲sizeof(CHARFORMA2T)

   DWORD dwMask;

   DWORD dwEffects;

   LONG yHeight;

   LONG yOffset;

   COLORREF crTextColor;

   BYTE bCharSet;

   BYTE bPitchAndFamily;

   TCHAR szFaceName[LF_FACESIZE];

   WORD wWeight;

   SHORT sSpacing;

   COLORREF crBackColor;

   LCID lcid;

   DWORD dwReserved;

   SHORT sStyle;

   WORD wKerning;

   BYTE bUnderlineType;

   BYTE bAnimation;

   BYTE bRevAuthor;

   BYTE bReserved1;


} CHARFORMAT2;


 


CHARFORMAT2中的yHeightyOffsetsSpacing的單位是twips()1緹等於1/1440英寸。而1英寸等於DPI像素。DPI的值隨操作系統的設置不同而不同,默認是96。它的值可以通過GetDeviceCaps得到,分水平DPI值和垂直DPI值:


        HDChDCN = ::GetDC(::GetDesktopWindow());

        intnXDPI = GetDeviceCaps(hDCN, LOGPIXELSX);

        intnYDPI = GetDeviceCaps(hDCN, LOGPIXELSY);

        ::ReleaseDC(::GetDesktopWindow(),hDCN);


所以給定像素,得到對應多少緹,可以封裝成下面的接口:


// 1 twips equal to 1/1440 inch.


int LibUIDK::GetXTwipsFromPixel(int nPixel)

{

        HDChDCN = ::GetDC(::GetDesktopWindow());

        intnXDPI = GetDeviceCaps(hDCN, LOGPIXELSX);

        ::ReleaseDC(::GetDesktopWindow(),hDCN);

        intnRet = nPixel * 1440 / nXDPI;


        returnnRet;

}



int LibUIDK::GetYTwipsFromPixel(int nPixel)

{

        HDChDCN = ::GetDC(::GetDesktopWindow());

        intnYDPI = GetDeviceCaps(hDCN, LOGPIXELSY);

        ::ReleaseDC(::GetDesktopWindow(),hDCN);

        intnRet = nPixel * 1440 / nYDPI;


        returnnRet;

}



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