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

支持剪贴板交互和拖放的文件管理器

starIconstarIconstarIconstarIconemptyStarIcon

4.00/5 (6投票s)

2010年11月9日

CPOL

10分钟阅读

viewsIcon

58575

downloadIcon

1234

一个支持剪贴板交互和拖放的文件管理器应用程序。

引言

这是一个类似于Windows资源管理器的文件管理器。它是一个基于Windows窗体控件的DLL,具有可移植性,可以让你拥有一个可以随时放入任何应用程序的文件浏览器。大部分内容可以在这个网站和其他网站上找到,但我认为将其全部集中在一个地方并提供一个可运行的项目会很棒。

使用 TreeView 组件和指定的根目录,从根目录的内容创建一个文件树。使用 ImageList 和自定义 IconHelper 类,所有节点都根据文件夹或文件类型分配用户指定的图像。

由于拖放功能在任何文件浏览器中都是必不可少的,因此它集成了拖放功能,既适用于文件管理器本身,也适用于Windows资源管理器。用户可以在文件管理器内部拖放树节点,并且文件可以从Windows资源管理器拖入文件管理器。目前没有实现从文件管理器拖放到外部应用程序或Windows资源管理器的功能,但可以轻松添加。对于较大的文件树,还实现了在拖动时控件的滚动功能。

此外,还有用于剪切、复制、粘贴、删除和创建文件夹的上下文菜单。在文件管理器内部,用户可以剪切、复制、粘贴、删除和创建文件夹。

最后但同样重要的是,它完全支持剪贴板。文件管理器注册为剪贴板查看器,并监视剪切和复制剪贴板事件(没有粘贴事件,因为它们不存在)。当用户从文件管理器外部剪切或复制文件或文件夹时,文件管理器中的粘贴命令会从剪贴板导入数据。

背景

目前,我正在制作一个基于XNA的引擎,需要一种方法来组织和可视化显示当前项目使用的所有文件,所以我决定制作一个简单的文件浏览器来满足我的文件放置需求。这比我想象的要复杂得多。一个多月后,这就是我所得到的。下一步是将运行插入文件的自定义构建事件集成,以获取XNA Framework在插入或添加资产时所需的*.XNB*文件。也许完成这一步后,我也会上传。在制作这个项目时,我从这个网站获得了很大的帮助,我觉得我需要回馈一些东西。这些代码很多都散布在这个网站和互联网上,但我认为将它们集中在一个地方会很好。

使用代码

文件树

FMStart.png

让我们从文件管理器最基本的部分——树视图开始。使用树视图组件和文件浏览器类,EnumerateDirectory 函数使用指定的根路径填充树视图

//Enumerate an individual directories contents.
public void EnumerateDirectory(TreeNode parentNode)
{
    //Attempts to enumerate the directory specified.
    try
    {
        //Holds directory information.
        DirectoryInfo diRootInfo;
        diRootInfo = new DirectoryInfo(parentNode.FullPath + "\\");

        //Loops through directories to enumerate.
        foreach (DirectoryInfo dir in diRootInfo.GetDirectories())
        {
            //Holds new node to add.
            TreeNode node = new TreeNode();
            node.Text = dir.Name;
            node.ImageIndex = 0;
            node.SelectedImageIndex = 0;
            parentNode.Nodes.Add(node);

            //Checks if directory has sub directories or files to add.
            if (HasSubDirectory(node) ||
                HasFiles(node))
                EnumerateDirectory(node);
        }
        //Loop to Enumerates files.
        foreach (FileInfo file in diRootInfo.GetFiles())
        {
            //Creates node and assigns icon.
            TreeNode node = new TreeNode();
            node.Text = file.Name;
            node.ImageIndex = IconHelper.GetImageIndex(file.Extension);
            node.SelectedImageIndex = node.ImageIndex;

            //Adds node.
            parentNode.Nodes.Add(node);
        }//End file enumerate loop.
    }//End try.
    catch (Exception ex)
    {
    }
}//End enumerate directory.

这只是获取根目录,并使用辅助函数检查它是否包含子目录和子文件,然后创建节点并将其作为正确的子节点放置,并为常规和选定的节点设置正确的图像图标。还有一些细节,但这是创建初始文件树的核心代码。

您可能已经注意到,节点的正常和选定图像索引都由图标辅助类设置

