news 2026/9/9 12:20:18

MFC自定义文件对话框实战:详解CFileDialog Hook机制与实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MFC自定义文件对话框实战:详解CFileDialog Hook机制与实现

简介:面向MFC开发者的自定义文件对话框示例工程,解决标准CFileDialog不支持图片预览的痛点,适合需要增强文件选择体验的Windows桌面应用开发者。资源包内含28个文件,以9个头文件和6个cpp源文件为核心,配套Visual Studio工程文件、资源脚本以及可直接运行的exe演示程序,压缩包仅1.64MB,轻量易用。通过继承CFileDialog并重写OnInitDialog、处理文件列表选择消息,结合PictureStatic自绘控件与GDI+图像加载,清晰展示了从对话框扩展、控件布局到图片实时预览的完整实现思路。目前已有329人学习下载,代码注释规范,目录结构简洁,可直接编译运行,也方便抽取其中的自绘预览控件或GDI+封装模块复用到自己的项目中。 做MFC的老哥们应该都有印象,只要项目里涉及文件选择,第一反应就是拖一个CFileDialog上去。这个类用起来确实省事,三行代码就弹出系统标准的打开/保存对话框。可一旦需求变成"把文件对话框改成自己软件的风格,加个预览图、加个业务按钮",事情就没那么可爱了。CFileDialog表面是个封装好的类,背后其实是一整套Windows公共对话框机制,定制入口藏得相当深,网上资料又碎。这篇把我实际项目里"自定义CFileDialog"的完整思路、关键代码和踩过的坑都写出来,给同样被这个需求折磨的朋友一个能直接抄作业的参考。

1. 为什么要把CFileDialog拉出来"魔改":痛点与选型分析

1.1 默认对话框到底哪里不好用

很多业务场景下,系统默认的文件对话框不是"能用"就行的。我遇到过的几种典型需求:

第一类是信息展示需求。比如工程软件里要选择一个图纸文件,光看到文件名不够,最好在对话框里直接预览缩略图;或者选择配置文件时,希望能显示这个文件的格式、大小、修改日期等额外元数据。默认对话框只显示文件名、类型、大小、日期这几列,扩展不了。

第二类是业务操作需求。典型的就是在文件选择界面上加几个业务按钮,比如"打开后立即上传"、"批量导入所选文件"、"记住上次路径"这类。系统对话框默认只有"打开"和"取消",加一个自定义按钮就得走消息路由。

第三类是行为控制需求。比如限制用户只能选择特定目录下的文件、在用户点"打开"之前做合法性校验、根据当前选择动态切换过滤器等。这些光靠设置m_ofn的静态属性不够,必须介入对话框的交互过程。

这些需求的共性在于:都需要和对话框内部控件交互,而CFileDialog没有暴露现成的接口,所以必须理解它底层的机制,从消息层面切入。

1.2 三条自定义路线怎么选

根据定制深度不同,个人把方案分成三档:

轻量档:纯成员配置。通过设置m_ofn结构体的lpstrTitlelpstrFilterlpstrInitialDirFlags等字段,修改标题、过滤器、初始目录、是否只读等。适合只是换换皮的需求,完全不需要额外代码。

中量档:Hook回调 + 通知消息。设置m_ofn.lpfnHook为自定义回调函数,配合OFN_ENABLEHOOK标志,在回调里处理CDN_*系列通知消息,可以拿到对话框各控件句柄,动态创建新控件、读取用户选择、拦截"打开"事件做校验。这是最常用的方案,也是本文重点。

重量档:自定义模板(OFN_ENABLETEMPLATE)或完全自绘。把整个对话框的资源模板换成自己定义的,控件布局完全自定义,系统只负责文件列表和导航逻辑,外观全部自己接管。适合深度换肤,但工作量大,而且很容易踩资源ID冲突的坑,一般项目不推荐一上来就用。

绝大多数"自定义文件对话框"的需求,中量档的Hook方案都够用,而且工程量可控,这也是我后面实操部分采用的方式。

2. 动手前的必备知识:揭开CFileDialog的底层机制

2.1 OPENFILENAME结构体:一把万能钥匙

