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

一个几乎完整的命名空间扩展示例

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.87/5 (19投票s)

2004年8月13日

16分钟阅读

viewsIcon

215670

downloadIcon

3764

一个使用系统提供的 ShellView (SHCreateShellFolderView) 的 Shell 命名空间扩展的实现。

Sample Image - NamespaceExtImpl_FileDialog.jpg

目录

引言

本文向您展示了一个几乎完整的 Shell Namespace Extension 实现。这是我花了几个夜晚来实现两个目标的结果:

  • 使用系统提供的 ShellView 对象。
  • 能够将此扩展用于 FileDialog(CommonDialog)。
第一个目标是对 Henk Devos 的文章《Namespace extensions - the undocumented Windows Shell》的回应。有几位用户要求为他的文章提供一个演示项目,现在它在这里!

第二个目标是我必须实现的规范。这个示例实际上将充当 DirectoryOpus 文件管理器的收藏夹“快捷方式”系统(实际上远不止是文件管理器,请参阅 DOpus)。

在继续阅读本文之前,您应该阅读 Michael Dunn 的《The Complete Idiot's Guide to Writing Namespace Extensions - Part I》,这是我实现此文章的基础。

Microsoft 知识库文章 216954 中提供了更多信息,名为“HOWTO: Support Common Dialog Browsing in a Shell Namespace Extension”。

要求

系统

由于此扩展使用了 SHCreateShellFolderView 函数,因此您的系统至少需要安装 IE 4.0。请注意,对于 WinNT4(以及适用的 Win95),即使未启用,也必须安装 Active Desktop(随 IE 4 一起提供)。

我在多个 Windows 平台上进行了测试,以下是带有注释的列表:

操作系统版本 Shell 版本 注释
Win98 4.7x - 不支持移除消息(@%MODULE%,-300)
- 未测试 Office FileDialog
WinNT4 SP6 4.7x - 不支持移除消息(@%MODULE%,-300)
- 未测试 Office FileDialog
Win2K 5.0 好的,OfficeXP FileDialog 工作正常
WinXP Pro 6.0 好的,OfficeXP FileDialog(通过 MSDev 7)工作正常

由于此扩展在上述平台上运行良好,因此也应该能在以下平台上运行(但我没有测试过):

  • WinME
  • WinXP Home
  • NT4、W2K、W2003 的服务器版本
我没有在意 Windows 95,在我写这篇文章(公元 2004 年)时,我认为这个操作系统已经过时了。但请注意,它应该可以工作,因为依赖关系高度依赖于 Internet Explorer,而不是操作系统本身。

开发环境

我用来创建此示例的环境是 MSDev 6.0 和 ATL 3.0。在 MSDev 7.0 和 ATL 7.0 下我也成功编译了它(尽管不是 MinSize 版本)。

您必须拥有 Microsoft Platform SDK,我使用的是 2003 年 2 月的版本。这一点很重要,因为我使用了最近文档化的 API。所以,如果您有一个旧的 SDK(例如随 MSDev 提供的),它将无法编译。

此扩展将做什么?

此扩展将充当收藏夹快捷方式系统。与系统的“收藏夹”文件夹不同,我不想为每个快捷方式使用 .lnk 文件(它是一个表示快捷方式的文本文件)。我想要的快捷方式存储在注册表中。如本文开头所述,这些快捷方式是由 DirectoryOpus 存储的。

快捷方式由什么组成?

