TradingView--最專業的走勢圖表,收下吧,也許你會用到

前言

最近在作交易所項目裏的K線圖,得些經驗,與你們分享。
代碼居多,流量預警!!!!
點贊 收藏 不迷路。css

技術選型

  • echarts
    • 基於canvas繪製,種類齊全的可視化圖表庫。
    • 官方地址: echarts.baidu.com/
  • highcharts
  • tradingview
  • 優缺點
    • hightcharts: 前些日子有仔細研究過 hightcharts www.fota.com/option/。 發現svg中的dom操做,以及定製化內容更好實現,但幾乎都須要手動實現的這個特性在開發週期短的壓迫下屈服了。上面的這個項目在慢慢摸索下也作了小三個月的樣子,但仍是頗有成就感的。
    • echarts: echarts的官方案例不少,常常在作一些後臺管理系統,展示數據時候會用到,方便,易用,使用者也足夠多,搜索引擎雞本可以解決你的任何問題。但對一些在圖上劃線,等操做,就顯得略微疲軟。不夠能知足需求。
    • tradingview: 只要進入官網,就可見其專業性,他徹底就是爲了專業交易兒打造的,您只須要想裏面填充數據就能夠了,甚至在一些經常使用的交易內容上,可使用tradingview本身的數據推送。
  • 小記
    • 因此,專業的交易圖表,就交給專業的庫來作吧
    • 手動狗頭~~~~(∩_∩)

準備工做

  • 申請帳號(key)
    • 在官網註冊後會有郵件提示的,一步一步跟着作就能夠了,這裏就不作贅述了。
  • 環境搭建
    • 我使用的是本身搭建的React+webpack4腳手架,你也可使用原生JS,或者你喜歡的任何框架(後面貼出來的代碼都是在React環境下的)。
    • 從官方下載代碼庫
  • 瞭解websocket通信協議
    • 發送請求
    • 接收數據
  • 大綱
  • 一位大神的Demo github.com/tenggouwa/t…

準備開始吧html


