查看: 29239|回复: 52

[原创]OSG窗口与网页浏览器的嵌合

[复制链接]

该用户从未签到

发表于 2008-3-9 16:10:43 | 显示全部楼层 |阅读模式
OpenSceneGraph与网络浏览器的嵌合,换句话说,就是在用户机的IE等浏览器中加载并显示OSG的渲染窗口。对于试图展示三维设计相关技术的网站而言,这无疑是向客户展示自身能力以及仿真产品的最直观方法;对于从事商品买卖,楼宇销售,地图导航等行业的企业来说,使用OSG在浏览器中实时显示自己的产品,并允许用户使用场景漫游器来即时体验其特色,同样是很好的选择;当然,只要设计合理,OSG同样可以用于网页游戏的制作。
本教程的目的是编写一个MFC ActiveX控件,在其中实现OSG窗口的显示和数据交互,并使用注册OCX的方法将OSG窗口嵌合到浏览器中,实现网页上的三维模型显示和场景漫游功能。本教程不会详细介绍ActiveX控件的高级编程知识,OCX文件的注册方法,以及ActiveX安装程序打包和发放的方法,相信您阅读了这篇拙劣的文章之后,一定可以凭借自己的实力将本教程附带的简陋代码改造和完善,并形成和发布自己的作品。
波尔实验室的Michael Gronager曾实现了一个OSG与网页嵌合的工程:osgAx。该工程基于OSG 0.9.6的版本,原网站为osgax.vr-c.dk,不过很遗憾这个工程已经停止更新,其源代码也无从下载。笔者在多方寻求osgAx源代码无果之后,决定自行从头编写OSG的ActiveX工程,笔者的功力与osgAx的作者恐怕尚不能同日而语,代码中多有错漏之处,请您给予批评和指正。
osgActiveX工程的源代码请在附件中下载。如有任何问题,欢迎您在bbs.osgchina.org发帖讨论;或者直接与我联系:wangray84@gmail.com(不推荐这种方式,因为您遇到的问题也可能是别人想知道的,无论是经验还是困惑,都希望大家一起分享)。

预备工作
有关OpenSceneGraph编程的基本知识,请参看《OpenSceneGraph快速入门指导》一书,以及网络上诸多相关的教程。
有关ActiveX编程以及HTML/VBScript代码实现的更多知识,请参阅网络上繁多的教程,以及各种网络编程相关书籍。
这里我们需要首先建立一个ActiveX工程,对于VS2008的用户,可以选择建立“MFC ActiveX控件”工程,并根据自己的需要设置相关的选项。工程建立后,会自动生成三个类,分别为(假设我们设定工程名为osgMFCAx):
CosgMFCAx:控件程序类。
CosgMFCAxCtrl:控件主窗口类。它是我们代码实现的核心部分,我们将扩展这个类以实现OSG模型的读入,窗口的创建和渲染流程。这个类包括了调度映射(Dispatch Maps)和事件映射(Event Maps)的实现函数,前者提供了外部程序(如浏览器)访问控件的属性和方法,后者用于向控件对象发送事件通知。
CosgMFCAxPropPage:这是控件的属性类,用于显示一个属性对话框并允许用户通过修改参数来改变控件的属性和状态。这里我们暂时不对它进行处理。
此外,系统自动生成的DllRegisterServer和DllUnregisterServer函数用于执行注册和注销控件的操作。这里我们同样不对它进行改动。
这里我们不使用MFC ClassWizard执行添加属性和方法的工作,因为VS2003以后的版本对MFC ClassWizard的支持明显减弱,且笔者以为过于依赖MFC的向导很不利于对程序结构的理解。
ActiveX工程的配置项中,同样需要添加OSG的头文件目录,以及osg.lib等必需的依赖库。而在运行时,除了必要的OCX控件外,也需要在客户机上安装OSG的DLL动态链接库文件,这一点在打包安装程序时需要特别注意。