//Returns file types image index.
public static int GetImageIndex(string sType)
{
    //Image return switch.
    switch (sType)
    {
        case ".fx":
            return 1;
        /*
        Check for you own custom file types here.
        */
    }
}

这只是一个静态类,包含静态方法,在此版本的返回函数中接受文件类型,并返回该类型文件的图像索引。结合图像列表和您自己的文件图标,您可以将任何图标分配给您想要的任何文件类型或文件夹。

拖放

FMDDOne.png

FMDDTwo.png

FMDDThree.png

文件管理器中最困难和最重要的组件之一是拖放功能。拖放操作从项目拖动事件开始

//Handles the initial item drag.
private void ManagerItemDrag(object sender, ItemDragEventArgs e)
{
    //Hold selected node from mouse pointer.
    TreeNode ndNode = (TreeNode)e.Item;

    //Checks if node pointed to is a file.
    if (ndNode.ImageIndex != 0)
    {
        if (CurrentEffect == DragDropEffects.Move)
            CurrentState = States.Move;
        else
            CurrentState = States.Copy;
    }//End file check.

    //Sets the effect for pass through.
    this.DoDragDrop(e.Item, CurrentEffect);

    //Clears old node highlight.
    if(ndOld != null)
    {
        //Clears old node.
        ndOld.BackColor = Color.White;
        ndOld.ForeColor = Color.Black;
    }
}//End manager item drag.

项目拖动启动了该过程。ItemDragEventArgs 包含正在拖动的数据。在文件管理器内部,e 是一个 TreeNode,而在文件管理器外部,这通常是 DataFormats.FileDrop,它包含一个正在拖动的文件位置的字符串数组。DoDragDrop 将数据发送到整个拖放过程,并且 e 可以更改为 DataFormats.FileDrop,其中包含一个文件名字符串数组,以允许从文件管理器内部拖放到外部的Windows资源管理器或其他应用程序,但目前,我只保留从管理器外部拖放到管理器内部,以及管理器内部的拖放操作,但更改可以很容易地实现。

当用户将内容拖入文件树时,会触发 Drag Enter 事件,这可能来自文件管理器外部或内部,两种情况都已处理

//Handles drag enter event.
private void ManagerDragEnter(object sender, DragEventArgs e)
{
    //Checks for incoming files from outside the manager.
    if (e.Data.GetDataPresent(DataFormats.FileDrop, false) == true)
        e.Effect = DragDropEffects.Copy;
    else//Sets the effect for pass through.
        e.Effect = CurrentEffect;
}//End drag enter.

这只是检查从文件管理器外部或管理器内部拖入的数据,并为通过设置拖放效果。

接下来,我们有一个半可选事件,即 Drag Over 事件。当用户将项目拖过文件管理器并且仍然按住鼠标时,会发生此事件。这用于两件事。首先,它会突出显示被指向的节点,让用户知道如果此时释放鼠标,放置操作会发生在哪里。其次,如果文件树大于视图,它会处理文件树的滚动,让用户可以导航到当前超出范围的文件

//Handles drag over events.
private void ManagerDragOver(object sender, DragEventArgs e)
{
    //Holds point position of mouse cursor.
    Point pt = ((TreeView)sender).PointToClient(new Point(e.X, e.Y));
    //Holds the destination node.
    TreeNode ndNode = ((TreeView)sender).GetNodeAt(pt);

    //Sets effect for pass through.
    e.Effect = CurrentEffect;
            
    //Checks if mouse points to a node.
    if (ndNode != null)
    {
        //Checks if a file node is selected.
        if (ndNode.ImageIndex == 0)
        {
            //Sets scroll over drag node color.
            ndNode.BackColor = Color.DarkBlue;
            ndNode.ForeColor = Color.White;

            //Checks if a new node was selected.
            if ((ndOld != null) && (ndOld != ndNode))
            {
                //Clears old node.
                ndOld.BackColor = Color.White;
                ndOld.ForeColor = Color.Black;
            }//End select check.

            //Sets new old node.
            ndOld = ndNode;
        }//End file node check.
        else if(ndOld != null)
        {
            //Clears old node.
            ndOld.BackColor = Color.White;
            ndOld.ForeColor = Color.Black;
        }
    }//End node pointed to check.
    else if(ndOld != null)
    {
        //Clears old node.
        ndOld.BackColor = Color.White;
        ndOld.ForeColor = Color.Black;
    }

    //Checks for tree scroll.
    ScrollTreeView();
}//End manager drag over.