建立

  • page
    |--kLine // k線內容文件夾
    |--|--api // 須要使用的方法
    |--|--|--datafees.js // 定義了一些公用方法
    |--|--|--dataUpdater.js // 更新時調用的內容
    |--|--|--socket.js // websocket方法
    |--|--index.js // 本身代碼開發
    |--|--index.scss // 樣式開發
    複製代碼
  • datafees.js加入以下代碼react

    /**
         * JS API
         */
        import React from 'react'
        import DataUpdater from './dataUpdater'
        
        class datafeeds extends React.Component {
        /**
         * JS API
         * @param {*Object} react react實例
         */
            constructor(self) {
                super(self)
                this.self = self
                this.barsUpdater = new DataUpdater(this)
                this.defaultConfiguration = this.defaultConfiguration.bind(this)
            }
            /**
             * @param {*Function} callback  回調函數
             * `onReady` should return result asynchronously.
             */
            onReady(callback) {
                // console.log('=============onReady running')
                return new Promise((resolve) => {
                    let configuration = this.defaultConfiguration()
                    if (this.self.getConfig) {
                        configuration = Object.assign(this.defaultConfiguration(), this.self.getConfig())
                    }
                    resolve(configuration)
                }).then(data => callback(data))
            }
            /**
             * @param {*Object} symbolInfo  商品信息對象
             * @param {*String} resolution  分辨率
             * @param {*Number} rangeStartDate  時間戳、最左邊請求的K線時間
             * @param {*Number} rangeEndDate  時間戳、最右邊請求的K線時間
             * @param {*Function} onDataCallback  回調函數
             * @param {*Function} onErrorCallback  回調函數
             */
            getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onDataCallback) {
                const onLoadedCallback = (data) => {
                    data && data.length ? onDataCallback(data, { noData: false }) : onDataCallback([], { noData: true })
                }
                this.self.getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
                /* eslint-enable */
            }
            /**
             * @param {*String} symbolName  商品名稱或ticker
             * @param {*Function} onSymbolResolvedCallback 成功回調
             * @param {*Function} onResolveErrorCallback   失敗回調
             * `resolveSymbol` should return result asynchronously.
             */
            resolveSymbol(symbolName, onSymbolResolvedCallback, onResolveErrorCallback) {
                return new Promise((resolve) => {
                    // reject
                    let symbolInfoName
                    if (this.self.symbolName) {
                        symbolInfoName = this.self.symbolName
                    }
                    let symbolInfo = {
                        name: symbolInfoName,
                        ticker: symbolInfoName,
                        pricescale: 10000,
                    }
                    const { points } = this.props.props
                    const array = points.filter(item => item.name === symbolInfoName)
                    if (array) {
                        symbolInfo.pricescale = 10 ** array[0].pricePrecision
                    }
                    symbolInfo = Object.assign(this.defaultConfiguration(), symbolInfo)
                    resolve(symbolInfo)
                }).then(data => onSymbolResolvedCallback(data)).catch(err => onResolveErrorCallback(err))
            }
            /**
             * 訂閱K線數據。圖表庫將調用onRealtimeCallback方法以更新實時數據
             * @param {*Object} symbolInfo 商品信息
             * @param {*String} resolution 分辨率
             * @param {*Function} onRealtimeCallback 回調函數
             * @param {*String} subscriberUID 監聽的惟一標識符
             * @param {*Function} onResetCacheNeededCallback (從1.7開始): 將在bars數據發生變化時執行
             */
            subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
                this.barsUpdater.subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback)
            }
            /**
             * 取消訂閱K線數據
             * @param {*String} subscriberUID 監聽的惟一標識符
             */
            unsubscribeBars(subscriberUID) {
                this.barsUpdater.unsubscribeBars(subscriberUID)
            }
            /**
             * 默認配置
             */
            defaultConfiguration = () => {
                const object = {
                    session: '24x7',
                    timezone: 'Asia/Shanghai',
                    minmov: 1,
                    minmov2: 0,
                    description: 'www.coinoak.com',
                    pointvalue: 1,
                    volume_precision: 4,
                    hide_side_toolbar: false,
                    fractional: false,
                    supports_search: false,
                    supports_group_request: false,
                    supported_resolutions: ['1', '15', '60', '1D'],
                    supports_marks: false,
                    supports_timescale_marks: false,
                    supports_time: true,
                    has_intraday: true,
                    intraday_multipliers: ['1', '15', '60', '1D'],
                }
                return object
            }
        }
        
        export default datafeeds
    
    複製代碼
  • dataUpdater加入以下代碼webpack

    class dataUpdater {
        constructor(datafeeds) {
            this.subscribers = {}
            this.requestsPending = 0
            this.historyProvider = datafeeds
        }
        subscribeBars(symbolInfonwq, resolutionInfo, newDataCallback, listenerGuid) {
            this.subscribers[listenerGuid] = {
                lastBarTime: null,
                listener: newDataCallback,
                resolution: resolutionInfo,
                symbolInfo: symbolInfonwq
            }
        }
        unsubscribeBars(listenerGuid) {
            delete this.subscribers[listenerGuid]
        }
        updateData() {
            if (this.requestsPending) return
            this.requestsPending = 0
            for (let listenerGuid in this.subscribers) {
                this.requestsPending++
                this.updateDataForSubscriber(listenerGuid).then(() => this.requestsPending--).catch(() => this.requestsPending--)
            }
        }
        updateDataForSubscriber(listenerGuid) {
            return new Promise(function (resolve, reject) {
              var subscriptionRecord = this.subscribers[listenerGuid];
              var rangeEndTime = parseInt((Date.now() / 1000).toString());
              var rangeStartTime = rangeEndTime - this.periodLengthSeconds(subscriptionRecord.resolution, 10);
              this.historyProvider.getBars(subscriptionRecord.symbolInfo, subscriptionRecord.resolution, rangeStartTime, rangeEndTime, function (bars) {
                this.onSubscriberDataReceived(listenerGuid, bars);
                resolve();
              }, function () {
                reject();
              });
            });
        }
        onSubscriberDataReceived(listenerGuid, bars) {
            if (!this.subscribers.hasOwnProperty(listenerGuid)) return
            if (!bars.length) return
            const lastBar = bars[bars.length - 1]
            const subscriptionRecord = this.subscribers[listenerGuid]
            if (subscriptionRecord.lastBarTime !== null && lastBar.time < subscriptionRecord.lastBarTime) return
            const isNewBar = subscriptionRecord.lastBarTime !== null && lastBar.time > subscriptionRecord.lastBarTime
            if (isNewBar) {
                if (bars.length < 2) {
                    throw new Error('Not enough bars in history for proper pulse update. Need at least 2.');
                }
                const previousBar = bars[bars.length - 2]
                subscriptionRecord.listener(previousBar)
            }
            subscriptionRecord.lastBarTime = lastBar.time
            console.log(lastBar)
            subscriptionRecord.listener(lastBar)
        }
        periodLengthSeconds =(resolution, requiredPeriodsCount) => {
            let daysCount = 0
            if (resolution === 'D' || resolution === '1D') {
                daysCount = requiredPeriodsCount
            } else if (resolution === 'M' || resolution === '1M') {
                daysCount = 31 * requiredPeriodsCount
            } else if (resolution === 'W' || resolution === '1W') {
                daysCount = 7 * requiredPeriodsCount
            } else {
                daysCount = requiredPeriodsCount * parseInt(resolution) / (24 * 60)
            }
            return daysCount * 24 * 60 * 60
        }
    }
    export default dataUpdater
    
    複製代碼
  • socket.js加入以下代碼(也可使用本身的websocket模塊)git

    class socket {
            constructor(options) {
                this.heartBeatTimer = null
                this.options = options
                this.messageMap = {}
                this.connState = 0
                this.socket = null
            }
            doOpen() {
                if (this.connState) return
                this.connState = 1
                this.afterOpenEmit = []
                const BrowserWebSocket = window.WebSocket || window.MozWebSocket
                const socketArg = new BrowserWebSocket(this.url)
                socketArg.binaryType = 'arraybuffer'
                socketArg.onopen = evt => this.onOpen(evt)
                socketArg.onclose = evt => this.onClose(evt)
                socketArg.onmessage = evt => this.onMessage(evt.data)
                // socketArg.onerror = err => this.onError(err)
                this.socket = socketArg
            }
            onOpen() {
                this.connState = 2
                this.heartBeatTimer = setInterval(this.checkHeartbeat.bind(this), 20000)
                this.onReceiver({ Event: 'open' })
            }
            checkOpen() {
                return this.connState === 2
            }
            onClose() {
                this.connState = 0
                if (this.connState) {
                    this.onReceiver({ Event: 'close' })
                }
            }
            send(data) {
                this.socket.send(JSON.stringify(data))
            }
            emit(data) {
                return new Promise((resolve) => {
                    this.socket.send(JSON.stringify(data))
                    this.on('message', (dataArray) => {
                        resolve(dataArray)
                    })
                })
            }
            onMessage(message) {
                try {
                    const data = JSON.parse(message)
                    this.onReceiver({ Event: 'message', Data: data })
                } catch (err) {
                    // console.error(' >> Data parsing error:', err)
                }
            }
            checkHeartbeat() {
                const data = {
                    cmd: 'ping',
                    args: [Date.parse(new Date())]
                }
                this.send(data)
            }
            onReceiver(data) {
                const callback = this.messageMap[data.Event]
                if (callback) callback(data.Data)
            }
            on(name, handler) {
                this.messageMap[name] = handler
            }
            doClose() {
                this.socket.close()
            }
            destroy() {
                if (this.heartBeatTimer) {
                    clearInterval(this.heartBeatTimer)
                    this.heartBeatTimer = null
                }
                this.doClose()
                this.messageMap = {}
                this.connState = 0
                this.socket = null
            }
        }
        export default socket
    複製代碼