ActiveX代码实现
首要的问题是:如何在ActiveX控件中嵌入OSG的窗口?
我们可以参考OSG发行版中附带的osgviewerMFC例子,在其中演示了OSG窗口嵌入MFC工程的一种通用思路:
(1)获取MFC窗口的句柄,并据此创建新的图形设备上下文(Graphics Context);
(2)创建一个摄像机,并指定它所使用的GC,视口(Viewport)和透视矩阵;
(3)将摄像机设定为视景器类(Viewer)的主摄像机,从而将OSG视景嵌合到指定的MFC窗口中。
(4)OSG的渲染循环不应当放在MFC的OnDraw或者OnPaint函数中,由于这两个函数只有在窗口需要重绘时方能收到消息,因此无法在其中执行osgViewer::Viewer::frame来渲染场景。此时我们可以建立一个新的线程,在新线程中实现渲染循环,并在程序退出的时候及时终止线程。
这一思路对于ActiveX的编程同样适用。首先,我们需要创建控件窗口的消息响应函数:
  1. afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct);
  2. afx_msg void OnDestroy();
  3. 以及,
  4. BEGIN_MESSAGE_MAP(CosgMFCAxCtrl, COleControl)
  5.         ON_WM_CREATE()
  6.         ON_WM_DESTROY()
  7.         ……
  8. END_MESSAGE_MAP()
复制代码
并编写相应的函数内容。

在OnCreate函数中,我们需要创建OSG窗口的图形上下文,并设置场景渲染的摄像机:
  1. // 获取当前显示窗口的大小
  2. RECT rect;
  3. GetWindowRect(&rect);
  4. // 设置窗口的图形属性,包括窗口的显示大小,以及它所使用的Windows句柄等
  5. // 将窗口属性传递给新建的窗口图形上下文
  6. m_Traits = new osg::GraphicsContext::Traits;
  7. osg::ref_ptr<osg::Referenced> windata =
  8.         new osgViewer::GraphicsWindowWin32::WindowData( GetSafeHwnd() );
  9. m_Traits->x = 0;
  10. m_Traits->y = 0;
  11. m_Traits->width = rect.right - rect.left;
  12. m_Traits->height = rect.bottom - rect.top;
  13. ……
  14. m_Traits->inheritedWindowData = windata;
  15. osg::GraphicsContext* gc =
  16.         osg::GraphicsContext::createGraphicsContext( m_Traits.get() );
  17. // 将窗口图形上下文传递给新建的摄像机,并设置摄像机的视口,透视矩阵等参数
  18. osg::ref_ptr<osg::Camera> camera = new osg::Camera;
  19. camera->setGraphicsContext(gc);
  20. camera->setViewport(
  21.         new osg::Viewport(m_Traits->x, m_Traits->y, m_Traits->width, m_Traits->height) );
  22. camera->setProjectionMatrixAsPerspective( 30.0f,
  23.         (double)m_Traits->width/(double)m_Traits->height, 1.0f, 10000.0f );
  24. // 设置观察场景的视景器,将新的摄像机设置为场景的主摄像机
  25. // 这里我们还设置了视景器线程模型,由于我们是使用自建的线程来执行渲染,
  26. // 因此建议使用单线程的模型,以避免处理场景时出现问题
  27. m_Viewer = new osgViewer::Viewer;
  28. m_Viewer->setThreadingModel( osgViewer::Viewer::SingleThreaded );
  29. m_Viewer->setCamera( camera.get() );
  30. m_Viewer->setCameraManipulator( new osgGA::TrackballManipulator );
  31. // 这里我们将一个空节点赋予场景,稍后加载模型时,将模型节点追加到该节点
  32. // 的子节点位置;卸载模型时亦照此办理,以便于控制场景的节点树
  33. m_Root = new osg::Group;
  34. m_Viewer->setSceneData( m_Root.get() );
  35. m_Viewer->realize();
  36. // 开启新的渲染线程
  37. _beginthread(&RenderThread, 0, m_Viewer);
复制代码
注意这里我们自行开启了一个新的线程,用于执行OSG的渲染循环,线程执行的函数名为void RenderThread(void* ptr),这里它不属于任何类,当然您也可以参照osgviewerMFC的例子,将其归于某个类的成员函数。
编写RenderThread的内容:
  1. osgViewer::Viewer* viewer = (osgViewer::Viewer*)ptr;
  2. while( !viewer->done() )
  3. {
  4.         viewer->frame();
  5. }
  6. _endthread();
复制代码
然后不要忘记在类的析构函数中关闭新建的线程以及OSG的渲染线程,以免出现错误以及资源不能释放的情况:
  1. m_Viewer->setDone( true );
  2. Sleep(1000);
  3. m_Viewer->stopThreading();