//Checks for tree scroll.
protected void ScrollTreeView()
{
    const Single scrollRegion = 20;
    // See where the cursor is
    Point pt = tvFileView.PointToClient(Cursor.Position);

    //Checks for scroll down
    if ((pt.Y + scrollRegion) > tvFileView.Height)
        SendMessage(
            tvFileView.Handle, 
            (int)277,
            (IntPtr)1, 
            (IntPtr)0);
    else if (pt.Y < (tvFileView.Top + scrollRegion))//Checks scroll up.
        SendMessage(
            tvFileView.Handle,
            (int)277,
            (IntPtr)0, 
            (IntPtr)0);
}//End scroll tree view.

ScrollTreeView 函数使用光标位置检查边界,并通过Windows程序发送消息,告知文件树滚动视图滚动以及如何滚动。

拖放过程中的最后一个事件是 Drop 事件。其中包含相当多的代码,但为了节省空间,这里是基本概述

//Handles the drop event.
private void ManagerDragDrop(object sender, DragEventArgs e)
{
    //Checks for file drop from outside.
    if (e.Data.GetDataPresent(DataFormats.FileDrop, false) == true)
    {
        //Holds file drop data.
        object oFileDrop = null;
        //Holds clipboard data.
        IDataObject doData = e.Data;
        //Array of filenames present inside clipboard
        oFileDrop = doData.GetData(DataFormats.FileDrop, true);
        //Holds files to copy.
        string[] sFiles = (string[])oFileDrop;
 
        /*
        Do what you wish with the files here 
        by using their paths to operate on them.
        */
    }
    else//Checks for ManagerDragDrop drop.
    {
        //Called last when mouse released during a drop
        bool movingFile = (CurrentEffect == DragDropEffects.Move);
        //Holds node to be copied or moved.
        TreeNode NewNode;

        //Checks for data in the node.
        if (e.Data.GetDataPresent("System.Windows.Forms.TreeNode", false))
        {
            /*
            Do what you wish with the tree node being draged here.
            */
        }
    }
}

如您所见,我们只是检查来自文件管理器外部的文件拖放数据,或者来自文件管理器内部的树节点拖动。要访问所有这些事件,只需进入文件树的事件并用您的自定义函数覆盖所需的事件即可。

上下文菜单

没有老式右键上下文菜单的文件管理器算什么?创建它非常容易。只需在表单设计器中创建一个 ContextMenu 并添加您想要的按钮,然后将其与点击事件关联即可。上下文菜单的第一个功能允许用户在文件管理器内部创建新文件夹

//Handles creation menu item click
private void CreateFolderClickHandler(object sender, EventArgs e)
{
    //Checks if new folder is used.
    if (Directory.Exists(tvFileView.SelectedNode.FullPath + "\\" + "NewFolder"))
    {
        //Flag to tell if file name found.
        bool bFound = false;
        //Counter to add to file name.
        int nCounter = 1;

        //Loops to find usable file name.
        while (!bFound)
        {
            //Checks for unused file name.
            if (!Directory.Exists(
                    tvFileView.SelectedNode.FullPath + "\\" + "NewFolder" + 
                    "(" + nCounter + ")"))
            {
                //Creates directory.
                Directory.CreateDirectory(
                    tvFileView.SelectedNode.FullPath + "\\" + 
                    "NewFolder" + "(" + nCounter + ")");
                //Holds new node.
                TreeNode aNode = new TreeNode(("NewFolder" + 
                    "(" + nCounter + ")"));
                //Sets node image.
                aNode.ImageIndex = 0;
                aNode.SelectedImageIndex = 0;
                //Adds node to file tree.
                tvFileView.SelectedNode.Nodes.Add(aNode);
                //Sets found flag.
                bFound = true;
            }
            else//Increments counter.
                nCounter++;
        }//End file name found check.
    }//End used name check.
    else//Creates new folder.
    {
        //Creates directory.
        Directory.CreateDirectory(tvFileView.SelectedNode.FullPath +
            "\\" + "NewFolder");
        //Holds new node.
        TreeNode aNode = new TreeNode("NewFolder");
        //Sets node image.
        aNode.ImageIndex = 0;
        aNode.SelectedImageIndex = 0;
        //Adds node to file tree.
        tvFileView.SelectedNode.Nodes.Add(aNode);
    }//End new folder creation check.
}//End create click handler.

