經過Ansi Escape Codes酷炫玩轉命令行!

引言

你是否:html

  • 好奇過命令行裏那些花裏胡哨的進度條是如何實現的?
  • 好奇過Spring Boot爲何可以打印五光十色的日誌?
  • 好奇過Python或者PHP等腳本語言的交互式命令行是如何實現的?
  • 好奇過Vim或者Emacs等在Terminal中的編輯器是怎麼實現的?

若是你曾經好奇過,或者被這段話勾起了你的好奇心,那麼你絕對不能錯過這篇文章!java

背景

經過本文你能夠學到:git

  1. 何爲Ansi Escape Codes以及它們能幹什麼?
  2. Ansi Escape Codes的一些高級應用。
  3. JDK9中Jshell的使用。

事先聲明,本文主要參考:www.lihaoyi.com/post/Buildy…。原文思路清晰,案例生動形象,排版優秀,實爲良心之做。可是因爲原文是用英語書寫且用Python做爲演示,因此本後端小菜雞不要臉地將其翻譯一遍,而且用JDK9的Jshell作演示,方便廣大的Javaer學習。github

本文全部的代碼已經推到Github中,地址爲:github.com/Lovelcp/blo…。強烈建議你們將代碼clone下來跑一下看看效果,加深本身的印象。shell

環境

  • Mac或Linux或者WIn10操做系統。除了Win10以外的Windows系統暫時不支持Ansi Escape Codes。
  • 由於本文采用Jshell做爲演示工具,因此你們須要安裝最近剛正式發佈的JDK9。

OK!一切準備就緒,讓咱們開始吧!後端

富文本

Ansi Escape Codes最基礎的用途就是讓控制檯顯示的文字以富文本的形式輸出,好比設置字體顏色、背景顏色以及各類樣式。讓咱們先來學習如何設置字體顏色,而不用再忍受那枯燥的黑白二色!微信

字體顏色