复制代码
现在我们的控件应该可以运行了,使用VS2008自带的“ActiveX控件测试容器”加载新的osgMFCAx控件,可以看到空白的程序界面。如果急于验证执行结果的话,也可以向m_Root加载一个本地的模型文件,例如cow.osg,并观察模型加载的效果。

这之后是读入文件的处理,我们希望要加载的文件名是从用户在网页上的交互输入传递而来,因此,我们需要编写相应的调度映射函数,从HTML部分获取文件名,并设法传递给OSG的readNodeFile函数。
设置调度映射函数如下:
  1. afx_msg BSTR GetFile();
  2. afx_msg void LoadFile(LPCTSTR lpszName);
  3. afx_msg void ResetOSG();
复制代码
它们的作用分别是:获取当前加载文件的信息并传递给HTML;从HTML获取文件名并进行加载;重新设置OSG的窗口大小。
同时设置,
  1. BEGIN_DISPATCH_MAP(CosgMFCAxCtrl, COleControl)
  2.         DISP_PROPERTY_EX(CosgMFCAxCtrl, "GetFileName", GetFile, LoadFile, VT_BSTR)
  3.         DISP_FUNCTION(CosgMFCAxCtrl, "ResetOSG", ResetOSG, VT_EMPTY, VTS_NONE)
  4. ……
  5. END_DISPATCH_MAP()
复制代码
其中GetFile和LoadFile对应控件的一个属性GetFileName,而ResetOSG则对应了控件的一个方法ResetOSG。在HTML代码中,我们可以使用这些属性和方法来实现与用户与控件的交互工作,但前提是设置了相应的IDL程序接口。打开系统自动生成的osgMFCAx.idl文件,在主调度接口的相应位置编写属性和方法的接口,如下:
  1. dispinterface _DosgMFCAx
  2. {
  3.         properties:
  4.                 [id(1)] BSTR GetFileName;
  5.         methods:
  6.                 [id(2)] void ResetOSG();
  7.                 [id(DISPID_ABOUTBOX)] void AboutBox();
  8. };
复制代码
上述工作完成之后,我们再着重讨论一下LoadFile函数的内容。它的作用是获取网页的输入(事实上也就是函数的输入参数lpszName)。为了满足UNICODE编码的格式要求,我们需要恰当地转换输入参数的类型(const TCHAR*是不能直接输入到readNodeFile的),例如采用下面的方法:
  1. setlocale( LC_ALL, "chs" );
  2. wcstombs_s( NULL, m_Filename, 255, lpszName, 255 );
复制代码
其中m_FileName是char*型的成员变量,在GetFile函数中,我们也可以把这个变量的值返回给HTML部分并显示给用户。这之后我们可以使用函数osgDB::readNodeFile加载模型并显示于场景中了:
  1. osg::ref_ptr<osg::Node> node = osgDB::readNodeFile( m_FileName );
  2. node->setDataVariance( osg::Object::DYNAMIC );
  3. m_Root->addChild( node.get() );
  4. // 重新计算漫游器的原点位置并重置漫游器
  5. // 这样做的目的是避免在读入某个模型时,因为视角的当前方位不当
  6. // 以致我们不便于观察模型
  7. m_Viewer->getCameraManipulator()->computeHomePosition();
  8. m_Viewer->getCameraManipulator()->home( 0.0 );
复制代码
这里注意两个问题:加载新模型时,旧有节点的处理;以及加载失败时,如何给出恰当的错误信息。
第一个问题可以使用osg::ref_ptr的机制来解决:由于OSG使用了内存引用计数机制,因此当某个节点没有被任何节点或场景引用时,它会被自动删除和释放。因此,我们可以在加载新模型之前,使用removeChildren函数删除m_Root节点的现有子节点,从而将旧有的节点从内存中清除,避免因为没有及时释放内存空间而造成内存泄漏。
第二个问题可以通过改变模型文件读取机制来解决,例如下面的代码:
  1. osg::ref_ptr<osg::Node> node;
  2. osgDB::ReaderWriter::ReadResult rr =
  3.         osgDB::Registry::instance()->readNode( m_Filename, osgDB::Registry::instance()->getOptions() );
  4. if ( rr.validNode() )
  5.         node = rr.takeNode();
  6. if ( !node )
  7. {
  8.         ……
  9.         return;
  10. }