一个简单的文件插入命令链接到 ContextMenu。我们只是检查是否已经存在名为“New Folder”的文件夹,如果没有,我们就创建一个,或者我们循环找到要附加到末尾的正确数字。

接下来,我们有剪切上下文菜单功能

//Handles cut context menu item click.
private void CutClickHandler(object sender, EventArgs e)
{
    //Sets old node to regular.
    if (ndCutCopy != null &&
        ndCutCopy.ImageIndex >= 18)
    {
        ndCutCopy.ImageIndex = IconHelper.GetIndexFromCut(ndCutCopy.ImageIndex);
        ndCutCopy.SelectedImageIndex = 
            IconHelper.GetIndexFromCut(
                ndCutCopy.SelectedImageIndex);
    }
    //Sets cut node.
    ndCutCopy = tvFileView.SelectedNode;
    tvFileView.SelectedNode.ImageIndex = IconHelper.GetCutIndex(
        tvFileView.SelectedNode.ImageIndex);
    tvFileView.SelectedNode.SelectedImageIndex = IconHelper.GetCutIndex(
        tvFileView.SelectedNode.SelectedImageIndex);
    //Sets cut/copy effect.
    CutCopyEffect = DragDropEffects.Move;

    //Sets clipboard data.
    SetClipboardData();

    //Resets cut / copy variables.
    bClipPaste = false;
    nOperation = 0;
    sCutCopy = null;
}//End cut click handler.

我们只需获取被剪切的节点,并为粘贴和一些其他操作(例如将数据设置到剪贴板,这将在几分钟内介绍)分配效果。

现在,我们有了几乎完全相同的复制选项

//Handles copy context menu item click.
private void CopyClickHandler(object sender, EventArgs e)
{
    //Sets old node to regular.
    if (ndCutCopy != null &&
        ndCutCopy.ImageIndex >= 18)
    {
        ndCutCopy.ImageIndex = 
              IconHelper.GetIndexFromCut(ndCutCopy.ImageIndex);
        ndCutCopy.SelectedImageIndex =
            IconHelper.GetIndexFromCut(
                ndCutCopy.SelectedImageIndex);
    }
    //Sets copy node.
    ndCutCopy = tvFileView.SelectedNode;
    //Sets cut/copy effect.
    CutCopyEffect = DragDropEffects.Copy;

    //Sets clipboard data variables.
    SetClipboardData();

    //Resets cut / copy variables.
    bClipPaste = false;
    nOperation = 0;
    sCutCopy = null;
}//End copy click handler.

这是 SetClipboardData() 函数,它将当前文件以 DataFormat.FileDrop 格式(包含文件路径)设置到剪贴板。现在不必担心它是如何工作的,我们稍后会设置它。只需知道我们使用数据格式和要设置到剪贴板的数据设置一个 DataObject

//Sets file to clipboard.
private void SetClipboardData()
{
    DataObject doData = new DataObject();
    string[] sFile = new string[1];
    sFile[0] = tvFileView.SelectedNode.FullPath;
    doData.SetData(DataFormats.FileDrop, true, sFile);
    Clipboard.SetDataObject(doData, true);
}//End set clipboard data.

接下来是粘贴。这处理了判断我们是从剪贴板粘贴还是从文件管理器内部的树节点粘贴的逻辑