初始化圖表

  • 能夠同時請求websocket數據。
  • 新建init函數,並在onready/mounted/mounted等時候去調用(代碼的含義在註釋裏,我儘可能寫的詳細一點)
  • init = () => {
        var resolution = this.interval; // interval/resolution 當前時間維度
        var chartType = (localStorage.getItem('tradingview.chartType') || '1')*1;
        var locale = this.props.lang; // 當前語言
        var skin = this.props.theme; // 當前皮膚(黑/白)
        if (!this.widgets) {
            this.widgets = new TradingView.widget({ // 建立圖表
                autosize: true, // 自動大小(適配,寬高百分百)
                symbol:this.symbolName, // 商品名稱
                interval: resolution,
                container_id: 'tv_chart_container', // 容器ID
                datafeed: this.datafeeds, // 配置,即api文件夾下的datafees.js文件
                library_path: '/static/TradingView/charting_library/', // 圖表庫的位置,我這邊放在了static,由於已經壓縮過
                enabled_features: ['left_toolbar'],
                timezone: 'Asia/Shanghai', // 圖表的內置時區(經常使用UTC+8)
                // timezone: 'Etc/UTC', // 時區爲(UTC+0)
                custom_css_url: './css/tradingview_'+skin+'.css', //樣式位置
                locale, // 語言
                debug: false,
                disabled_features: [ // 在默認狀況下禁用的功能
                    'edit_buttons_in_legend',
                    'timeframes_toolbar',
                    'go_to_date',
                    'volume_force_overlay',
                    'header_symbol_search',
                    'header_undo_redo',
                    'caption_button_text_if_possible',
                    'header_resolutions',
                    'header_interval_dialog_button',
                    'show_interval_dialog_on_key_press',
                    'header_compare',
                    'header_screenshot',
                    'header_saveload'
                ],
                overrides: this.getOverrides(skin), // 定製皮膚,默認無蓋默認皮膚
                studies_overrides: this.getStudiesOverrides(skin) // 定製皮膚,默認無蓋默認皮膚
            })
            var thats = this.widgets;
            // 當圖表內容準備就緒時觸發
            thats.onChartReady(function() {
                createButton(buttons);
            })
            var buttons = [
                {title:'1m',resolution:'1',chartType:1},
                {title:'15m',resolution:'15',chartType:1},
                {title:'1h',resolution:'60',chartType:1},
                {title:'1D',resolution:'1D',chartType:1},
            ];
            // 建立按鈕(這裏是時間維度),並對選中的按鈕加上樣式
            function createButton(buttons){
                for(var i = 0; i < buttons.length; i++){
                    (function(button){
                        let defaultClass =
                        thats.createButton()
                        .attr('title', button.title).addClass(`mydate ${button.resolution === '15' ? 'active' : ''}`)
                        .text(button.title)
                        .on('click', function(e) {
                            if (this.className.indexOf('active')> -1){// 已經選中
                                return false
                            }
                            let curent =e.currentTarget.parentNode.parentElement.childNodes
                            for(let index of curent) {
                                if (index.className.indexOf('my-group')> -1 && index.childNodes[0].className.indexOf('active')> -1) {
                                    index.childNodes[0].className = index.childNodes[0].className.replace('active', '')
                                }
                            }
                            this.className = `${this.className} active`
                            thats.chart().setResolution(button.resolution, function onReadyCallback() {})
                        }).parent().addClass('my-group'+(button.resolution == paramary.resolution ? ' active':''))
                    })(buttons[i])
                }
            }
        }
    }
    複製代碼