复制代码
如果节点加载失败,我们就可以将rr.message()的内容或者自定义的错误文字显示出来,或者通过自定义的控件属性和方法传递给HTML部分。
我们还可以编写图片文件的读入函数,注意此时需要自定义一个四边形面片用于显示图片纹理。具体的实现请参见附带代码。

该用户从未签到

 楼主| 发表于 2008-3-9 16:15:08 | 显示全部楼层

[原创]OSG窗口与网页浏览器的嵌合(续)

HTML代码实现
现在我们再讨论一下浏览器端HTML代码的实现。要在浏览器中观察OSG的场景,首先需要加载刚刚编译完成的OCX控件。通用的方法为:进入命令行方式,并输入
  1. regsvr32 osgMFCAx.ocx
复制代码
系统会提示已经装入控件。
卸载控件的命令为:
  1. regsvr32 /u osgMFCAx.ocx
复制代码
如果需要自行制作三维模型网页浏览的插件安装包,可以参考这两个命令进行OSG控件的注册和注销;此外还需要将ocx文件和OSG所需的DLL文件拷贝到系统文件夹下,以免控件运行时找不到必需的动态链接库。
这里我们采用一种较为简单的方法来即时注册OCX控件,以免给程序的调试带来不必要的麻烦。
  1. <OBJECT
  2.   classid=  "clsid:9AB36F74-9505-4B3E-A9D6-6294F67C804D"
  3.   id=       OSGOcx
  4.   codebase= osgMFCAx.ocx
  5.   width=    640
  6.   height=   480 >
  7. </OBJECT>
复制代码
注意这里的classid是从源文件的IMPLEMENT_OLECREATE_EX一行中获取的;而codebase所指定的文件名必须与HTML网页文件在同一文件夹下,或者指定其所在路径也可以。(如果要使用regsvr32来注册控件,则不必定义codebase属性)
我们在这个简单的HTML网页中,添加用于输入窗口高度/宽度的输入框(txtWidth和txtHeight)和确定按钮(btnResize),加载文件名的输入框(txtFile)和确定按钮(txtOK),以及已加载文件的显示框(txtLoaded,只读)。并分别编写网页加载时,以及命令按钮按下时的VBScript代码:
  1. <SCRIPT LANGUAGE="VBScript">
  2. <!--
  3. Sub Window_onLoad()
  4.   OSGOcx.GetFileName = "cow.osg"
  5.   txtLoaded.Value = OSGOcx.GetFileName
  6.   OSGOcx.ResetOSG
  7. End Sub

  8. Sub btnOK_onClick
  9.   OSGOcx.GetFileName = txtFile.Value
  10.   txtLoaded.Value = OSGOcx.GetFileName
  11. End Sub

  12. Sub btnResize_onClick
  13.   OSGOcx.width = CInt( txtWidth.Value )
  14.   OSGOcx.height = CInt( txtHeight.Value )
  15.   OSGOcx.ResetOSG
  16. End Sub
  17. -->
  18. </SCRIPT>
复制代码
从代码中可以看出,之前我们所编写的控件属性GetFileName和方法ResetOSG在这里都已经得到了应用,其中GetFileName即可用作输入也可用作输出。

运行效果
运行时需要首先允许ActiveX的启动,点击IE浏览器窗口顶端的黄色提示条并选择“允许阻止的内容”即可,然后我们还需要允许控件与网页的交互,在随后出现的对话框中选择“是”。出现如图1的界面。
这里的cow.osg是本地机的文件,它应当保存在系统PATH环境变量或者OSG_FILE_PATH环境变量所指定的文件路径中。我们可以在文件名输入框中输入新的文件名称并点击“加载模型”观察效果。注意这里同样可以输入URL地址来显示网络上的模型或图片,输入
  1. http://www.osgchina.org/img/osg_banner.jpg
复制代码
出现如图2的界面。

图1

图1

图2

图2

该用户从未签到

 楼主| 发表于 2008-3-9 16:23:44 | 显示全部楼层

教程下载,源代码与示例程序