//Handles paste context menu item click.
private void PasteClickHandler(object sender, EventArgs e)
{
    //Checks for clipboard paste.
    if (bClipPaste)
    {
        PasteFromClipboard();
    }//End clipboard past check.
    else//Checks for node copy paste.
    {
        //Checks for cut.
        if (CutCopyEffect == DragDropEffects.Move)
        {
            //Sets to regular image.
            ndCutCopy.ImageIndex = IconHelper.GetIndexFromCut(
                ndCutCopy.ImageIndex);
            ndCutCopy.SelectedImageIndex = 
                IconHelper.GetIndexFromCut(
                    ndCutCopy.SelectedImageIndex);
            //Moves files and folders.
            MoveFileFolder(ndCutCopy, tvFileView.SelectedNode);
            //
            ndCutCopy = null;
            //Sets cut/copy effect.
            CutCopyEffect = DragDropEffects.None;

            //Resets cut / copy variables.
            bClipPaste = false;
            nOperation = 0;
            sCutCopy = null;
        }//End cut check.
        else if (CutCopyEffect == DragDropEffects.Copy)//Checks for copy.
        {
            //Checks for folder.
            if (ndCutCopy.ImageIndex == 0 ||
                ndCutCopy.ImageIndex == 18)
                CopyMoveFolder(ndCutCopy, tvFileView.SelectedNode, true);
            else//Checks for file.
                CopyFile(ndCutCopy, tvFileView.SelectedNode);
            //Resets the cut/copy node.
            ndCutCopy = null;
            //Sets cut/copy effect.
            CutCopyEffect = DragDropEffects.None;

            //Resets cut / copy variables.
            bClipPaste = false;
            nOperation = 0;
            sCutCopy = null;
        }//End copy check.
    }//End node copy paste.
}//End paste click handler.

粘贴函数只是检查我们是从剪贴板粘贴(代码因长度省略;完整代码在项目中)还是从文件管理器内部的树节点粘贴,并判断是剪切还是复制,并处理这些条件。

当然,还有基本的删除功能,用于删除文件和文件夹

//Handles delete context menu click.
private void DeleteClickHandler(object sender, EventArgs e)
{
    //Sets old node to regular.
    if (ndCutCopy != null &&
        ndCutCopy.ImageIndex >= 18)
    {
        ndCutCopy.ImageIndex = IconHelper.GetIndexFromCut(ndCutCopy.ImageIndex);
        ndCutCopy.SelectedImageIndex = 
            IconHelper.GetIndexFromCut(
                ndCutCopy.SelectedImageIndex);
    }
    //Deletes node selected.
    DeleteNodes(tvFileView.SelectedNode);
}//End delete click handler.

还有一个重命名函数,但其代码较长,并处理一些特殊情况,因此请查看源代码以获取完整描述。您必须检查许多特殊情况,例如文件扩展名的删除、重复名称以及其他一些情况,但只需查看源代码,它就会解释所有内容。

剪贴板

FMCBCutOne.png

FMCBCutTwo.png

FMCBCutThree.png

臭名昭著的Windows剪贴板!不是最用户友好的设置,但我们只能利用现有条件 :-)。所以让我们从获取剪贴板访问权限开始。我们需要访问一些 User32.dll 函数

//Imports windows callback loop send message function.
[DllImport("user32.dll")]
private static extern int SendMessage(
    IntPtr hWnd,
    int wMsg, 
    IntPtr wParam, 
    IntPtr lParam);
//Used to set control as a clipboard viewer.
[DllImport("User32.dll")]
protected static extern int SetClipboardViewer(
    int hWndNewViewer);
//Used to unregister as a clipboard viewer.
[DllImport("User32.dll", CharSet = CharSet.Auto)]
public static extern bool ChangeClipboardChain(
    IntPtr hWndRemove, 
    IntPtr hWndNewNext);

首先,我们需要 SendMessage() 函数来访问回调循环,以从 Windows 获取消息,从而知道剪贴板何时执行操作。接下来,我们需要 SetClipboardViewer() 来将我们的文件管理器注册为剪贴板查看器以获取剪贴板事件。最后,我们需要 ChangeClipboardChain() 在我们完成时从查看器链中移除我们的应用程序,并知道链何时已更改。

我们还需要一些定义来了解我们从消息循环中寻找哪些消息,以及一个要设置为查看器链的句柄指针,以便我们成为剪贴板查看器

//Identifier for clipboard change.
const int WM_DRAWCLIPBOARD = 0x308;
//Identifier for clipboard chain change.
const int WM_CHANGECBCHAIN = 0x030D;
//Holds poiter to the next clipboard viewer in the chain.
IntPtr pClipboardViewer;

WM_DRAWCLIPBOARD 是向剪贴板发出剪切或复制操作已发生的信号,而 WM_CHANGECBCHAIN 则表示应用程序已从查看器链中移除。我们还需要注册为剪贴板查看器,因此在构造函数中,我们添加了