請求數據

  • 新建initMessage函數---在須要去獲取數據的時候,調取initMessage。
  • initMessage = (symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) => {
            let that = this
            //保留當前回調
            that.cacheData['onLoadedCallback'] = onLoadedCallback;
            //獲取須要請求的數據數目
            let limit = that.initLimit(resolution, rangeStartDate, rangeEndDate)
            //若是當前時間節點已經改變,中止上一個時間節點的訂閱,修改時間節點值
            if(that.interval !== resolution){
                that.interval = resolution
                paramary.endTime = parseInt((Date.now() / 1000), 10)
            } else {
                paramary.endTime = rangeEndDate
            }
            //獲取當前時間段的數據,在onMessage中執行回調onLoadedCallback
            paramary.limit = limit
            paramary.resolution = resolution
            let param
            // 分批次獲取歷史(這邊區分了歷史記錄分批加載的請求)
            if (isHistory.isRequestHistory) {
                param = {
                    // 獲取歷史記錄時的參數(與所有主要區別是時間戳)
                }
            } else {
                param = {
                    // 獲取所有記錄時的參數
                }
            }
            this.getklinelist(param)
        }
    複製代碼
  • 在請求歷史數據時,因爲條件不知足,會一直請求後臺接口,因此須要加上 函數節流
    • 在lodash這個庫裏面是有節流的方法的
    • 首先引入節流函數----import throttle from 'lodash/throttle'
    • 使用很是簡單,只要在函數前面套一層-----this.initMessage = throttle(this.initMessage, 1000);
      • throttle()函數裏面,第一個參數是須要截留的函數,第二個爲節流時間。