这里提供的教程,源代码以及示例程序均为个人原创(我没有找到osgAx那个古老的工程,所以想抄也没办法抄~~ )。
如果您需要转载我提供的教程和代码,请注明原作者和osgChina网站;如果希望直接使用我所写的程序(事实上这不太可能,因为没有完成的工作太多了),请不要用于商业目的。
欢迎您改写这个例子,或者提供您自己开发的ActiveX控件,并对我的代码提出批评和指正。

源代码工作环境:Windows XP SP2,VS 2005 SP1,OpenSceneGraph 2.3.x(3月5日的SVN版本)。

OSG窗口与IE浏览器的嵌合.pdf

340 KB, 下载次数: 1515, 下载积分: 威望 1

教程PDF文档

osgActiveX.rar

29.68 KB, 下载次数: 1151, 下载积分: 威望 1

VS2008源代码

HTMLexample.rar

49.53 KB, 下载次数: 1279, 下载积分: 威望 1

示例程序

该用户从未签到

发表于 2008-3-9 22:06:52 | 显示全部楼层
beautiful~~~~~~~ :) :)

该用户从未签到

发表于 2008-3-11 16:41:52 | 显示全部楼层
强烈支持!!

该用户从未签到

发表于 2008-3-12 09:01:30 | 显示全部楼层