//Sets the next clipboard viwer in the chain.
pClipboardViewer = (IntPtr)SetClipboardViewer((int)this.Handle);

我们需要注销文件管理器作为剪贴板查看器,因此我们向 Dispose() 添加以下内容

//Notifies clipboard chain of control destruction.
ChangeClipboardChain(this.Handle, pClipboardViewer);

现在我们需要一个函数来处理 Windows 消息处理过程

//Handles windows procedure messages.
protected override void WndProc(ref Message m)
{
    switch (m.Msg)
    {
        case WM_DRAWCLIPBOARD:
            //Sets clipboard contents.
            SetClipboard();
            SendMessage(pClipboardViewer, m.Msg, m.WParam, m.LParam);
            break;
        case WM_CHANGECBCHAIN:
            if (m.WParam == pClipboardViewer)
                pClipboardViewer = m.LParam;
            else
                SendMessage(pClipboardViewer, m.Msg, m.WParam, m.LParam);
                break;
            default:
                base.WndProc(ref m);
                break;
    } 
}//End wndproc.

好的!现在我们已经注册为剪贴板查看器,并且可以从中获取消息,我们将查看 SetClipboard() 函数,该函数处理当用户在文件管理器外部执行剪切或复制时从剪贴板获取文件数据。

//Handles contents of the clipboard.
private void SetClipboard()
{
    //Holds clipboard data.
    IDataObject doData = Clipboard.GetDataObject();

    //Checks for file drop data.
    if (doData.GetDataPresent(DataFormats.FileDrop, true))
    {
        //Holds file drop data.
        object oClipboard = null;
        MemoryStream msStream = 
           (MemoryStream)doData.GetData("Preferred DropEffect", true);
        //Holds cut or copy bype.
        int nFlag = 0;
        //Trys to get the first byte.
        if (msStream != null)
            nFlag = msStream.ReadByte();

        //Flags to tell if cut or copy clipboard.
        bool bCut = (nFlag == 2);
        bool bCopy = (nFlag == 5);

        //Get array of filenames present inside clipboard
        if (doData != null)
            oClipboard = doData.GetData(DataFormats.FileDrop, true);

        //Sets paste from clipboard flag.
        if (bCut && oClipboard != null)
        {
            //Sets for copy paste.
            bClipPaste = true;
            nOperation = 1;
            sCutCopy = (string[])oClipboard;
        }
        else if (bCopy && oClipboard != null)
        {
            //Sets for cut paste.
            bClipPaste = true;
            nOperation = 2;
            sCutCopy = (string[])oClipboard;
        }
        else
        {
            //Resets cut / copy variables.
            bClipPaste = false;
            nOperation = 0;
            sCutCopy = null;
        }
    }//End file drop data check.
    else//Checks for non file drop data.
    {
        //Resets cut / copy variables.
        bClipPaste = false;
        nOperation = 0;
        sCutCopy = null;

        /*
        Note: If a user has cut a file in the windows explorer
        and does a paste into the explorer it registers here.
        This is by no means a true paste event
        but could be useful, but unreliable.
        */
    }//End non file drop data check.
}//End paste from clipboard.

我们从剪贴板中获取数据,并确保它采用 FileDrop 格式。我们检查第一个字节以查看它是剪切还是复制操作。然后我们从剪贴板数据中获取文件列表。然后我们回到前面讨论的粘贴点击处理程序,让它执行其操作。

复制和移动操作

这就是事情变得复杂的地方。有不同的变体来处理文件和文件夹的剪切(这是一种移动操作)和复制功能。它们可以与树节点或作为字符串提供的文件路径一起使用。它们还检查重复的文件名并相应地填充文件树,以及许多其他功能。您必须查看代码才能看到所有这些功能,因为在文章中格式化会比我能处理的代码更多 ;-)。所有操作都有很好的注释,您应该能够分辨出每个操作的作用和原因。

关注点

Windows 为什么不在剪贴板查看器中注册粘贴事件?那些需要获取此事件的人知道我在说什么 ;-) 这花费了相当多的工作,我也因此学到了很多知识,所以我希望这能帮助到其他走这条路的人。欢迎任何错误报告或修复!

修订

版本 1.1

解决了一些文件访问权限问题。在某些系统上创建目录时,它会限制并阻止一些文件移动和创建功能。进行了修改以阻止这种情况。

© . All rights reserved.