65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Silverlight 可视化实时和历史股票数据

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (12投票s)

2011年1月19日

CPOL

13分钟阅读

viewsIcon

83739

downloadIcon

2927

本文旨在提出一种在 Silverlight 中获取和可视化实时及历史股票数据的通用方法。

引言

本文旨在提出一种在 Silverlight 中获取和可视化实时及历史股票数据的通用方法。本示例使用 Google Finance 作为实时和历史股票数据源,并使用 Visiblox Charts for Silverlight 来展示获取的数据。然而,本示例的设计使得更换数据源或数据可视化库都相对容易实现。本文的结构如下:

获取实时和历史股票数据

要解决的第一个问题是找到一个提供实时和历史股票数据的数据源,并理解其 API 的工作原理。有两个数据源提供免费可访问的股票信息,但有合理的限制:Google FinanceYahoo Finance。这两个数据源都提供延迟的实时股票数据信息以及历史数据。在本文中,我选择使用 Google Finance。让我们看看它的 API 是如何工作的。

Google Finance API 概述

在我们的应用程序中,我们希望能够查询股票的实时价格并获取历史价格(如前所述,Google Finance 像所有免费数据源一样提供有延迟的“实时”数据)。唯一文档化的公共 Google Finance API 是 portfolio API,但该 API 无法轻松获取股票价格。获取实时和历史股票数据的 APIunfortunately 是未文档化的。

然而,Google Finance 页面通过一个未文档化的 REST API 进行查询。通过回溯网站上的请求,可以轻松理解此 API。我们的应用程序将需要的 public API 调用如下。

实时股票数据 API

要获取实时股票数据,需要进行以下调用:http://www.google.com/finance/info?q={stockCode}。q 参数指定股票代码,就像在 Google Finance 上指定的那样。

因此,例如,可以通过此调用查询最新的微软 (MSFT) 股票:http://www.google.com/finance/info?q=MSFT。返回的数据是 JSON 格式,上述调用将返回类似以下内容:

// [ { "id": "358464" ,"t" : "MSFT" ,"e" : "NASDAQ" ,"l" : "27.02" ,"l_cur" : 
// "27.02" ,"ltt":"4:00PM EST" ,"lt" : "Dec 3, 4:00PM EST" ,"c" : "+0.13" ,"cp" : 
// "0.48" ,"ccol" : "chg" ,"el": "27.02" ,"el_cur": "27.02" ,"elt" : 
// "Dec 3, 7:24PM EST" ,"ec" : "0.00" ,"ecp" : "0.00" ,"eccol" : "chb" ,"div" : 
// "0.16" ,"yld" : "2.37" } ]

历史股票数据 API

public 历史股票数据 API 也未文档化,因此我不得不回溯在 Google Finance 页面上进行的调用以发现它。根据此,可以使用以下调用请求历史股票数据:http://finance.google.co.uk/finance/historical?q={stockCode}&startdate={startDate}&enddate={endDate}&output=csv

调用的参数如下:

  • stockCode - 股票代码,如在 Google Finance 上指定
  • startDate - 期间的开始日期,格式为 MMM+d,+yyyy(例如 Jan+01,+2010)
  • endDate - 期间的结束日期,格式相同

一个示例查询,获取 2010 年 1 月 28 日至 2010 年 2 月 1 日之间的微软 (MSFT) 股票价格,如下所示:finance.google.co.uk/finance/historical?q=MSFT&startdate=Jan+28,+2010&enddate=Feb+01,+2010&output=csv。此调用的输出是一个简单的逗号分隔值 (CSV) 文件,结构类似:

Date, Open, High, Low, Close, Volume
1-Feb-10,28.39,28.48,27.92,28.41,85931099
29-Jan-10,29.90,29.92,27.66,28.18,193888424
28-Jan-10,29.84,29.87,28.89,29.16,117513692

解决 Silverlight 跨域问题

在映射了 Google Finance 的 REST API 后,显然我们在解决方案中将查询这些 URL。然而,为了让 Silverlight 访问跨域资源,目标域需要通过定义 crossdomain.xmlclientaccesspolicy.xml指定必需的设置来明确允许访问。由于我们想要查询的域没有此类权限,Silverlight 客户端无法直接查询此数据源。