Cool.....
  • TA的每日心情
    开心
    2019-11-11 10:36
  • 签到天数: 2 天

    [LV.1]初来乍到

    发表于 2008-3-12 10:03:45 | 显示全部楼层
    有时间整理一下放到前面,,,这段时间太忙了~~~~~~

    该用户从未签到

    发表于 2008-3-12 11:09:36 | 显示全部楼层
    牛!实在是牛!

    该用户从未签到

    发表于 2008-3-12 12:41:40 | 显示全部楼层
    支持

    该用户从未签到

    发表于 2008-3-12 21:25:17 | 显示全部楼层
    强呀,我也正在学OSG,好东西呀!

    该用户从未签到

    发表于 2008-3-18 21:49:26 | 显示全部楼层
    强,正在想是否能将osg和网页结合,好好学学!
  • TA的每日心情
    开心
    2019-11-11 10:36
  • 签到天数: 2 天

    [LV.1]初来乍到

    发表于 2008-3-19 08:13:31 | 显示全部楼层

    该用户从未签到

    发表于 2008-3-21 15:42:52 | 显示全部楼层
    very good!

    该用户从未签到

    发表于 2008-3-25 10:23:16 | 显示全部楼层
    太强了
  • TA的每日心情
    开心
    2019-11-11 10:36
  • 签到天数: 2 天

    [LV.1]初来乍到

    发表于 2008-3-25 13:44:13 | 显示全部楼层
    未命名.JPG 发一张用AX插件的图:

    该用户从未签到

    发表于 2008-4-20 21:07:53 | 显示全部楼层
    佩服犹如滔滔江水绵绵不绝

    该用户从未签到

    发表于 2008-7-7 19:52:38 | 显示全部楼层
    强烈支持

    该用户从未签到

    发表于 2008-10-2 15:35:17 | 显示全部楼层

    回复 楼主 的帖子

    有没有办法实现插件向网页发送信息呢?
    例如我在那个osgviewer中把鼠标放到牛的身体上,就会有关于牛的信息显示在网页的一侧

    该用户从未签到

     楼主| 发表于 2008-10-2 20:30:26 | 显示全部楼层
    原帖由 showland 于 2008-10-2 15:35 发表
    有没有办法实现插件向网页发送信息呢?
    例如我在那个osgviewer中把鼠标放到牛的身体上,就会有关于牛的信息显示在网页的一侧


    这个在ActiveX插件里面实现就可以了,比如新增几个dispinterface交互函数什么的,然后编写它们的交互内容,可以参考代码中GetFileName函数的实现

    该用户从未签到

    发表于 2008-10-2 21:30:22 | 显示全部楼层
    多谢你,我尝试一下~~

    该用户从未签到

    发表于 2008-10-9 14:24:04 | 显示全部楼层
    有没有在activex中实现界面控件,比如加入 cegui

    该用户从未签到

    发表于 2008-10-15 15:21:34 | 显示全部楼层
    报这个错误,应该如何解决啊?
    Project : error PRJ0050: 未能注册输出。请确保您有修改注册表的相应权限。

    在这个例子中,
    http://blog.csdn.net/zhuqinglu/archive/2008/03/10/2162433.aspx,我也出现了同样的错误哦。

    该用户从未签到

    发表于 2008-10-21 13:48:24 | 显示全部楼层
    用其他机子我就没有问题了,很顺利就生成了.ocx文件。
    网上解决这个错误的方法我找了一大堆,结果没一个能用的。晕了。
    估计是我的环境哪里出问题了。

    该用户从未签到

    发表于 2008-10-21 14:00:08 | 显示全部楼层
    我也遇到过,debug版本的是有问题,改成release版本就可以了
    可能是库文件或者dll文件有缺或者损坏~~

    该用户从未签到

    发表于 2008-10-21 17:06:57 | 显示全部楼层
    First all, I'm new to MFC. I was building an ActiveX project, then after some added code I compiled but got that creepy PRJ0050 error.

    I read in one msdn page that the error could be caused by the VS engine in the final compilation phase, when trying to register the component, and that I should try registering the activeX using REGSVR32. After that I should be able to see why the registration failed.

    I tried that and used REGSVR32. It threw a "process not found" error which I thought was somewhat strange. After that I opened the activeX with the dependency walker (DEPENDS.EXE) to check the dependencies and saw that a reference to ADVAPI32 was with a red warning. hmmm....

    I came back to my source files and saw that the last added code was a call to the function RegGetValue, which IS defined in the winreg.h header file. I opened ADVAPI32.DLL with the dependency walker and saw that the function RegGetValue does not exist there, so I realized that another function had to be used instead: RegQueryValueEx. After changing that line of code I could compile the project successfully.

    我在网上找到这段话,用depends.exe工具找出了原因。

    该用户从未签到

    发表于 2008-10-21 17:18:12 | 显示全部楼层
    我现在装的是osg 2.6.
    生成解决方案时,编译环境报这个错误。
    Project : error PRJ0050: 未能注册输出。请确保您有修改注册表的相应权限。
    在denpends.exe中报错误:
    Error: At least one required implicit or forwarded dependency was not found.
    Warning: At least one module has an unresolved import due to a missing export function in a delay-load dependent module.
    系统找不到指定的文件:
    OSG35-OSGD.DLL

    该用户从未签到

    发表于 2008-10-21 17:19:48 | 显示全部楼层
    系统找不到指定的文件:
    OSG35-OSGD.DLL
    OSG35-OSGBD.DLL
    OSG35-OSGAD.DLL
    OSG35-OSGVIEWERD.DLL

    osg 2.6里都是44,它却提示找不到35。
    匪夷所思。我强制把这些文件改成35看看,生成就成功了。

    该用户从未签到

    发表于 2008-10-21 17:57:38 | 显示全部楼层
    找朋友帮我解决了。
    是因为我把原来osg 2.4的一些东西拷到第三方插件里面去了。
    这里面有优先级。所以就出错了。
    把那个PATH里面第三方插件的路径给删除掉。程序就没问题了。

    该用户从未签到

    发表于 2009-1-10 22:24:28 | 显示全部楼层
    你好,我遇到了一点问题。我的模型贴图比较大的时候,在ie浏览器里就不能显示贴图,是一个黑乎乎的界面,但是地形是载入进来了,用傲游浏览器的时候第一次打开模型可以清晰的显示 ,但是重新打开一次页面的时候,又不能显示贴图了,若是关闭傲游浏览器,重新再载入,也是没有问题,请问应该如何解决呢?
    我的贴图大概200都MB

    该用户从未签到

    发表于 2009-6-4 17:47:50 | 显示全部楼层
    您好我想问一下在osg2.8中,我想编译这个,结果发现fatal error C1083: 无法打开包括文件:“osgViewer/GraphicsWindowWin32”: No such file or directory,是不是在2.8+vs2008中这个文件没有进行编译,我在源文件中看却是有这个的
    您需要登录后才可以回帖 登录 | 注册

    本版积分规则

    OSG中国官方论坛-有您OSG在中国才更好

    网站简介:osgChina是国内首个三维相关技术开源社区,旨在为国内更多的技术开发人员提供最前沿的技术资讯,为更多的三维从业者提供一个学习、交流的技术平台。

    联系我们

    • 工作时间:09:00--18:00
    • 反馈邮箱:1315785073@qq.com
    快速回复 返回顶部 返回列表