收到數據,渲染圖表

  • 能夠在接收數據的地方調用socket.on('message', this.onMessage(res.data))
  • onMessage函數,是爲渲染數據進入圖表內容
  • // 渲染數據
    onMessage = (data) => { // 經過參數將數據傳遞進來
        let thats = this
        if (data === []) {
            return
        }
        // 引入新數據的緣由,是我想要加入緩存,這樣在大數據量的時候,切換時間維度能夠大大的優化請求時間
        let newdata = []
        if(data && data.data) {
            newdata = data.data
        }
        const ticker = `${thats.symbolName}-${thats.interval}`
        // 第一次所有更新(增量數據是一條一條推送,等待所有數據拿到後再請求)
        if (newdata && newdata.length >= 1 && !thats.cacheData[ticker] && data.firstHisFlag === 'true') {
            // websocket返回的值,數組表明時間段歷史數據,不是增量
            var tickerstate = `${ticker}state`
            // 若是沒有緩存數據,則直接填充,發起訂閱
            if(!thats.cacheData[ticker]){
                thats.cacheData[ticker] = newdata
                thats.subscribe()   // 這裏去訂閱增量數據!!!!!!!
            }
            // 新數據即當前時間段須要的數據,直接餵給圖表插件
            // 若是出現歷史數據不見的時候,就說明 onLoadedCallback 是undefined
            if(thats.cacheData['onLoadedCallback']){ // ToDo
                thats.cacheData['onLoadedCallback'](newdata)
            }
            //請求完成,設置狀態爲false
            thats.cacheData[tickerstate] = false
            //記錄當前緩存時間,即數組最後一位的時間
            thats.lastTime = thats.cacheData[ticker][thats.cacheData[ticker].length - 1].time
        }
        // 更新歷史數據 (這邊是添加了滑動按需加載,後面我會說明)
        if(newdata && newdata.length > 1 && data.firstHisFlag === 'true' && paramary.klineId === data.klineId && paramary.resolution === data.resolution && thats.cacheData[ticker] && isHistory.isRequestHistory) {
            thats.cacheData[ticker] = newdata.concat(thats.cacheData[ticker])
            isHistory.isRequestHistory = false
        }
        // 單條數據()
        if (newdata && newdata.length === 1 && data.hasOwnProperty('firstHisFlag') === false && data.klineId === paramary.klineId  && paramary.resolution === data.resolution) {
            //構造增量更新數據
            let barsData = newdata[0]
            //若是增量更新數據的時間大於緩存時間,並且緩存有數據,數據長度大於0
            if (barsData.time > thats.lastTime && thats.cacheData[ticker] && thats.cacheData[ticker].length) {
                //增量更新的數據直接加入緩存數組
                thats.cacheData[ticker].push(barsData)
                //修改緩存時間
                thats.lastTime = barsData.time
            } else if(barsData.time == thats.lastTime && thats.cacheData[ticker] && thats.cacheData[ticker].length){
                //若是增量更新的時間等於緩存時間,即在當前時間顆粒內產生了新數據,更新當前數據
                thats.cacheData[ticker][thats.cacheData[ticker].length - 1] = barsData
            }
            // 通知圖表插件,能夠開始增量更新的渲染了
            thats.datafeeds.barsUpdater.updateData()
        }
    }
    複製代碼