从现在开始,我将使用“收藏夹”一词代替“快捷方式”,那么一个收藏夹由什么组成?
  • 一个名称(例如:“_/# Fancy Temp Dir #\_”)
  • 一个路径(例如:“E:\Temp”)
  • 一个排名
排名决定了在收藏夹列表中的位置。值越低,在列表中的位置越高。

您一定注意到名称可以包含任何字符,包括路径中不允许的字符。

期望的行为

我的命名空间的根视图必须显示收藏夹、它们的名称,以及在“详细信息”模式下,显示路径和排名。

当用户选择一个收藏夹时的行为是转到该项的实际路径。这是唯一需要的行为,用户之后做什么并不重要,重要的是目标路径命名空间。

这听起来应该很简单,很容易实现。啊,但就像往常一样,计算机有十种方法可以做同一件事,而不同的程序(或者说不同的 MS 团队)将使用所有这些方法,甚至可能再加上一两种。

如何实现?

好的,让我们开始实际工作。为了实现期望的行为,我们应该实现什么?有价值的信息在 Platform SDK 中,请查阅以获取详细信息。

PIDL 布局

首先,我们必须选择一个 PIDL 布局。我们的项是什么?它们是收藏夹。收藏夹由什么组成?名称、路径和排名。我选择将所有这些信息嵌入 PIDL 中。为此,我有一个类 CPidlMgr,它将进行通用的 PIDL 处理,以及一个 CDataFavo 类,它处理嵌入的信息。

从 PIDL 获取路径的示例

    LPOLESTR pOleStr = CDataFavo::GetPath( pidl );

请看这个字符串类型:LPOLESTR,所有字符串信息都以 UNICODE 字符串的形式存储在我的 PIDL 中,即使是 ANSI 版本。这是一个设计选择,请阅读 SDK 以了解为什么不使用 TCHAR 字符串。

CDataFavo 类继承自 CPidlData,它由 CPidlMgr 用于创建新的 PIDL。要创建新的 PIDL,首先创建一个 CDataFavo 对象,然后使用 SetXXX() 方法填充它。然后使用 CPidlMgr::Create() 获取 PIDL。示例

    LPITEMIDLIST pidl;
    
    CDataFavo Favo;
    Favo.SetName(_T("_/# Fancy Temp Dir #\_"));
    Favo.SetPath(_T("E:\\Temp"));
    Favo.SetRank(3);
    
    pidl = m_PidlMgr.Create(Favo);

Explorer 的用例

我将通过用例来描述扩展的控制流。我不会对所有这些用例进行深入的解释,请阅读代码,跟踪它,修改它。如果您仍然缺少某些内容,请在消息板上提问。

您会注意到 Explorer 的行为与 FileDialog 的行为之间存在差异。

我假设您有一个启用了 TreeView(在左侧)的 Explorer。我将右侧视图(您看到项的地方)称为 ShellView。

Explorer 或任何其他控制器将做的第一件事是调用我们的 IPersistFolder::Initialize() 方法。正如 Michael Dunn 的文章中所述,我们在这里仅保存传递的 pidl,它代表了我们在系统命名空间中的扩展位置。

然后用户将执行以下操作之一:

点击 TreeView 中的命名空间图标

那么,当您点击 TreeView 中的命名空间图标时会发生什么?另一种获得完全相同行为的方法是双击 ShellView 中的命名空间图标(当显示桌面内容时)。

Explorer 将希望显示命名空间项,为此它调用 IShellFoler::CreateViewObject() 请求 IShellView。在这里,我按照要求创建了视图。由于它是系统视图(请参见 Shell View),IShellFolder 的多个方法将被调用来填充视图。

点击 TreeView 中的命名空间图标旁的加号

Explorer 将调用此序列:

  • EnumObjects() 一次
  • CompareIDs() 几次
  • GetAttributesOf() 针对每个项
  • GetDisplayNameOf() 针对每个项
  • GetUIObjectOf() 针对每个项以获取 IExtractIcon

结果是树被展开并显示我们的项(如果它们是 SFGAO_FOLDER),显示它们的名称。请注意,此时 ShellView 仍然显示另一个目录。

点击 TreeView 中的收藏夹项

现在,如果用户点击树中的一个项,将调用 BindToObject(),传递该项的 PIDL。我们希望 Explorer 在 ShellView 中显示目标路径的内容。

我们拥有目标路径(在我们的 pidl 中),并且我们必须 BindToObject() 到该目标路径。所以我们创建一个绝对 pidl(从桌面开始)并将其传递给 BindToObject()。这是它的样子:

    HRESULT hr;
    CComPtr<IShellFolder> DesktopPtr;

    hr = SHGetDesktopFolder(&DesktopPtr);
    if (FAILED(hr))
        return hr;

    LPITEMIDLIST pidlLocal;
    hr = DesktopPtr->ParseDisplayName(NULL, pbcReserved, 
       CDataFavo::GetPath(pidl), NULL, &pidlLocal, NULL);
    if (FAILED(hr))
        return hr;

    hr = DesktopPtr->BindToObject(pidlLocal, pbcReserved, riid, ppvOut);

    ILFree(pidlLocal);
    return hr;

双击 ShellView 中的收藏夹项

尽管行为应与 点击 TreeView 中的收藏夹项 相同,但 Explorer 的实现方式不同。

实际上,双击 ShellView 中的项对应于调用该项上下文菜单的默认条目。在浏览文件夹时,默认条目是“Explore”,这就是为什么行为相同的原因。请注意,您可以实现一个具有不同默认条目的上下文菜单,从而改变“双击”行为。

这意味着 Explorer 将调用我们的 IShellFolder::GetUIObjectOf() 方法,请求 IContextMenu。在我们的例子中,我们不需要实现 IContextMenu,我们所要做的只是将此调用委托给目标路径。

要了解如何做到这一点,请阅读下一段。

右键单击 ShellView 中的收藏夹项

这里必须显示一个上下文菜单。此菜单与所选项有关。如果选择了多个项,则菜单必须显示适用于所有选定项的条目。

对于我的扩展,我选择显示目标路径的上下文菜单,因此我无需自己实现一个。我也只处理单个选定的项,因为每个项都可以指向不同的存储。因此,我不能轻易地(即无需大量代码)将上下文菜单同时委托给不同的存储。

Explorer 将调用我们的 IShellFolder::GetUIObjectOf() 方法。要将其委托给目标路径,我们必须获取目标路径父级的 IShellFolder,以便用单项 pidl 调用其 GetUIObjectOf()

要实现这一点,我们必须首先将目标路径转换为绝对 pidl,方法如下:

    hr = SHGetDesktopFolder(&DesktopPtr);
    if (FAILED(hr))
        return hr;

    LPITEMIDLIST pidlLocal;
    hr = DesktopPtr->ParseDisplayName(NULL, NULL, 
       CDataFavo::GetPath(*pPidl), NULL, &pidlLocal, NULL);
    if (FAILED(hr))
        return hr;

现在 pidlLocal 包含指向目标路径的绝对 pild。我们现在必须获取父 IShellFolder,这可以通过 SHBindToParent() 来完成,但此函数仅从 Shell 版本 5.0 开始可用,因此这里有一个等效的代码:

    LPITEMIDLIST pidlRelative;

    // pidlTmp will point to the single-item pidl of the target path
    LPITEMIDLIST pidlTmp = ILFindLastID(pidlLocal);

    // Now strips the last part of the pidl, to have the pidl of the parent
    pidlRelative = ILClone(pidlTmp);
    ILRemoveLastID(pidlLocal);

    // We can now get the parent IShellFolder
    hr = DesktopPtr->BindToObject(pidlLocal, NULL, 
          IID_IShellFolder, (void**)&TargetParentShellFolderPtr);
    ILFree(pidlLocal);
    if (FAILED(hr))
    {
        ILFree(pidlRelative);
        return hr;
    }

TargetParentShellFolderPtr 现在拥有父 IShellFolder,这样我们就可以用包含单项 pidl 的 pidlTmp 调用其 GetUIObjectOf 方法。

    hr = TargetParentShellFolderPtr->GetUIObjectOf(hwndOwner, 1, 
        (LPCITEMIDLIST*)&pidlRelative,
        riid, puReserved, ppvReturn);
重定向已完成,如果之前的任何函数失败,则根本不会显示上下文菜单。当目标路径不存在或无法访问(网络)时,可能会发生这种情况。

FileDialog 的用例

FileDialog,即 Common Dialogs 中的那个,其行为与 Explorer 略有不同。

首先,命名空间图标必须显示在顶部组合框中,为此,我们必须使用以下标志注册我们的扩展:SFGAO_FILESYSANCESTOR, SFGAO_FILESYSTEM, SFGAO_FOLDER, SFGAO_BROWSABLE。这对应于注册表中 ShellFolder 键下的 Attributes 值,请参阅项目 .rgs 文件。

我不记得确切的条件(OS、Shell 版本、用例)是什么,但未实现 IShellFolder2 可能会导致问题,所以请实现它。唯一添加的方法是 GetCurFolder(),它只是返回传递给 IShellFolder::Initialize() 的 pidl 的副本。

在顶部组合框中选择命名空间图标

FileDialog 将简单地调用 IShellFolder::CreateViewObject(),因为我们使用系统 ShellView,IShellFolder 的多个方法将被调用以响应创建视图对象。它们是(顺序不限):

  • EnumObjects()
  • CompareIDs()
  • GetAttributesOf()
  • GetDisplayNameOf()
  • GetUIObjectOf()

双击视图中的收藏夹项

出于某种原因,我不太理解,FileDialog 不会简单地使用项 pidl 调用 IShellFolder::BindToObject()

它首先调用 IShellFolder::GetUIObjectOf() 请求 IDataObject。查看 SDK 中的这个接口可以得知,它用于在模块之间交换任何形式的数据。它用于剪贴板、拖放等。

那么这与我们有什么关系呢?FileDialog 将使用 IDataObject 作为项 pidl 的容器。

此用例的调用序列为(移除对 GetAttributesOf()GetDisplayNameOf() 的调用):

  • GetUIObjectOf()
  • BindToObject()
FileDialog 调用 IDataObject 的唯一方法是 GetData()。目的是获取项 pidl。所有其他方法都可以简单地返回 E_NOTIMPL

让我们看一下:

STDMETHODIMP CDataObject::GetData(LPFORMATETC pFE, LPSTGMEDIUM pStgMedium)
{
    // Is the caller requesting a pidl?
    if (pFE->cfFormat == m_cfShellIDList)
    {
        // Return the item pidl in the form of a CIDA structure
        pStgMedium->hGlobal = CreateShellIDList(m_pidlParent, 
           (LPCITEMIDLIST*)&m_pidl, 1);

        if (pStgMedium->hGlobal)
        {
            pStgMedium->tymed = TYMED_HGLOBAL;

            // Even if our tymed is HGLOBAL, WinXP calls ReleaseStgMedium()
            // which tries to call pUnkForRelease->Release() : BANG! 
            // (if not NULL)
            pStgMedium->pUnkForRelease = NULL;
            return S_OK;
        }
    }

    return E_INVALIDARG;
}

当调用 GetUIObjectOf() 时,我们创建一个 IDataObject 对象并设置其 m_pidlParent 和 m_pidl 与项 pidl,GetData() 简单地将它们在一个 CIDA 结构中返回,这由 CreateShellIDList() 完成。

注意 pStgMedium->pUnkForRelease = NULL; 这一行,如注释中所述,如果您省略它,WinXP 可能会崩溃。

右键单击 ShellView 中的收藏夹项

与 Explorer 一样,这将显示一个上下文菜单。行为与 Explorer 相同(请参见 右键单击 ShellView 中的收藏夹项),但在调用 GetUIObjectOf() 之前,它还会通过 IDataObject 获取项 pidl,如上所述。

Office FileDialog 的问题

是的,Office FileDialog 不是 CommonDialog 的那个,它是经过修改的。
请注意,MSDev 7.x(.net)也有这个修改过的 FileDialog。

它有两个缺点:

文件系统存在性检查

第一个缺点是它将检查您正在浏览的每个文件夹,如果它不是有效的文件系统文件夹(有效路径),它将发出警告。

它通过调用我们的 IShellFolder::GetDisplayNameOf() 方法并带上 SHGDN_FORPARSING 标志来获取文件夹路径。对于虚拟文件夹(如我们的),SDK 指出,在响应此调用时,我们应该返回命名空间扩展 GUID,前面加上两个分号,如下所示:“::{GUID}”。当然,这个字符串不是一个有效的路径,所以扩展不能从 Office FileDialog 使用,唉!

为了解决这个问题,我们必须返回一个有效路径而不是“::{GUID}”。因为我的扩展没有安装文件夹,所以我决定返回一个应该存在于所有系统中的路径:临时目录。

GetDisplayNameOf() 方法的第一部分如下所示:

STDMETHODIMP CShellFolder::GetDisplayNameOf(
  LPCITEMIDLIST pidl, DWORD uFlags, LPSTRRET lpName)
{
    if ((pidl == NULL) || (lpName == NULL))
        return E_POINTER;

    // Return name of Root
    if (pidl->mkid.cb == 0)
    {
        switch (uFlags)
        {
        case SHGDN_NORMAL | SHGDN_FORPARSING :    
                  // <- if wantsFORPARSING is present in the regitry
            TCHAR TempPath[MAX_PATH];
            if (GetTempPath(MAX_PATH, TempPath) == 0)
                return E_FAIL;

            return SetReturnString(TempPath, *lpName) ? S_OK : E_FAIL;
        }
        // We dont' handle other combinations of flags
        return E_FAIL;
    }

    // Getting item names follows here
    ...
    ...
}

还有一件事要做。如前所述,FileDialog 将调用 GetDisplayNameOf() 来检索您正在浏览的文件夹名称。这仅在您告知它希望被调用以解析这些名称时才成立。默认情况下,它不会调用您。启用此行为需要在注册表中设置一个值。在 HKCR\CLSID\{extension guid here}\ShellFolder 键下创建一个名为“wantsFORPARSING”的空字符串值。这将起到作用。更多详细信息可以在 这里找到。

子项浏览

另一个修改过的行为是 Office FileDialog 浏览子项。它仍然调用根 IShellFolder BindToObject() 方法,但使用的是多级 pidl。请注意,这是完全合法的(请参阅 SDK),但会增加我们代码的复杂性。

到目前为止,我们的 BindToObject() 期望一个单级 pidl,其中包含收藏夹目标路径。我们现在必须处理仍然以我们的项开头但包含与目标路径子文件夹相关的子项的 pidl。

我修改了 BindtoObject() 的代码:

    // Handle multi-level pidl differently
    if (!m_PidlMgr.IsSingle(pidl))
    {
        HRESULT hr;
        hr = SHGetDesktopFolder(&DesktopPtr);
        if (FAILED(hr))
            return hr;

        LPITEMIDLIST pidlLocal;
        hr = DesktopPtr->ParseDisplayName(NULL, pbcReserved, 
           CDataFavo::GetPath(pidl), NULL, &pidlLocal, NULL);
        if (FAILED(hr))
            return hr;

        // Bind to the root folder of the favorite folder
        CComPtr<IShellFolder> RootFolderPtr;
        hr = DesktopPtr->BindToObject(pidlLocal, NULL, 
          IID_IShellFolder, (void**)&RootFolderPtr);
        ILFree(pidlLocal);
        if (FAILED(hr))
            return hr;

        // And now bind to the sub-item of it
        return RootFolderPtr->BindToObject(m_PidlMgr.GetNextItem(pidl),
            pbcReserved, riid, ppvOut);
    }

    // Here comes the previous code
    ...

首先,我检查它是否是单级 pidl。然后我获取目标路径 pidl,就像在之前的代码中一样。然后我使用第一个 BindToObject() 获取目标路径的 IShellFolder,这使我们能够使用 pidl 的剩余部分(即它的子文件夹、子子文件夹等)调用其 BindToObject()

Shell View

新文档化的 API(但存在于 shell 4.7x 中)提供的系统 ShellView,SHCreateShellFolderView() 完成了大部分工作。要填充和处理项,它将调用我们的 IShellFolder 方法和 IShellFolder2 方法。基本上,我们必须做的就是实现这些方法,其中大多数已经完成了。

请注意,未实现 IShellFolder2 将导致在启用 Web 视图时(几乎在 Win2K 上)出现“脚本错误”。因此,请实现 IShellFolder2,即使您从所有方法中返回 E_NOTIMPL

警告: 我没有使用 Henk Devos 文章中描述的 SHCreateShellFolderViewEx()。我曾经使用过,但后来更改了,因为该函数在 FileDialog 中不起作用(未创建视图)。

当使用 SHCreateShellFolderView() 时,您必须提供一个 IShellFolderViewCB,其中包含 MessageSFVCB 方法,ShellView 将调用此方法让您处理一些消息。因为我使用 ATL,所以我创建了一个基类来封装视图创建,但更好的是,通过标准的 message map 来处理视图消息。因此,要处理消息,只需继承它并添加您感兴趣的消息处理程序。

例如,处理一条消息:

#include <ShellFolderView.h>    // This one contains the base class

class CShellView : public CShellFolderViewImpl
{
public:
    // The message map
    BEGIN_MSG_MAP(CShellView)
        MESSAGE_HANDLER(SFVM_COLUMNCLICK, OnColumnClick)
    END_MSG_MAP()

    // When a user clicks on a column header in details mode
    LRESULT OnColumnClick(UINT uMsg, WPARAM wParam, 
             LPARAM lParam, BOOL &bHandled)
    {
        // Shell version 4.7x doesn't understand S_FALSE
        // as described in the SDK.
        SendFolderViewMessage(SFVM_REARRANGE, wParam);
        return S_OK;
    }

bHandled 参数的工作方式与标准消息映射相同,如果您没有处理消息,请将其设置为 FALSE(进入函数时为 TRUE)。如果您处理了消息,返回值(LRESULT)将从 MessageSFVCB 返回。这允许您根据 SDK 中的描述返回任何值。

有时您的处理程序需要向视图发送消息,这可以通过 SendFolderViewMessage() 方法完成,该方法将内部调用 SHShellFolderView_Message()

为了提供标准视图,您不必处理 SDK 中描述的所有消息。在我的扩展中,我只处理两条消息,并且运行良好。

Explorer 将通过调用我们的 IShellFolder::CreateViewObject() 方法来请求 ShellView,以下是创建我们类视图的代码:

STDMETHODIMP CShellFolder::CreateViewObject(HWND hwndOwner,
    REFIID riid, void** ppvOut)
{
    // Make sure the caller requested an IShellView
    if (riid == IID_IShellView)
    {
        // Create the view object
        CComObject<CShellView>* pViewObject;
        hr = CComObject<CShellView>::CreateInstance(&pViewObject);
        if (FAILED(hr))
            return hr;

        // AddRef the object while we are using it
        pViewObject->AddRef();

        // Create the view
        hr = pViewObject->Create((IShellView**)ppvOut, hwndOwner, 
           (IShellFolder*)this);

        // We are finished with our own use of the view object 
        // (AddRef()'ed by Create() if successfull)
        pViewObject->Release();

        return hr;
    }

    // We do not handle other objects
    return E_NOINTERFACE;
}

就是这样!

检查我的扩展代码,其中包含一些错误检查以及用于跟踪所有 ShellView 消息的机制。这有助于进行更深入的研究。

有趣的点

ATL 构建

创建 ATL 项目时,会定义 _ATL_MIN_CRT 符号。这是为了避免链接到标准 CRT(目标是最小化可执行文件的大小)。因为我使用了一些 CRT 功能,所以我将其删除了,然后再次检查以查看我使用了 CRT 的哪些功能。

我没有使用太多,主要是字符串和内存(str* 和 mem*)函数。还有静态对象。这让我开始使用 **AtlAux**,它执行最少的 CRT 类操作,并将字符串函数重定向到 Windows API。您可以在 CodeProject 上找到它。对于内存函数,我只是从 CRT 源代码中获取它们并放入 stdafx.cpp

因此,以下是不同的配置:

  • MinSize 使用 _ATL_MIN_CRT 并自动重定向到非 CRT API(通过 AtlAux)。
  • MinDependencies 使用 CRT(静态链接)。
这向您展示了一个非 CRT 依赖的示例。在 VC7 和 ATL 7.0 下测试时,我发现 MinCRT 的大部分内容都消失了,所以请将此视为一个可以完成的工作示例。另请注意,MinSize 版本将动态链接到 ATL.DLL,而该 DLL 默认情况下在操作系统中找不到。

如何在 File Dialog 的“Places Bar”中添加扩展?

看看本文顶部的图片。看到 FileDialog 左侧窗格中的那个图标了吗?这是一个快捷方式图标,其作用就像在顶部组合框中选择我们的命名空间图标一样。但是怎么做呢?

一切都在注册表中,查看 HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Policies\comdlg32\PlacesBar。在那里,创建一个名为“PlaceN”的字符串值,其中 N 是从 0 到 4。将其值设置为“::{E477F21A-D9F6-4B44-AD43-A95D622D2910}”,这是我们的扩展 CLSID,前面加上两个分号。这将起到作用。更多详细信息可以在 这里找到。

结论

这是此命名空间扩展的第一个版本,它实现了基本功能,但展示了如何实现它们,这就是本文的目标。在撰写本文时,我已经在开发第二个版本,它将涵盖文件夹和子文件夹。完成后我会更新这篇文章。

我是在完成开发后写的这篇文章,而不是在开发过程中写的,所以可能有一些问题我没有注意到。如果您缺少某些信息或文章的某些部分过于晦涩,请告诉我。

历史

  • 2004年8月12日
    • 首次发布。
© . All rights reserved.