此问题有一些解决方案,所有这些解决方案都涉及一定程度的请求代理。一种可能的方法是使用 Yahoo pipes,另一种解决方案是设置一个代理,例如使用 Google App Engine,它可以转发请求到目标域并从目标域返回。在我的解决方案中,我选择实现后者,因为代理提供了一种通用的跨域问题解决方案,而 Yahoo Pipes 需要为每种情况重新配置。

我不会深入探讨如何使用 Google App Engine 设置代理,有关详细信息,请参阅博客文章关于将 Google App Engine 用作 Silverlight 和 Flash 跨域请求代理。使用 Google App Engine 作为代理的好处是,在达到合理的每日限额之前不会收取资源费用,并且您只需要一个 Google 帐户即可开始使用。

为了让示例正常工作,我已将代理实现上传到一个自定义 app engine 域(您将在源代码中找到它)。要提交代理请求以访问微软 (MSFT) 的实时股票价格,需要进行以下请求:http://{customdomain}.appspot.com/proxy?url=http://www.google.com/finance/info?q=MSFT

关于配额的说明

使用 Google App Engine 作为代理纯粹是出于演示目的。请注意,如果代理收到过多请求并且免费配额用完,它将停止工作。

获取和解析股票数据

在发现数据源 API 后,下一步是获取和解析一些股票数据。为此,让我们设计应用程序的数据结构并实现数据的查询和解析。

设计数据结构

我们需要为我们的应用程序表示两种不同的数据结构:实时数据和历史数据。

实时数据

Google Finance 数据源返回大约 10 个实时数据参数,我们只建模其中最重要的 4 个:股票代码、时间戳、价格和自上次更新以来的变化,从而创建 LiveStockData 类。

public class LiveStockData
{
    public string StockCode { get; set; }

    public DateTime TimeStamp { get; set; }

    public double Price { get; set; }

    public double Change { get; set; }
}

历史数据

Google Finance 数据源返回数据的高、低、开、收盘价和交易量。让我们将股票代码添加到这些信息中,并创建我们的类 - HLOCHistoricStockData - 来表示历史 HLOC 数据。

public class HLOCHistoricStockData
{
    public string StockCode { get; set; }

    public DateTime Date { get; set; }

    public double Price { get; set; }

    public double Open { get; set; }

    public double Close { get; set; }

    public double High { get; set; }

    public double Low { get; set; }

    public double Volume { get; set; }
}

通用变更事件

由于应用程序将获取 URL,这是一个异步过程,获得这些变更通知的最简单方法是通过订阅事件。在我们的情况下,逻辑上是返回获取的 LiveStockDataHLOCHistoricStockData 数据结构。为了以类型安全的方式做到这一点,让我们设计一个通用的更改处理程序事件,并带有通用参数。

public delegate void StockDataEventHandler<T>(object sender, EventWithDataArgs<T> e);

public class EventWithDataArgs<T>:EventArgs
{
    public T Data { get; internal set; }
    public EventWithDataArgs(T data)
    {
        this.Data = data;
    }
}

我们稍后将使用 StockDataEventHandler 作为通用事件处理程序进行更改通知。

获取和解析实时股票数据

设计完数据结构后,是时候继续获取实时数据了。思路是创建一个类,一旦创建就可以订阅它。该类将不断查询数据源,并在订阅的股票价格发生变化时触发事件。

让我们勾勒出这样一个类的接口是什么样子,并称其为 ILiveStockDataQuerier

public interface ILiveStockDataQuerier
{
    /// <summary>
    /// Event fired when the subscribed stock price has changed
    /// </summary>
    event StockDataEventHandler<livestockdata> OnDataUpdated;

    /// <summary>
    /// Subscribes to the stock, constantly queries it and 
    /// fires updates when it has updated
    /// </summary>
    void Subscribe(string stockCode);
}

继续,让我们实现这个接口。由于我们将需要不断查询,这需要在后台线程上完成,使用 BackgroundWorker 以避免应用程序挂起。当有人订阅股票代码时,我们还想存储该信息,以便知道要继续查询哪只股票。

private string _subscribedStockCode;


private BackgroundWorker _queryService;