CFileDialog的核心其实是一个OPENFILENAME结构体,成员变量就是m_ofn。Windows公共对话框的GetOpenFileName/GetSaveFileName函数就是靠这个结构体来确定怎么显示对话框的。所以,无论怎么自定义,第一步都是在这个结构体上做文章。

几个最容易用到的字段:

  • lpstrFilter:文件类型过滤器,格式比较特殊,必须用"描述\0通配符\0描述\0通配符\0\0"的双空字符结尾结构,一处漏了就出现过滤器乱码或空白。
  • lpstrTitle:对话框标题,没什么玄机。
  • lpstrInitialDir:初始目录,注意如果设置了OFN_NOCHANGEDIR标志,对话框切换目录不会影响进程的当前工作目录,这点调试时容易忽略。
  • Flags:一大堆OFN_*标志,比如OFN_FILEMUSTEXIST强制文件必须存在、OFN_ALLOWMULTISELECT允许多选、OFN_OVERWRITEPROMPT保存时覆盖提醒。

CFileDialog还提供GetPathNameGetFileNameGetFileExt这些封装好的接口取结果,但自定义场景下经常需要实时拿用户当前选中的路径,就得靠后面说的通知消息了。

2.2 Explorer风格与Hook通知:定制控件的基础

Windows公共对话框从Win95之后支持Explorer风格,也就是我们在系统里看到的那种左侧有导航栏、右侧是文件列表的样式。这个风格下,lpfnHook回调的机制和传统风格有差异,但总体一致。

关键点是这些CDN_*通知消息。它们以WM_NOTIFY的形式发送给Hook函数,常见的有:

  • CDN_INITDONE:对话框初始化完成,此时可以安全获取各子控件句柄,也是动态创建自定义控件的最佳时机。
  • CDN_SELCHANGE:用户选中了不同的文件,文件列表的选中项变化了。
  • CDN_FOLDERCHANGE:用户切换了目录。
  • CDN_TYPECHANGE:用户切换了文件过滤器类型。
  • CDN_FILEOK:用户点击了"打开"或"保存"按钮,此时有机会拦截并校验路径。
  • CDN_HELP:用户点了帮助按钮。

在Hook函数里收到WM_NOTIFY时,lParam指向OFNOTIFY结构体,其第一个成员nmhdr是标准的NMHDR,里面hwndFrom是文件对话框的窗口句柄,code就是上面的通知码。

要实时获取当前选中文件的完整路径,有个小技巧:向文件对话框的父窗口句柄发送CDM_GETFILEPATH消息。在Hook回调里,hdlg参数是对话框内部的子窗口句柄,要拿到真正能接收CDM_*命令的句柄,需要调用GetParent(hdlg)。这个细节很容易被忽略,不少人直接在hdlg上发CDM_GETFILEPATH,结果返回0,一脸懵。

2.3 别踩Vista风格的坑:选对定制入口

MFC从VS2008之后,CFileDialog默认在Vista及以上系统会走新的IFileDialog接口,这时的文件对话框外观和交互都是新版Windows那种风格,Hook机制和旧的GetOpenFileName完全不同,m_ofn.lpfnHook甚至不会被调用。

这是个特别大的坑。很多人设置了Hook,调试时发现函数压根不进入,就是被这个Vista风格开关坑了。解决方法是在CFileDialog构造函数里设置m_bVistaStyle = FALSE;,强制走旧版公共对话框流程,Hook才能生效。

代价是:这样会让对话框外观退回到经典风格,在Win10/11上看起来比较老气。如果你的软件对UI要求高,又需要自定义控件,那就得走IFileDialog的事件接口那条路(这是一个更复杂的机制,本文先不展开),或者接受经典风格。多数业务系统里,功能优先级高于外观,用m_bVistaStyle = FALSE换取可控性,是务实的选择。

3. 实战:给文件对话框加预览、加业务按钮

3.1 定义一个能挂Hook的CFileDialog派生类

我的做法是写一个CFileDialog的派生类,把自定义逻辑封装进去,使用方只关心业务接口。下面这个例子实现了两个功能:右侧显示选中图片的预览图,底部加一个"记住路径"复选框。

先看头文件里的类声明:

// MyFileDialog.h #pragma once #include <afxdlgs.h> class CMyFileDialog : public CFileDialog { public: CMyFileDialog(BOOL bOpenFileDialog, LPCTSTR lpszDefExt = NULL, LPCTSTR lpszFileName = NULL, LPCTSTR lpszFilter = NULL, CWnd* pParentWnd = NULL); BOOL IsRememberPath() const { return m_bRememberPath; } protected: afx_msg void OnPreviewChanged(); static UINT_PTR CALLBACK FileDialogHook(HWND hdlg, UINT uiMsg, WPARAM wParam, LPARAM lParam); private: CStatic m_wndPreview; // 预览控件(动态创建) CButton m_wndRemember; // 记住路径复选框(动态创建) BOOL m_bRememberPath; // 保存用户勾选结果 CString m_strSelectedPath; // 记录当前选中的文件路径 BOOL m_bInitialized; // 防止重复初始化 };

构造函数里关键是把Hook挂到m_ofn上:

CMyFileDialog::CMyFileDialog(BOOL bOpenFileDialog, LPCTSTR lpszDefExt, LPCTSTR lpszFileName, LPCTSTR lpszFilter, CWnd* pParentWnd) : CFileDialog(bOpenFileDialog, lpszDefExt, lpszFileName, OFN_FILEMUSTEXIST | OFN_EXPLORER | OFN_ENABLESIZING, lpszFilter, pParentWnd) { // 强制关闭Vista风格,否则Hook不会生效 m_bVistaStyle = FALSE; // 指定Hook函数 m_ofn.lpfnHook = FileDialogHook; m_ofn.lCustData = (LPARAM)this; // 启用Hook m_ofn.Flags |= OFN_ENABLEHOOK; m_bInitialized = FALSE; m_bRememberPath = FALSE; }

这里有个传参的关键套路:lCustData可以把this指针传进Hook函数,这样在静态Hook回调里就能通过它访问到当前对象实例,调用成员变量和成员函数。这是C++类封装系统回调的经典做法,不然静态函数里拿不到对象状态,很多动态操作都没法做。

3.2 处理通知消息:核心逻辑写在这里

Hook函数本体是一个静态全局函数,所有通知消息都在这里收。它的任务很明确:把WM_NOTIFY转成对象内部逻辑,动态创建控件这件事放在CDN_INITDONE里做,实时更新预览放在CDN_SELCHANGE里做。

UINT_PTR CALLBACK CMyFileDialog::FileDialogHook(HWND hdlg, UINT uiMsg, WPARAM wParam, LPARAM lParam) { CMyFileDialog* pDlg = NULL; if (uiMsg == WM_NOTIFY) { NMHDR* pNm = (NMHDR*)lParam; if (pNm->code == CDN_INITDONE || pNm->code == CDN_SELCHANGE) { // 通过lCustData拿回对象指针 OPENFILENAME* pOfn = (OPENFILENAME*)((OFNOTIFY*)lParam)->lpOFN; pDlg = pOfn ? (CMyFileDialog*)pOfn->lCustData : NULL; } } else { // 初始化时其实就是WM_NOTIFY(CDN_INITDONE),这里其实不需要 return 0; } if (!pDlg) return 0; switch (uiMsg) { case WM_NOTIFY: { NMHDR* pNm = (NMHDR*)lParam; switch (pNm->code) { case CDN_INITDONE: { // 对话框主窗口句柄 HWND hParent = GetParent(hdlg); CRect rcParent; ::GetWindowRect(hParent, &rcParent); // 在对话框右侧创建预览控件 if (!pDlg->m_wndPreview.GetSafeHwnd()) { pDlg->m_wndPreview.Create(NULL, WS_CHILD | WS_VISIBLE | SS_BITMAP | SS_CENTERIMAGE, CRect(rcParent.Width() - 220, 40, rcParent.Width() - 20, 240), CWnd::FromHandle(hdlg)); } // 在按钮区域附近创建"记住路径"复选框 if (!pDlg->m_wndRemember.GetSafeHwnd()) { pDlg->m_wndRemember.Create(_T("记住当前路径"), WS_CHILD | WS_VISIBLE | BS_AUTOCHECKBOX, CRect(rcParent.Width() - 220, 250, rcParent.Width() - 20, 275), CWnd::FromHandle(hdlg)); } pDlg->m_bInitialized = TRUE; break; } case CDN_SELCHANGE: { // 当前选中的文件路径,要发消息给父窗口 HWND hParent = GetParent(hdlg); TCHAR szPath[MAX_PATH * 2] = {0}; SendMessage(hParent, CDM_GETFILEPATH, MAX_PATH * 2, (LPARAM)szPath); pDlg->m_strSelectedPath = szPath; // 如果是图片类型,加载并显示预览 CString strExt = PathFindExtension(szPath); strExt.MakeLower(); if (strExt == _T(".jpg") || strExt == _T(".png") || strExt == _T(".bmp")) { pDlg->m_wndPreview.SetBitmap((HBITMAP)LoadImage( NULL, szPath, IMAGE_BITMAP, 0, 0, LR_LOADFROMFILE | LR_CREATEDIBSECTION)); } else { pDlg->m_wndPreview.SetBitmap(NULL); } break; } case CDN_FILEOK: { // 在对话框即将关闭时读取复选框状态 pDlg->m_bRememberPath = (pDlg->m_wndRemember.GetCheck() == BST_CHECKED); break; } } break; } } return 0; }

有几个细节需要说明:

  • hwndFrom在这个流程里等于GetParent(hdlg)拿到的句柄,但实际开发中发现直接用GetParent最稳。
  • 动态创建的子控件,父窗口我用的是CWnd::FromHandle(hdlg)hdlg是整个对话框内部的子窗口,不是主窗口,但作为父窗口够用了。如果要让控件出现在文件列表右侧,这个父窗口的坐标原点就以hdlg的客户区为准,所以GetWindowRect拿到的屏幕坐标直接套用即可(因为hdlg本身也是全客户区大小)。
  • CDM_GETFILEPATH是向hParent发送的,这点务必记住,这是最容易踩的坑。
  • CDN_FILEOK里读复选框状态时,要注意可能在某些情况下控件还没创建。稳妥的做法是加个m_bInitialized保护,或者用GetSafeHwnd()先判断。

对于CDN_SELCHANGE里获取路径,还可以用CDM_GETSPEC拿文件名(不含路径),用CDM_GETFOLDERIDLIST拿文件夹的IDList,看业务需要选择。我个人最常用CDM_GETFILEPATH,一步到位拿到完整路径。

3.3 完整效果与保存对话框的差异化处理

调用方式和平时的CFileDialog没什么两样:

CMyFileDialog dlg(TRUE, _T("jpg"), // 默认扩展名 NULL, _T("图片文件(*.jpg;*.png;*.bmp)\0*.jpg;*.png;*.bmp\0") _T("所有文件(*.*)\0*.*\0\0")); if (dlg.DoModal() == IDOK) { CString strPath = dlg.GetPathName(); BOOL bRemember = dlg.IsRememberPath(); // 业务处理... }

如果是保存对话框,逻辑上要额外注意几点:

第一,保存对话框一般要加OFN_OVERWRITEPROMPT标志,系统在用户选择已存在的文件时会自动弹覆盖确认框。这个标志应该放在构造函数里明确设置,别指望用户自己处理覆盖逻辑。

第二,保存场景下的预览功能一般做隐藏处理,因为保存时选中的往往是"要写入的目标文件",不一定存在,预览没有意义。做法是在类里加个BOOL m_bPreviewMode,构造函数传入,CDN_SELCHANGE里判断为保存模式就直接跳过预览逻辑。

第三,CDN_FILEOK里做后缀名兜底。如果用户在保存框里输入了不带扩展名的文件名,可以在这里判断并自动补:

case CDN_FILEOK: { if (!pDlg->m_bOpenMode) { CString strFileName = pDlg->m_strSelectedPath; if (PathFindExtension(strFileName).IsEmpty()) { strFileName += _T(".cfg"); // 注意:通过CDM_SETCONTROLTEXT可以修改编辑框内容, // 但通常直接在DoModal后处理更安全,这里只是提示思路 } } break; }

要真正把修改后的文件名回填到对话框编辑框里,可以发CDM_SETCONTROLTEXT给父窗口,指定设为文件名的那个编辑框的控件ID,但这个ID在不同Windows版本上可能不同,需要动态查找,比较麻烦。更稳妥的方案是不在对话框内修改,而是等DoModal返回后在外部对GetPathName()的返回值做处理后缀名的逻辑。实际项目里我都是后者。

4. 常见问题排查与效率小技巧

4.1 控件不显示、消息收不到怎么办

这是我自己调试过程中最常遇到的问题,基本可以按下面的顺序排查:

  • Hook函数根本没被调用:先检查m_ofn.Flags有没有设OFN_ENABLEHOOK,再检查m_bVistaStyle是否为FALSE。90%的情况是这两个地方之一的配置问题。
  • 控件创建了但不显示:确认Create时指定了WS_VISIBLE,以及父窗口句柄正确。有次我把父窗口误传给了GetParent(hdlg)拿到的句柄,而不是hdlg,结果控件跑到了对话框主窗口外层,死活看不到。
  • CDN_SELCHANGE里取路径失败:确认SendMessage的接收对象是GetParent(hdlg)而不是hdlg。另外,如果对话框还没完成初始化就收到消息,发送命令会失败,代码里要有空值判断。
  • lParam里拿不到OFNOTIFY结构体:因为lParamWM_NOTIFY里才有效。如果在其他消息里试图解释它,会得到野指针。所以lCustData的传递不要在WM_NOTIFY之外的地方做,或者在所有分支都用判断保护。

4.2 文件过滤器格式与双空字符陷阱

lpstrFilter的格式是很多新手容易写错的。正确格式是每项由描述和通配符组成,项与项之间用\0分隔,整个字符串以\0\0结尾。

我用的一些示例写法:

// 两种过滤器 _T("文本文件(*.txt)\0*.txt\0所有文件(*.*)\0*.*\0\0") // 三种过滤器,含多后缀 _T("图片文件(*.jpg;*.png;*.bmp)\0*.jpg;*.png;*.bmp\0") _T("配置文件(*.ini;*.cfg)\0*.ini;*.cfg\0") _T("所有文件(*.*)\0*.*\0\0")

如果字符串写成了_T("文本文件(*.txt)\0*.txt\0所有文件(*.*)\0*.*\0"),末尾只有一个\0,Windows会认为过滤器没有正确结束,弹出的对话框里可能看不到过滤器下拉框,或者过滤器错乱。这类问题用调试器在DoModal前查看m_ofn.lpstrFilter的内存内容最直观,可以用内存窗口确认结尾是否有两个0x00字节。

4.3 多选模式下的路径拼接细节

如果要支持多选,m_ofnlpstrFile缓冲需要足够大,否则用户选了很多文件时会返回不完整甚至失败。官方建议至少MAX_PATH * 32或者更大,实际项目中我一般设置成32768字节。

m_ofn.Flags |= OFN_ALLOWMULTISELECT; m_ofn.lpstrFile = szMultiPath; // 大缓冲区 m_ofn.nMaxFile = 32768;

多选模式下,GetPathName的返回值语义会发生变化。如果只选了一个文件,它返回完整路径;如果选了多个文件,它只返回所在目录,具体文件名要通过GetStartPositionGetNextPathName遍历:

POSITION pos = dlg.GetStartPosition(); while (pos) { CString strFileName = dlg.GetNextPathName(pos); // 逐个处理 }

这个特性很多人会忽略,以为多选时GetPathName会返回第一个文件路径或逗号拼接的路径,直接拿来用就会出现"文件找不到"的怪问题。

还有一点:多选模式下,为了保证缓冲区足够大,最好在构造时就分配一个成员数组,比如在类里加一个成员变量:

TCHAR m_szMultiPath[32768];

m_ofn.lpstrFile指向它,同时记得把缓冲区清零,否则GetOpenFileName返回时可能因为非空字符被截断。这个小细节,网上资料很少提,实际遇到时很让人抓狂。

4.4 一个效率技巧:记住上次路径

很多软件的"记住上次打开路径"功能,用自定义对话框实现其实非常简单。在CDN_FILEOK阶段把当前路径存到注册表或配置文件,下次构造CMyFileDialog时读取并用m_ofn.lpstrInitialDir设置进去即可。

需要注意的是,lpstrInitialDir的优先级在多数Windows版本下低于最近访问目录列表,所以即使设置了它,有时系统仍会打开"最近访问"的文件夹。要强制初始目录生效,可以配合OFN_NOCHANGEDIR标志并手动用SetCurrentDirectory切换工作目录,但这样做会影响整个进程的当前工作目录,副作用比较大。更稳妥的变通是在CDN_INITDONE通知里发CDM_SETCONTROLTEXT给"目录"编辑框,或者直接调用SetCurrentDirectory后再发目录跳转命令。这块因系统版本不同行为有差异,我自己用下来最省心的是接受系统的默认行为,只要大多数情况下能落到指定目录就行。


从我多次折腾自定义文件对话框的经验来看,最值得记住的一条是:先想清楚业务到底需要什么程度的自定义,再决定技术路线。如果只是改标题、改默认目录这种轻需求,别碰Hook;真正需要Hook的场景,基本都是要在文件选择的同时展示或采集业务数据,这种时候m_bVistaStyle = FALSE+OFN_ENABLEHOOK这套组合是性价比很高的方案,虽然外观老一点,但可控、稳定、好调试。等哪天实在接受不了经典外观了,再考虑IFileDialog那套现代接口也不迟。另外,写Hook函数时务必把所有分支都加上空指针保护,毕竟它运行在系统对话框的进程上下文里,一个野指针就可能让整个应用崩掉,而且还不容易复现。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 12:15:27

缓存明明还留着引用,为什么内存紧张时它说没就没了

「Java 进阶之路」系列 Day35 写在前面 前几篇一直在讲垃圾"怎么回收"&#xff08;标记清除/整理/复制、分代收集、CMS/G1/ZGC&#xff09;&#xff0c;但更前置的问题其实是&#xff1a;JVM怎么判断一个对象是不是垃圾&#xff1f;很多人第一反应是"引用计数&…

作者头像 李华
网站建设 2026/9/9 12:15:00

多级指针存在的意义

就以二级指针来说吧。其他多级指针也是类似。 二级指针的定义比较明确&#xff0c;就是指向指针的指针&#xff0c;或者说一级指针变量的地址。 那么&#xff0c;二级指针存在的意义是什么呢&#xff1f; 或者说&#xff0c;为什么需要二级指针呢&#xff1f; 一句话解释&#…

作者头像 李华
网站建设 2026/9/9 12:14:36

LVGL图片转C数组全攻略:从原理到工程落地的完整指南

简介&#xff1a;这份资源是面向嵌入式开发者的 LVGL 图片转化工具&#xff0c;可将 PNG、BMP 等图像文件直接转换为 C 语言数组&#xff0c;帮助基于 LVGL 图形库的 MCU 项目在有限内存下高效加载并显示图片&#xff0c;省去运行时解码开销&#xff0c;适合从事 GUI 开发、低资…

作者头像 李华
网站建设 2026/9/9 12:14:35

JDK 17反射异常InaccessibleObjectException详解与解决方案

最近把一个服务从JDK 8直接升到JDK 17&#xff0c;启动第一分钟就被java.lang.reflect.InaccessibleObjectException拍脸。我猜你现在搜到这&#xff0c;十有八九也是被这一串异常信息折腾得够呛。这篇就把这个异常彻底讲透&#xff1a;它为什么会冒出来、怎么从堆栈里快速定位…

作者头像 李华
网站建设 2026/9/9 12:12:50

LangChain入门指南:核心组件、LCEL、RAG与Agent实战

1. LangChain入门&#xff0c;先搞懂它到底在解决什么问题1.1 没有LangChain的时候&#xff0c;我们是怎么写代码的先聊点实在的。你在接触LangChain之前&#xff0c;肯定写过一段直接调大模型接口的代码&#xff0c;大概长这样&#xff1a;import openaiopenai.api_key "…

作者头像 李华
网站建设 2026/9/9 12:12:20

夜视机芯SDK跨平台集成实战:Android与Linux双平台对接全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华