邏輯中心===>getbars

  • 新建getbars函數(該函數會在圖表有變化時自動調用)
  • getBars = (symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback) => {
            const timeInterval = resolution // 當前時間維度
            this.interval = resolution
            let ticker = `${this.symbolName}-${resolution}`
            let tickerload = `${ticker}load`
            var tickerstate = `${ticker}state`
            this.cacheData[tickerload] = rangeStartDate
            //若是緩存沒有數據,並且未發出請求,記錄當前節點開始時間
            // 切換時間或幣種
            if(!this.cacheData[ticker] && !this.cacheData[tickerstate]){
                this.cacheData[tickerload] = rangeStartDate
                //發起請求,從websocket獲取當前時間段的數據
                this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
                //設置狀態爲true
                this.cacheData[tickerstate] = true
            }
            if(!this.cacheData[tickerload] || this.cacheData[tickerload] > rangeStartDate){
                //若是緩存有數據,可是沒有當前時間段的數據,更新當前節點時間
                this.cacheData[tickerload] = rangeStartDate;
                //發起請求,從websocket獲取當前時間段的數據
                this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback);
                //設置狀態爲true
                this.cacheData[tickerstate] = !0;
            }
            //正在從websocket獲取數據,禁止一切操做
            if(this.cacheData[tickerstate]){
                return false
            }
            // 拿到歷史數據,更新圖表
            if (this.cacheData[ticker] && this.cacheData[ticker].length > 1) {
                this.isLoading = false
                onLoadedCallback(this.cacheData[ticker])
            } else {
                let self = this
                this.getBarTimer = setTimeout(function() {
                    self.getBars(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
                }, 10)
            }
            // 這裏很重要,畫圈圈----實現了往前滑動,分次請求歷史數據,減少壓力
            // 根據可視窗口區域最左側的時間節點與歷史數據第一個點的時間比較判斷,是否須要請求歷史數據
            if (this.cacheData[ticker] && this.cacheData[ticker].length > 1 && this.widgets && this.widgets._ready && !isHistory.isRequestHistory && timeInterval !== '1D') {
                const rangeTime = this.widgets.chart().getVisibleRange()  // 可視區域時間值(秒) {from, to}
                const dataTime = this.cacheData[ticker][0].time // 返回數據第一條時間
                if (rangeTime.from * 1000 <= dataTime + 28800000) { // true 不用請求 false 須要請求後續
                    isHistory.endTime = dataTime / 1000
                    isHistory.isRequestHistory = true
                    // 發起歷史數據的請求
                    this.initMessage(symbolInfo, resolution, rangeStartDate, rangeEndDate, onLoadedCallback)
                }
            }
        }
    複製代碼

小記

  • tradingview主要就是這幾個函數之間的搭配。
  • 使用onLoadedCallback(this.cacheData[ticker])或者this.datafeeds.barsUpdater.updateData()去更新數據。
  • 滑動加載時,能夠先加載200條,後面每次150條,這樣大大縮小了數據量,減小了渲染時間。
  • 滑動加載時的節流會常常用到。

進階websocket

  • 二進制傳輸數據github

  • websocket在傳輸數據的時候是明文傳輸,並且像K線上的歷史數據,通常數據量比較大。爲了安全性以及更快的加載出圖表,咱們決定使用二進制的方式傳輸數據。web

    • 能夠經過使用pako.js解壓二進制數據
    • 引入pako.jsyarn add pako -S
    • 使用方法
      if (res.data instanceof Blob) { // 看下收到的數據是否是Blob對象
          const blob = res.data
          // 讀取二進制文件
          const reader = new FileReader()
          reader.readAsBinaryString(blob)
          reader.onload = () => {
              // 首先對結果進行pako解壓縮,類型是string,再轉換成對象
              data = JSON.parse(pako.inflate(reader.result, { to: 'string' }))
          }
      }
      複製代碼
    • 轉換後,數據大小大概減小了20%。

差很少了canvas


寫在最後

  • 這裏只分享些簡單的內容,細節能夠參照原生js版本的Demo github.com/tenggouwa/t…
  • 關於滾動加載,以及二進制的內容有問題的能夠評論留言。
  • 若是這篇文章對你有幫助,或者是讓您對tradingview有些瞭解,歡迎留言或點贊,我會一一回復。
  • 筆者最大的但願就是您能從個人文章裏得到點什麼,我就很開心啦。。。
  • 後面,至少每月更新一篇文章。點贊關注不迷路啊,老鐵。
相關文章
相關標籤/搜索