public void Subscribe(string stockCode)
{
    _subscribedStockCode = stockCode;
    _queryService = new BackgroundWorker();
    _queryService.DoWork += new DoWorkEventHandler((s, e) => 
			{ this.QueryData(_subscribedStockCode);  });
    _queryService.RunWorkerAsync();
}

private void QueryData(string stockCode)
{
    WebClient queryWebClient = new WebClient();
    queryWebClient.OpenReadCompleted += 
	new OpenReadCompletedEventHandler(liveWebClient_OpenReadCompleted);
    var url = String.Format(GoogleLiveStockDataUrl
        , stockCode);
    var proxiedUrl = Helpers.GetProxiedUrl(url);
    queryWebClient.BaseAddress = proxiedUrl;
    queryWebClient.OpenReadAsync(new Uri(proxiedUrl, UriKind.Absolute));
}

一旦返回 URL,我们就需要解析它并触发带有实时股票信息的更改事件。我使用了流行的 JSON.NET 库来帮助解析 JSON 响应。代码如下:

void liveWebClient_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e)
{
    if (e.Error != null)
        return;

    var webClient = sender as WebClient;
    if (webClient == null)
        return;

    using (StreamReader reader = new StreamReader(e.Result))
    {
        string contents = reader.ReadToEnd();
        contents = contents.Replace("//", "");

        // Change the thread culture to en-US to make parsing 
        // of the dates returned easier
        var originalCulture = Thread.CurrentThread.CurrentCulture;
        Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
        try
        {
            var array = JArray.Parse(contents);
            if (array.Count > 0)
            {
                var o = array[0];
                double value = o["l"].Value<double>();
                double change = o["c"].Value<double>();
                DateTime date = DateTime.ParseExact(o["lt"].Value<string>(), 
				"MMM d, h:mmtt EST", CultureInfo.CurrentUICulture);
                var data = new LiveStockData()
                {
                    StockCode = _subscribedStockCode,
                    TimeStamp = date,
                    Price = value,
                    Change = change
                };
                // For simplicity, fire a change event every time
                if (OnDataUpdated != null)
                {
                    OnDataUpdated(this, 
			new EventWithDataArgs<livestockdata>(data));
                }
                // Start over
                Thread.Sleep(1000);
                this.QueryData(_subscribedStockCode);
            }
        }
        // The response could not be parsed
        catch (JsonReaderException ex)
        {
            // Implement custom exception handling if needed
        }
    }
}

获取和解析历史股票数据

在实现了处理实时更新查询和解析的类之后,让我们实现获取和解析历史股票价格的类。我们期望这个类执行以下操作:可以指示它检索给定时间范围内给定股票的数据,将响应解析为 HLOCHistoricStockData 类并引发一个传递该类的事件。基于这些要求,让我们勾勒出该类的接口 - IHistoricalStockDataQuerier

public interface IHistoricalStockDataQuerier
{
    /// <summary>
    /// Event fired when the subscribed stock's data has been returned
    /// </summary>
    event StockDataEventHandler<IList<hlochistoricstockdata> >OnQueryCompleted;

    /// <summary>
    /// Queries historical data between the given dates and fires 
    /// OnChanged when finished.
    /// </summary>
    void QueryData(string stockCode, DateTime startDate, DateTime endDate);
}

实现股票数据查询非常简单。

public void QueryData(string stockCode, DateTime startDate, DateTime endDate)
{
    WebClient historicWebClient = new WebClient();
    historicWebClient.OpenReadCompleted += 
	new OpenReadCompletedEventHandler(historicWebClient_OpenReadCompleted);
    Thread.CurrentThread.CurrentCulture = new CultureInfo("en-US");
    var url = String.Format(GoogleHistoricalStockDataUrl,
        stockCode,
        startDate.ToString("MMM+dd\\%2C+yyyy"),
        endDate.ToString("MMM+dd\\%2C+yyyy")
        );
    var proxiedUrl = Helpers.GetProxiedUrl(url);
    historicWebClient.BaseAddress = proxiedUrl;
    historicWebClient.OpenReadAsync(new Uri(proxiedUrl, UriKind.Absolute));
}

解析结果也并不棘手,因为我们收到的是简单的 CSV。