經過Ansi指令(即Ansi Escape Codes)給控制檯的文字上色是最爲常見的操做。好比:dom

  • 紅色:\u001b[31m
  • 重置:\u001b[0m

絕大部分Ansi Escape Codes都以\u001b開頭。讓咱們經過Java代碼來輸出一段紅色的Hello World編輯器

System.out.print("\u001b[31mHello World");複製代碼

從上圖中,咱們能夠看到,不只Hello World是變成了紅色,並且接下來的jshell>提示符也變成了紅色。其實無論你接下來輸入什麼字符,它們的字體顏色都是紅色。直到你輸入了其餘顏色的Ansi指令,或者輸入了重置指令,字體的顏色纔會再也不是紅色。工具

讓咱們嘗試輸入重置指令來恢復字體的顏色:

System.out.print("\u001b[0m");複製代碼

很好!jshell>提示符恢復爲了白色。因此一個最佳實踐就是,最好在全部改變字體顏色或者樣式的Ansi Escape Codes的最後加上重置指令,以避免形成意想不到的後果。舉個例子:

System.out.print("\u001b[31mHello World\u001b[0m");複製代碼

固然,重置指令能夠被添加在任何位置,好比咱們能夠將其插在Hello World的中間,使得Hello是紅色,可是World是白色:

System.out.print("\u001b[31mHello\u001b[0m World");複製代碼

8色

剛纔咱們介紹了紅色以及重置命令。基本上全部的控制檯都支持如下8種顏色:

  • 黑色:\u001b[30m
  • 紅色:\u001b[31m
  • 綠色:\u001b[32m
  • 黃色:\u001b[33m
  • 藍色:\u001b[34m
  • 洋紅色:\u001b[35m
  • 青色:\u001b[36m
  • 白色:\u001b[37m
  • 重置:\u001b[0m

不如將它們都輸出看一下:

System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");
System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");複製代碼

注意,A由於是黑色因此與控制檯融爲一體了。

16色

大多數的控制檯,除了支持剛纔提到的8色外,還能夠輸出在此之上更加明亮的8種顏色:

  • 亮黑色:\u001b[30;1m
  • 亮紅色:\u001b[31;1m
  • 亮綠色:\u001b[32;1m
  • 亮黃色:\u001b[33;1m
  • 亮藍色:\u001b[34;1m
  • 亮洋紅色:\u001b[35;1m
  • 亮青色:\u001b[36;1m
  • 亮白色:\u001b[37;1m

亮色指令分別在原來對應顏色的指令中間加上;1。咱們將全部的16色在控制檯打印,方便你們進行比對:

System.out.print("\u001b[30m A \u001b[31m B \u001b[32m C \u001b[33m D \u001b[0m");
System.out.print("\u001b[34m E \u001b[35m F \u001b[36m G \u001b[37m H \u001b[0m");
System.out.print("\u001b[30;1m A \u001b[31;1m B \u001b[32;1m C \u001b[33;1m D \u001b[0m");
System.out.print("\u001b[34;1m E \u001b[35;1m F \u001b[36;1m G \u001b[37;1m H \u001b[0m");複製代碼

從圖中咱們能夠清晰地看到,下面的8色比上面的8色顯得更加明亮。好比,原來黑色的A,在黑色的控制檯背景下,幾乎沒法看到,可是一旦經過亮黑色輸出後,對比度變得更高,變得更好辨識了。

256色

最後,除了16色外,某些控制檯支持輸出256色。指令的形式以下:

  • \u001b[38;5;${ID}m

讓咱們輸出256色矩陣:

for (int i = 0; i < 16; i++) {
    for (int j = 0; j < 16; j++) {
        int code = i * 16 + j;
        System.out.printf("\u001b[38;5;%dm%-4d", code, code);
    }
    System.out.println("\u001b[0m");
}複製代碼

關於字體顏色咱們就介紹到這,接下來咱們來介紹背景色。

背景顏色

剛纔所說的字體顏色能夠統稱爲前景色(foreground color)。那麼理所固然,咱們能夠設置文本的背景顏色:

  • 黑色背景:\u001b[40m
  • 紅色背景:\u001b[41m
  • 綠色背景:\u001b[42m
  • 黃色背景:\u001b[43m
  • 藍色背景:\u001b[44m
  • 洋紅色背景:\u001b[45m
  • 青色背景:\u001b[46m
  • 白色背景:\u001b[47m

對應的亮色版本:

  • 亮黑色背景:\u001b[40;1m
  • 亮紅色背景:\u001b[41;1m
  • 亮綠色背景:\u001b[42;1m
  • 亮黃色背景:\u001b[43;1m
  • 亮藍色背景:\u001b[44;1m
  • 亮洋紅色背景:\u001b[45;1m
  • 亮青色背景:\u001b[46;1m
  • 亮白色背景:\u001b[47;1m

首先讓咱們看看16色背景:

System.out.print("\u001b[40m A \u001b[41m B \u001b[42m C \u001b[43m D \u001b[0m");
System.out.print("\u001b[44m A \u001b[45m B \u001b[46m C \u001b[47m D \u001b[0m");
System.out.print("\u001b[40;1m A \u001b[41;1m B \u001b[42;1m C \u001b[43;1m D \u001b[0m");
System.out.print("\u001b[44;1m A \u001b[45;1m B \u001b[46;1m C \u001b[47;1m D \u001b[0m");複製代碼

值得注意的是,亮色背景並非背景顏色顯得更加明亮,而是讓對應的前景色顯得更加明亮。雖然這點有點不太直觀,可是實際表現就是如此。

讓咱們再來試試256背景色,首先指令以下:

  • \u001b[48;5;${ID}m

一樣輸出256色矩陣:

for (int i = 0; i < 16; i++) {
    for (int j = 0; j < 16; j++) {
        int code = i * 16 + j;
        System.out.printf("\u001b[48;5;%dm%-4d", code, code);
    }
    System.out.println("\u001b[0m");
}複製代碼

感受要被亮瞎眼了呢!至此,顏色設置已經介紹完畢,讓咱們接着學習樣式設置。

樣式

除了給文本設置顏色以外,咱們還能夠給文本設置樣式:

  • 粗體:\u001b[1m
  • 下劃線:\u001b[4m
  • 反色:\u001b[7m

樣式分別使用的效果:

System.out.print("\u001b[1m BOLD \u001b[0m\u001b[4m Underline \u001b[0m\u001b[7m Reversed \u001b[0m");複製代碼

或者結合使用:

System.out.print("\u001b[1m\u001b[4m\u001b[7m BOLD Underline Reversed \u001b[0m");複製代碼

甚至還能夠和顏色結合使用:

System.out.print("\u001b[1m\u001b[31m Red Bold \u001b[0m");
System.out.print("\u001b[4m\u001b[44m Blue Background Underline \u001b[0m");複製代碼

是否是很簡單,是否是很酷!學會了這些,咱們已經可以寫出十分酷炫的命令行腳本了。可是若是要實現更復雜的功能(好比進度條),咱們還須要掌握更加牛逼的光標控制指令!

光標控制

Ansi Escape Code裏更加複雜的指令就是光標控制。經過這些指令,咱們能夠自由地移動咱們的光標至屏幕的任何位置。好比在Vim的命令模式下,咱們可使用H/J/K/L這四個鍵實現光標的上下左右移動。

最基礎的光標控制指令以下:

  • 上:\u001b[{n}A
  • 下:\u001b[{n}B
  • 右:\u001b[{n}C
  • 左:\u001b[{n}D

經過光標控制的特性,咱們可以實現大量有趣且酷炫的功能。首先咱們來看看怎麼實現一個進度條。

進度數字顯示

做爲進度條,怎麼能夠沒有進度數字顯示呢?因此咱們先來實現進度條進度數字的刷新:

void loading() throws InterruptedException {
    System.out.println("Loading...");
    for (int i = 1; i <= 100; i++) {
        Thread.sleep(100);
        System.out.print("\u001b[1000D" + i + "%");
    }
}複製代碼

從圖中咱們能夠看到,進度在同一行從1%不停地刷新到100%。爲了進度只在同一行顯示,咱們在代碼中使用了System.out.print而不是System.out.println。在打印每一個進度以前,咱們使用了\u001b[1000D指令,目的是爲了將光標移動到當前行的最左邊也就是行首。而後從新打印新的進度,新的進度數字會覆蓋剛纔的進度數字,循環往復,這就實現了上圖的效果。

PS:\u001b[1000D表示將光標往左移動1000個字符。這裏的1000表示光標移動的距離,只要你可以確保光標可以移動到最左端,隨便設置多少好比設置2000均可以。

爲了方便你們更加輕鬆地理解光標的移動過程,讓咱們放慢進度條刷新的頻率:

void loading() throws InterruptedException {
    System.out.println("Loading...");
    for (int i = 1; i <= 100; i++) {
        System.out.print("\u001b[1000D");
        Thread.sleep(1000);
        System.out.print(i + "%");
        Thread.sleep(1000);
    }
}複製代碼

如今咱們能夠清晰地看到:

  1. 從左到右打印進度,光標移至行尾。
  2. 光標移至行首,原進度數字還在。
  3. 從左到右打印新進度,新的數字會覆蓋老的數字。光標移至行尾。
  4. 循環往復。

Ascii進度條

好了,咱們如今已經知道如何經過Ansi Escape Code實現進度數字的顯示和刷新,剩下的就是實現進度的讀條。廢話很少說,咱們直接上代碼和效果圖:

void loading() throws InterruptedException {
    System.out.println("Loading...");
    for (int i = 1; i <= 100; i++) {
        int width = i / 4;
        String left = "[" + String.join("", Collections.nCopies(width, "#"));
        String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";
        System.out.print("\u001b[1000D" + left + right);
        Thread.sleep(100);
    }
}複製代碼

由上圖咱們能夠看到,每次循環事後,讀條就會增長。原理和數字的刷新同樣,相信你們閱讀代碼就能理解,這裏就再也不贅述。

讓咱們來點更酷的吧!利用Ansi的光標向上以及向下的指令,咱們還能夠同時打印出多條進度條:

void loading(int count) throws InterruptedException {
    System.out.print(String.join("", Collections.nCopies(count, "\n"))); // 初始化進度條所佔的空間
    List<Integer> allProgress = new ArrayList<>(Collections.nCopies(count, 0));
    while (true) {
        Thread.sleep(10);

        // 隨機選擇一個進度條,增長進度
        List<Integer> unfinished = new LinkedList<>();
        for (int i = 0; i < allProgress.size(); i++) {
            if (allProgress.get(i) < 100) {
                unfinished.add(i);
            }
        }
        if (unfinished.isEmpty()) {
            break;
        }
        int index = unfinished.get(new Random().nextInt(unfinished.size()));
        allProgress.set(index, allProgress.get(index) + 1); // 進度+1

        // 繪製進度條
        System.out.print("\u001b[1000D"); // 移動到最左邊
        System.out.print("\u001b[" + count + "A"); // 往上移動
        for (Integer progress : allProgress) {
            int width = progress / 4;
            String left = "[" + String.join("", Collections.nCopies(width, "#"));
            String right = String.join("", Collections.nCopies(25 - width, " ")) + "]";
            System.out.println(left + right);
        }
    }
}複製代碼

在上述代碼中:

  • 咱們首先執行System.out.print(String.join("", Collections.nCopies(count, "\n")));打印出多個空行,這能夠保證咱們有足夠的空間來打印進度條。
  • 接下來咱們隨機增長一個進度條的進度,而且打印出全部進度條。
  • 最後咱們調用向上指令,將光標移回到最上方,繼續下一個循環,直到全部進度條都到達100%。

實際效果以下:

效果然是太棒啦!剩下將讀條和數字結合在一塊兒的工做就交給讀者啦。學會了這招,當你下次若是要作一個在命令行下載文件的小工具,這時候這些知識就派上用場啦!

製做命令行

最後,最爲酷炫的事情莫過於利用Ansi Escape Codes實現一個個性化的命令行(Command-Line)。咱們日常使用的Bash以及一些解釋型語言好比Python、Ruby等都有本身的REPL命令行。接下來,讓咱們揭開他們神祕的面紗,瞭解他們背後實現的原理。

PS:因爲在Jshell中,方向鍵、後退鍵等一些特殊鍵有本身的做用,因此接下來沒法經過Jshell演示。須要本身手動進行編譯運行代碼才能看到實際效果。

一個最簡單的命令行

首先,咱們來實現一個最簡單的命令行,簡單到只實現下面兩種功能:

  • 當用戶輸入一個可打印的字符時,好比abcd等,則在控制檯顯示。
  • 當用戶輸入回車時,另起一行,輸出剛纔用戶輸入的全部字符,而後再另起一行,繼續接受用戶的輸入。

那麼這個最簡單的命令行的實現代碼會長這樣:

import java.io.IOException;

public class CommandLine {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 設置命令行爲raw模式,不然會自動解析方向鍵以及後退鍵,而且直到按下回車read方法纔會返回
        String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
        Runtime.getRuntime()
               .exec(cmd)
               .waitFor();
        while (true) {
            String input = "";
            while (true) {
                char ch = (char) System.in.read();
                if (ch == 3) {
                    // CTRL-C
                    return;
                }
                else if (ch >= 32 && ch <= 126) {
                    // 普通字符
                    input += ch;
                }
                else if (ch == 10 || ch == 13) {
                    // 回車
                    System.out.println();
                    System.out.print("\u001b[1000D");
                    System.out.println("echo: " + input);
                    input = "";
                }

                System.out.print("\u001b[1000D"); // 首先將光標移動到最左側
                System.out.print(input); // 從新輸出input
                System.out.flush();
            }
        }
    }
}複製代碼

好的,讓咱們來講明一下代碼中的關鍵點:

  1. 首先最關鍵的是咱們須要將咱們的命令行設置爲raw模式,這能夠避免JVM幫咱們解析方向鍵,回退鍵以及對用戶輸入進行緩衝。你們能夠試一下不設置raw模式而後看一下效果,就能夠理解我說的話了。

  2. 經過System.in.read()方法獲取用戶輸入,而後對其ascii值進行分析。

  3. 若是發現用戶輸入的是回車的話,咱們這時須要打印剛纔用戶輸入的全部字符。可是咱們須要注意,因爲設置了raw模式,不移動光標直接打印的話,光標的位置不會移到行首,以下圖:

    因此這裏須要再次調用System.out.print("\u001b[1000D");將光標移到行首。

好了,讓咱們來看一下效果吧:

成功了!可是有個缺點,那就是命令行並無解析方向鍵,反而以[D[A[C[B輸出(見動圖)。這樣咱們只能一直日後面寫而沒法作到將光標移動到前面實現插入的效果。因此接下來就讓咱們給命令行加上解析方向鍵的功能吧!

光標移動

簡單起見,咱們僅需實現按下方向鍵的左右兩鍵時能控制光標左右移動。左右兩鍵對應的ascii碼分別爲27 91 6827 91 67。因此咱們只要在代碼中加上對這兩串ascii碼的解析便可:

import java.io.IOException;

public class CommandLine {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 設置命令行爲raw模式,不然會自動解析方向鍵以及後退鍵,而且直到按下回車read方法纔會返回
        String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
        Runtime.getRuntime()
               .exec(cmd)
               .waitFor();
        while (true) {
            String input = "";
            int index = 0;
            while (true) {
                char ch = (char) System.in.read();
                if (ch == 3) {
                    // CTRL-C
                    return;
                }
                else if (ch >= 32 && ch <= 126) {
                    // 普通字符
                    input = input.substring(0, index) + ch + input.substring(index, input.length());
                    index++;
                }
                else if (ch == 10 || ch == 13) {
                    // 回車
                    System.out.println();
                    System.out.print("\u001b[1000D");
                    System.out.println("echo: " + input);
                    input = "";
                    index = 0;
                }
                else if (ch == 27) {
                    // 左右方向鍵
                    char next1 = (char) System.in.read();
                    char next2 = (char) System.in.read();
                    if (next1 == 91) {
                        if (next2 == 68) {
                            // 左方向鍵
                            index = Math.max(0, index - 1);
                        }
                        else if (next2 == 67) {
                            // 右方向鍵
                            index = Math.min(input.length(), index + 1);
                        }
                    }
                }

                System.out.print("\u001b[1000D"); // 將光標移動到最左側
                System.out.print(input);
                System.out.print("\u001b[1000D"); // 再次將光標移動到最左側
                if (index > 0) {
                    System.out.print("\u001b[" + index + "C"); // 將光標移動到index處
                }
                System.out.flush();
            }
        }
    }
}複製代碼

效果以下:

It works!可是這個命令行還不支持刪除,咱們沒法經過Backspace鍵刪去敲錯的字符。有了剛纔的經驗,實現刪除功能也十分簡單!

刪除

照着剛纔的思路,咱們可能會在處理用戶輸入的地方,加上以下的代碼:

else if (ch == 127) {
    // 刪除
    if (index > 0) {
        input = input.substring(0, index - 1) + input.substring(index, input.length());
        index -= 1;
    }
}複製代碼

可是這段代碼存在點問題,讓咱們看一下效果圖:

從圖中咱們能夠看到:

  • 第一次,當我輸入了11234566,而後不停地按下刪除鍵,想要刪掉34566,可是隻有光標在後退,字符並無被刪掉。而後我再按下回車鍵,經過echo的字符串咱們發現刪除實際上已經成功,只是控制檯在顯示的時候出了點問題。
  • 第二次,我先輸入123456,而後按下刪除鍵,刪掉456,光標退到3。而後我再繼續不斷地輸入0,咱們發現隨着0覆蓋了原來的456顯示的位置。

因此刪除的確產生了效果,可是咱們要解決被刪除的字符還在顯示的這個bug。爲了實現刪除的效果,咱們先來學習一下Ansi裏的刪除指令:

  • 清除屏幕:\u001b[{n}J爲指令。
    • n=0:清除光標到屏幕末尾的全部字符。
    • n=1:清除屏幕開頭到光標的全部字符。
    • n=2:清除整個屏幕的字符。
  • 清除行:\u001b[{n}K爲指令。
    • n=0:清除光標到當前行末全部的字符。
    • n=1:清除當前行到光標的全部字符。
    • n=2:清除當前行。

因此咱們的思路就是無論用戶輸入了什麼,咱們先利用System.out.print("\u001b[0K");清除當前行,此時光標回到了行首,這時再輸出正確的字符。完整代碼以下:

import java.io.IOException;

public class CommandLine {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 設置命令行爲raw模式,不然會自動解析方向鍵以及後退鍵,而且直到按下回車read方法纔會返回
        String[] cmd = { "/bin/sh", "-c", "stty raw </dev/tty" };
        Runtime.getRuntime()
               .exec(cmd)
               .waitFor();
        while (true) {
            String input = "";
            int index = 0;
            while (true) {
                char ch = (char) System.in.read();
                if (ch == 3) {
                    // CTRL-C
                    return;
                }
                else if (ch >= 32 && ch <= 126) {
                    // 普通字符
                    input = input.substring(0, index) + ch + input.substring(index, input.length());
                    index++;
                }
                else if (ch == 10 || ch == 13) {
                    // 回車
                    System.out.println();
                    System.out.print("\u001b[1000D");
                    System.out.println("echo: " + input);
                    input = "";
                    index = 0;
                }
                else if (ch == 27) {
                    // 左右方向鍵
                    char next1 = (char) System.in.read();
                    char next2 = (char) System.in.read();
                    if (next1 == 91) {
                        if (next2 == 68) {
                            // 左方向鍵
                            index = Math.max(0, index - 1);
                        }
                        else if (next2 == 67) {
                            // 右方向鍵
                            index = Math.min(input.length(), index + 1);
                        }
                    }
                }
                else if (ch == 127) {
                    // 刪除
                    if (index > 0) {
                        input = input.substring(0, index - 1) + input.substring(index, input.length());
                        index -= 1;
                    }
                }
                System.out.print("\u001b[1000D"); // 將光標移動到最左側
                System.out.print("\u001b[0K"); // 清除光標所在行的所有內容
                System.out.print(input);
                System.out.print("\u001b[1000D"); // 再次將光標移動到最左側
                if (index > 0) {
                    System.out.print("\u001b[" + index + "C"); // 將光標移動到index處
                }
                System.out.flush();
            }
        }
    }
}複製代碼

讓咱們來看一下效果:

OK,成功了!那麼至此爲止,咱們已經實現了一個最小化的命令行,它可以支持用戶進行輸入,而且可以左右移動光標以及刪除他不想要的字符。可是它還缺失了不少命令行的特性,好比不支持解析像Alt-fCtrl-r等常見的快捷鍵,也不支持輸入Unicode字符等等。可是,只要咱們掌握了剛纔的知識,這些特性均可以方便地實現。好比,咱們能夠給剛纔的命令行加上簡單的語法高亮——末尾若是有多餘的空格則將這些空格標紅,效果以下:

實現的代碼也很簡單,能夠參考Github項目裏的CustomisedCommandLine類。

最後,再介紹一下其餘一些有用的Ansi Escape Codes:

  • 光標向上移動:\u001b[{n}A將光標向上移動n格。
  • 光標向下移動:\u001b[{n}B將光標向下移動n格。
  • 光標向右移動:\u001b[{n}C將光標向右移動n格。
  • 光標向左移動:\u001b[{n}D將光標向左移動n格。
  • 光標按行向下移動:\u001b[{n}E將光標向下移動n行而且將光標移至行首。
  • 光標按行向上移動:\u001b[{n}F將光標向上移動n行而且將光標移至行首。
  • 設置光標所在列:\u001b[{n}G將光標移至第n列(行數與當前所在行保持一致)。
  • 設置光標所在位置:\u001b[{n};{m}H將光標移至第nm列,座標原點從屏幕左上角開始。
  • 保存光標當前所在位置:\u001b[{s}
  • 讀取光標上一次保存的位置:\u001b[{u}

光標按行移動的測試代碼參考Github項目裏的LineMovementTest類,設置光標位置的測試代碼參考Github項目裏的PositionTest類。若是想了解更多的Ansi Escape Codes請參考維基百科

總結

經過本文的學習,我相信你們已經掌握瞭如何經過Ansi Escape Codes實現控制檯的富文本輸出以及控制檯光標的自定義移動。那麼文章一開始的那4個好奇,你們心中是否已經有了答案了呢?最後,仍是強烈建議英文好的同窗去閱讀一下原文:www.lihaoyi.com/post/Buildy…。祝你們週末愉快!

本文首發於kissyu.org/2017/11/25/…
歡迎評論和轉載!
訂閱下方微信公衆號,獲取第一手資訊!

相關文章
相關標籤/搜索