void historicWebClient_OpenReadCompleted(object sender, OpenReadCompletedEventArgs e)
{
    if (e.Error != null)
        return;

    var webClient = sender as WebClient;
    if (webClient == null)
        return;

    try
    {
        using (StreamReader reader = new StreamReader(e.Result))
        {
            var stockDataList = new List<hlochistoricstockdata>();
            string contents = reader.ReadToEnd();
            var lines = contents.Split('\n');
            bool firstLine = true;
            foreach (var line in lines)
            {
                // First line is metadata ==> ignore it
                if (firstLine)
                {
                    firstLine = false;
                    continue;
                }
                // Skip last empty line
                if (line.Length == 0)
                    continue;
                var parts = line.Split(',');
                // Google data is separated as Date, Open, High, Low, Close, Volume
                var stockData = new HLOCHistoricStockData()
                {
                    Date = Convert.ToDateTime(parts[0]),
                    Open = Convert.ToDouble(parts[1]),
                    High = Convert.ToDouble(parts[2]),
                    Low = Convert.ToDouble(parts[3]),
                    Close = Convert.ToDouble(parts[4]),
                    Price = Convert.ToDouble(parts[4]),
                    Volume = Convert.ToDouble(parts[5]),
                    StockCode = GetStockCodeFromUrl(webClient.BaseAddress)
                };
                stockDataList.Add(stockData);
            }
            // Raise the OnChanged event
            if (OnQueryCompleted != null)
            {
                OnQueryCompleted(this, new EventWithDataArgs
			<IList<hlochistoricstockdata>>(stockDataList));
            }
        }
    }
    // Something went wrong!
    catch (Exception ex)
    {
        // Implement custom exception handling if needed
    }
}
</hlochistoricstockdata>

此代码的有一部分需要解释。在实时数据查询示例中,我们在 _subscribedStockCode private 变量中保留了查询股票的代码。在历史版本中,我们没有这样做,而是在每次请求的结果中,我们可以通过从原始请求 URL 解析来访问它,调用 GetStockCodeFromUrl。这是一个小细节,但是这样我们不仅不必存储我们正在查询的股票,而且还可以同时为多只股票运行多个查询,因为查询返回时会包含它所属股票的代码。GetStockCodeFromUrl 方法的实现如下:

private string GetStockCodeFromUrl(string url)
{
    string pattern = ".*?q=([^&]+)*";
    var matches = Regex.Matches(url, pattern, RegexOptions.None);
    string stockName = matches[0].Groups[1].Value;
    return stockName;
}

可视化获取的股票数据

在创建了两个类 - GoogleLiveStockDataQuerierGoogleHistoricalStockDataQuerier - 来查询和解析数据并在完成时引发事件后,我们拥有可视化股票所需的一切。

Visiblox Charts for Silverlight 概述

可视化将使用免费版的 Visiblox Charts for Silverlight。有几个 Silverlight 组件可用 - 最受欢迎的是开源的 Silverlight Toolkit 图表。我在此场景中选择此工具的原因是它具有内置的缩放和平移功能,并且在屏幕上渲染数百个点时具有不错的性能。

在此项目中,实时股票数据将作为图表的标题不断更新,而历史股票将呈现为折线图的点。

可视化历史和实时数据

要可视化数据,我们需要创建一个图表,添加一个折线图系列,执行数据源查询,并在查询完成后,更新历史数据的折线图系列和实时数据的图表标题。我还将添加平移和缩放功能,以帮助导航数据。

创建图表

要创建图表,我们需要将 Chart 对象添加到可视化树中。我们还需要创建 LineSeries 来可视化检索到的数据。另外,在此示例中,我想使用 GoogleHistoricalStockDataQuerier 返回的 IList 列表来可视化历史股票数据,而无需做太多工作。因此,我将此业务对象集合绑定到折线图系列,方法是将 BindableDataSeries 设置为 LineSeriesDataSeries 属性,并在其上设置正确的 XValueBindingYValueBinding 属性。这样,以后我只需将 ItemsSource 设置在 BindableDataSeries 上即可更新历史股票数据。

使用 XAML 或代码隐藏都可以创建带有数据系列绑定的 LineSeriesChart。在此示例中,我选择使用 XAML。

<UserControl x:Class="StockVisualization.MainPage"
    (...)
    xmlns:charts="clr-namespace:Visiblox.Charts;assembly=Visiblox.Charts"
     >
    <StackPanel x:Name="LayoutRoot" Orientation="Vertical">
        <charts:Chart x:Name="StockChart" Width="600" Height="300" 
		LegendVisibility="Collapsed">
            <charts:Chart.Series>
                <!-- Add a LineSeries that shows points and displays 
		tooltips when hovering over them -->
                    <charts:LineSeries.DataSeries>
                        <!-- Set the data source of the LineSeries to be 
			a BindableDataSeries to allow binding to our business 
			object collection -->
                        <charts:BindableDataSeries XValueBinding="{Binding Date}" 
			YValueBinding="{Binding Price}"/>
                    </charts:LineSeries.DataSeries>
            </charts:Chart.Series>
    </StackPanel>
</UserControl>

创建输入控件

为了使示例正常工作,我们需要一种方式让用户指定要查询的股票类型。为简单起见,在此示例中我将使用一个带有预定义值的 ComboBox

查询实时和历史股票数据不是瞬间完成的,最好让用户知道后台正在发生一些事情。出于这个原因,我创建了一个 ProgressBar 来在查询进行时显示。

此示例中这些控件的 XAML 如下:

<StackPanel x:Name="LayoutRoot" Orientation="Vertical">
    <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
        <TextBlock>Stock code:</TextBlock>
        <ComboBox x:Name="ComboStockCode">
            <ComboBoxItem Content="MSFT" IsSelected="True"/>
            <ComboBoxItem Content="GOOG"/>
            <ComboBoxItem Content="VOD"/>
            <ComboBoxItem Content="BCS"/>
        </ComboBox>
        <Button x:Name="BtnUpdate" Content="Update" Click="BtnUpdate_Click"/>
    </StackPanel>
    <StackPanel Orientation="Horizontal" x:Name="PanelFetchingData" 
	Visibility="Collapsed" Margin="0,5,0,5" HorizontalAlignment="Center">
        <ProgressBar IsIndeterminate="True" Width="150" Height="15"/>
    </StackPanel>
    <charts:Chart x:Name="StockChart" Width="600" Height="300" 
	LegendVisibility="Collapsed">
        <!-- definition of the chart -->
    </charts:Chart> 
    <TextBlock HorizontalAlignment="Center" Width="400" 
	TextWrapping="Wrap" Text="Drag mouse to pan, scroll with mousewheel to zoom."/>
</StackPanel>

可视化实时股票数据

可视化实时股票数据非常明显:我们所需要做的就是创建一个 GoogleLiveStockDataQuerier 实例并订阅其 OnDataUpdated 事件。当该事件触发时,我们将更新图表的 Title。代码如下:

private GoogleLiveStockDataQuerier _liveDataQuerier;

public MainPage()
{
   // Other initialization logic
  _liveDataQuerier = new GoogleLiveStockDataQuerier();
    _liveDataQuerier.OnDataUpdated +=new StockDataEventHandler
			<livestockdata>(_liveDataQuerier_OnChanged);
  // Other logic
}

void _liveDataQuerier_OnChanged(object sender, EventWithDataArgs<livestockdata> e)
{
    if (e.Data != null)
    {
        Dispatcher.BeginInvoke(() =>
        {
            StockChart.Title = String.Format("{0}: {1} ({2})", 
		e.Data.StockCode, e.Data.Price, e.Data.Change);
        }
        );
    }
}

可视化历史股票数据

可视化历史股票数据也非常简单。当用户单击“更新”按钮时,将调用 GoogleHistoricalStockDataQuerier 实例的 QueryData 方法。调用此方法时,它将引发一个 OnQueryCompleted 事件,该事件将在事件的 Data 成员中返回一个 IList。然后,只需将此 IList 设置为我们在图表上定义的 BindableDataSeriesItemsSource 即可。

所有这些代码都比解释的要简单得多,相关部分如下:

private GoogleHistoricalStockDataQuerier _historicDataQuerier;
public MainPage()
{
    // Other initalization logic
    _historicDataQuerier = new GoogleHistoricalStockDataQuerier();
    _historicDataQuerier.OnQueryCompleted += new StockDataEventHandler
	<ilist<hlochistoricstockdata>>(_historicDataQuerier_OnQueryCompleted);
    // Load data in the chart on startup
    UpdateChartData();
}

public void UpdateChartData()
{
    // Clear the title of the chart
    StockChart.Title = "";
    // Show the loading indicator
    PanelFetchingData.Visibility = Visibility.Visible;
    var stockCode = (ComboStockCode.SelectedItem as ComboBoxItem).Content.ToString();
    // Change the live updates to the new stock
    _liveDataQuerier.Subscribe(stockCode);
    // Query a quarter's data and set the chart to show the last 6 months 
    // (the rest can be panned)
    _historicDataQuerier.QueryData(stockCode, DateTime.Now.AddMonths(-6), DateTime.Now);
}

void _historicDataQuerier_OnQueryCompleted(object sender, EventWithDataArgs
		<IList<hlochistoricstockdata>> e)
{
    // Hide the loading indicator
    PanelFetchingData.Visibility = Visibility.Collapsed;
    // Set the returned data as ItemsSource to the BindableDataSeries: 
    // this will update the series
    (StockChart.Series[0].DataSeries as BindableDataSeries).ItemsSource = e.Data;
}

为图表添加交互性

到目前为止,该示例获取了过去半年的数据,并在图表上显示所有数据点。这意味着我们已经显示了大约 200 个数据点,这在某些点可能会有点拥挤。

Screen Shot - the Stock Data Visualization project without any interactivity added

在屏幕上显示较少的数据点并允许缩放和平移是有意义的,使用户能够以更具交互性的方式导航图表。

缩放和平移是 Visiblox 图表的行为。为了使用它们,只需将它们设置为图表的 Behaviour 属性即可。由于我们将同时使用缩放和平移,因此我们需要添加一个 BehaviourManager,它允许在图表上使用多个行为。这可以在 XAML 中完成:

<charts:Chart x:Name="StockChart" Width="600" Height="300" 
	LegendVisibility="Collapsed">
<charts:Chart.Behaviour>
        <charts:BehaviourManager AllowMultipleEnabled="True">
            <charts:PanBehaviour YPanEnabled="False" IsEnabled="True"/>
            <charts:ZoomBehaviour IsEnabled="True"/>
            </charts:BehaviourManager>                
    </charts:Chart.Behaviour>
</charts:Chart> 

在上一个代码示例中,我们在 PanBehaviour 上将 YPanEnabled 设置为 false,这意味着我们只能在 X 轴上进行平移 - 在这种情况下,允许用户上下平移 Y 轴没有多大意义。

添加缩放和平移后我们面临的唯一问题是,默认情况下所有点都显示在屏幕上,因此没有太多可以平移的。通过在 UpdateChartData 方法中将 X 轴的 Range 设置为仅显示上个月的点,我们可以改变这一点。为此,我们只需重新定义 X 轴的 Range

// If the X axis has not yet been initialized 
// (that is we're calling UpdateChartData from the MainPage constructor) 
// we'll have to manually assign it
if (StockChart.XAxis == null)
{
    StockChart.XAxis = new DateTimeAxis(); 
}
StockChart.XAxis.Range = new DateTimeRange() 
{ Minimum = DateTime.Now.AddMonths(-1), Maximum = DateTime.Now };

添加这几行代码后,现在可以通过拖动鼠标来平移图表,并通过使用鼠标滚轮来缩放图表。

这是编译后的示例的屏幕截图:

Screen Shot - the Stock Data Visualization project, compiled, final version

关于代码重用的思考

在本文中,我将可视化实时和历史股票数据的问题分解为 3 个主要步骤:

通过定义股票数据的结构并声明获取它们的接口,我的目标是允许轻松地将数据提供商更换为任何其他提供商(例如 Yahoo Finance 或具有 public API 的其他服务)。

用于可视化数据的图表组件也可以轻松地替换为任何其他 Silverlight 图表库。在这种情况下,可视化实现将特定于所选组件。然而,使用任何图表库显示带有折线图或蜡烛图的图表应该不难。

结论

在本文中,我实现了一种相当通用的获取和可视化实时及历史股票数据的方法。在实现过程中,我使用了一个特定的数据提供商和特定的图表组件。该解决方案旨在轻松地更改数据源或数据可视化库。您可以 在此处下载源代码

希望您觉得本文有用。欢迎在下方发表评论。

历史

  • 2011 年 1 月 19 日:初稿
© . All rights reserved.