1. 为什么要在对话框里折腾打印?聊聊我的踩坑经历
很多刚接触MFC的朋友,包括当年的我自己,一听到“打印”两个字就头大。文档/视图架构下,打印功能有现成的框架,向导一点,基类一继承,好像还挺简单。但一到对话框程序里,就傻眼了——菜单栏没有“文件”->“打印”,视图类那套现成的OnPreparePrinting、OnPrint也用不上。难道为了一个打印功能,要把整个程序架构改成文档/视图吗?这显然不现实。
我接手过不少老项目,清一色的对话框应用,客户的需求却一个比一个“硬核”:要打印报表、要打印凭证、要支持预览、还要能选纸张和打印机。一开始我也尝试过偷懒,比如直接用CDC画到打印机上,或者调用ShellExecute打开文档让系统打印。但前者代码又臭又长,难以维护;后者用户体验割裂,而且无法精确控制打印内容和布局。踩过几次坑之后,我明白了:在MFC对话框里实现一套专业、可控、用户体验好的打印功能,不是可选项,而是企业级应用的必选项。
所以,今天我就把自己这些年摸爬滚打总结出来的“对话框打印实战手册”分享给你。我们不搞那些虚头巴脑的理论,直接上干货,从打印预览窗口的创建,到纸张参数的动态设置,再到最终打印输出的每一行关键代码,我都会掰开揉碎了讲清楚。目标就一个:让你看完就能在自己的对话框项目里,复制粘贴,稍作修改,立刻拥有一个媲美专业软件的打印模块。
2. 核心思路:借壳生蛋,构建我们自己的打印框架
原始文章给了一个非常棒的起点,它跳出了MFC文档视图的束缚,自己搭建了一套打印框架。但它的代码更像是一个“黑盒”,直接甩给你六个文件,你可能知其然不知其所以然。我们先来把它的核心思路捋清楚,这比直接看代码更重要。
MFC的打印和预览,本质上依赖于两个核心类:CView(特别是它的OnPrint等虚函数)和CFrameWnd(作为预览的容器窗口)。在对话框程序里,我们没有CView,也没有一个现成的框架窗口来承载预览界面。那怎么办?自己造。
原始代码的思路我称之为“借壳生蛋”:
- 创建一个自定义的框架窗口(
CPrintFrame):这个窗口不显示菜单栏、工具栏,它唯一的作用就是作为我们打印视图的“宿主”或“舞台”。当我们需要预览或打印时,就动态创建这个框架窗口。 - 创建一个自定义的打印视图(
CPrintView):它继承自CScrollView(或CView),我们把所有打印相关的逻辑,比如设置纸张、准备打印、绘制内容,都写在这个类里。这个视图会被嵌入到上面创建的CPrintFrame中。 - 创建一个自定义的预览视图(
CPrintPreviewView):继承自MFC的CPreviewView,主要目的是为了重写“关闭预览”和“直接打印”等按钮的行为,使其能和我们自定义的框架窗口正确交互,避免窗口句柄混乱导致程序崩溃。
简单来说,当用户在对话框上点击“打印预览”时,我们动态创建一个CPrintFrame,在里面创建一个CPrintView,然后命令这个视图进入预览模式。MFC内置的预览机制就会接管,调用我们重写的OnDraw、OnPrint来渲染每一页。整个过程对主对话框来说是透明的,预览窗口关闭后,一切恢复原状。
这个架构的巧妙之处在于,它最大程度复用了MFC内置的强大打印和预览机制,我们只需要关心“画什么”,而不用重新发明“怎么预览”、“怎么走打印对话框”这些轮子。下面,我们就一步步把这个架构搭建起来。
3. 第一步:搭建舞台——创建自定义打印框架窗口(CPrintFrame)
这个类是整个打印功能的“后台总控”。我们来看关键代码,并解释每一处设计的原因。
首先在头文件CPrintFrame.h里,我们这样定义:
#pragma once
#include "CPrintView.h" // 包含我们即将实现的打印视图
// ... 其他必要的包含
class CPrintFrame : public CFrameWnd
{
DECLARE_DYNCREATE(CPrintFrame)
protected:
CPrintFrame(); // 动态创建所需的默认构造
virtual ~CPrintFrame();
public:
// 关键构造函数!传入主对话框指针和预览标志
CPrintFrame(CYourMainDialog* pMainDlg, BOOL bPreview);
// 成员变量
CYourMainDialog* m_pMainDlg; // 指向主对话框,用于数据获取和窗口恢复
CPrintView* m_pCPrintView; // 我们自定义的打印视图
BOOL m_bPreview; // 标志:TRUE为预览,FALSE为直接打印
protected:
DECLARE_MESSAGE_MAP()
public:
afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); // 窗口创建时初始化
afx_msg void OnClose(); // 关闭窗口时的清理
void SetOrientation(short orientation); // 设置纸张方向(横向/纵向)
virtual BOOL PreCreateWindow(CREATESTRUCT& cs); // 窗口创建前的样式设置
};
重点在实现文件CPrintFrame.cpp的构造函数和OnCreate函数:
CPrintFrame::CPrintFrame(CYourMainDialog* pMainDlg, BOOL bPreview)
: m_pMainDlg(pMainDlg), m_pCPrintView(NULL), m_bPreview(bPreview)
{
CString strTitle = bPreview ? _T("打印预览") : _T("打印");
// 创建一个隐藏的框架窗口,不显示标准菜单/工具栏
Create(NULL, strTitle, WS_OVERLAPPEDWINDOW | FWS_ADDTOTITLE,
CRect(100, 100, 900, 700), NULL, NULL, 0, NULL);
}
int CPrintFrame::OnCreate(LPCREATESTRUCT lpCreateStruct)
{
if (CFrameWnd::OnCreate(lpCreateStruct) == -1)
return -1;
if (!m_pMainDlg) return -1; // 安全检查
// 1. 创建打印视图(CPrintView)的上下文
CCreateContext context;
context.m_pNewViewClass = RUNTIME_CLASS(CPrintView); // 告诉框架要创建什么类
context.m_pCurrentFrame = this; // 当前框架就是自己
context.m_pCurrentDoc = NULL; // 对话框程序没有文档,设为NULL
context.m_pLastView = NULL;
// 2. 动态创建CPrintView对象,并嵌入到本框架中
m_pCPrintView = (CPrintView*)CreateView(&context);
if (!m_pCPrintView) return -1;
// 3. 将新建的视图设为活动视图
SetActiveView(m_pCPrintView);
m_pCPrintView->ShowWindow(SW_SHOW);
m_pCPrintView->m_pCPrintFrame = this; // 让视图知道它的框架窗口是谁
// 4. 关键一步:将应用程序的主窗口指针暂时指向自己
// 这样,打印预览的工具栏、菜单命令才能正确路由到这个新框架
CWinApp* pApp = AfxGetApp();
pApp->m_pMainWnd = this;
// 5. 根据是预览还是打印,触发不同的流程
if (m_bPreview) {
// 隐藏主对话框,避免用户误操作
m_pMainDlg->ShowWindow(SW_HIDE);
// 命令打印视图开始预览模式
m_pCPrintView->OnFilePrintPreview();
} else {
// 直接发送打印命令
m_pCPrintView->SendMessage(WM_COMMAND, ID_FILE_PRINT);
}
return 0;
}
这里有个巨坑我当年踩了好久:AfxGetApp()->m_pMainWnd的切换。MFC的消息路由、命令更新机制都依赖于这个全局主窗口指针。如果不把它临时改成我们的CPrintFrame,预览窗口的“下一页”、“放大”等按钮可能根本没反应,或者消息发错了对象。记住,在OnClose时一定要把它改回去!
void CPrintFrame::OnClose()
{
// 恢复主窗口指针到原来的对话框!!!
CWinApp* pApp = AfxGetApp();
pApp->m_pMainWnd = m_pMainDlg;
// 显示主对话框
if (m_pMainDlg) m_pMainDlg->ShowWindow(SW_SHOW);
// 销毁自己
DestroyWindow();
}
SetOrientation函数展示了如何动态修改打印机设置,这在用户需要切换横版/竖版打印时非常有用:
void CPrintFrame::SetOrientation(short orientation) // DMORIENT_PORTRAIT 或 DMORIENT_LANDSCAPE
{
PRINTDLG pd = {0};
pd.lStructSize = sizeof(PRINTDLG);
// 获取当前打印机的默认设置
if (AfxGetApp()->GetPrinterDeviceDefaults(&pd)) {
if (pd.hDevMode != NULL) {
LPDEVMODE lpDevMode = (LPDEVMODE)::GlobalLock(pd.hDevMode);
if (lpDevMode) {
lpDevMode->dmOrientation = orientation; // 修改方向
// 同样,你可以在这里修改 dmPaperSize (纸张大小,如DMPAPER_A4)
// lpDevMode->dmPaperSize = DMPAPER_A4;
::GlobalUnlock(pd.hDevMode);
}
}
}
}
4. 第二步:灵魂画手——实现自定义打印视图(CPrintView)
这个类是真正干活的,负责告诉打印机“画什么”。它继承自CScrollView,这样还能顺便支持滚动视图(虽然打印预览时不一定需要)。我们重点关注几个必须重写的虚函数。
在CPrintView.h中,我们声明核心函数:
class CPrintView : public CScrollView
{
// ... 其他宏和构造函数
public:
CPrintFrame* m_pCPrintFrame; // 回指框架窗口
// 核心打印相关函数
virtual BOOL OnPreparePrinting(CPrintInfo* pInfo); // 打印初始化
virtual void OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo); // 开始打印(可创建GDI资源)
virtual void OnPrint(CDC* pDC, CPrintInfo* pInfo); // 绘制每一页!重中之重
virtual void OnEndPrinting(CDC* pDC, CPrintInfo* pInfo); // 结束打印(清理资源)
virtual void OnEndPrintPreview(CDC* pDC, CPrintInfo* pInfo, POINT point, CPreviewView* pView); // 结束预览
// 工具函数
void SetPaper(short dmOrientation, short dmPaperSize); // 设置纸张
void OnFilePrintPreview(); // 触发预览
void OnFilePrint(); // 触发打印
// 你的实际绘制函数
void DrawMyReport(CDC* pDC, CPrintInfo* pInfo); // 例如,绘制报表
void DrawMyCertificate(CDC* pDC, CPrintInfo* pInfo); // 例如,绘制证书
};
OnPreparePrinting是打印流程的起点,在这里我们可以设置打印对话框的默认参数,比如最大页数:
BOOL CPrintView::OnPreparePrinting(CPrintInfo* pInfo)
{
// 默认最大页数为1,如果你有多页,就在这里计算并设置
pInfo->SetMaxPage(1);
// 调用DoPreparePrinting会弹出系统的打印设置对话框
// 如果不想弹出对话框直接打印,可以设置 pInfo->m_bDirect = TRUE;
pInfo->m_bDirect = FALSE;
return DoPreparePrinting(pInfo);
}
OnPrint是最核心的函数,每一页的内容都在这里绘制。pDC是打印机或预览的设备上下文,你像在屏幕上画图一样操作它就行,但必须注意坐标转换。
void CPrintView::OnPrint(CDC* pDC, CPrintInfo* pInfo)
{
// 千万别调用基类的OnPrint,我们要自己控制一切
// CScrollView::OnPrint(pDC, pInfo);
// 示例:根据不同的打印任务,调用不同的绘制函数
// 可以从主对话框获取一个任务标识
int nPrintTask = m_pCPrintFrame->m_pMainDlg->GetCurrentPrintTask();
switch(nPrintTask) {
case TASK_REPORT:
DrawMyReport(pDC, pInfo);
break;
case TASK_CERTIFICATE:
DrawMyCertificate(pDC, pInfo);
break;
default:
// 画个默认内容或断言
break;
}
}
现在,让我们深入DrawMyReport这个函数,看看在打印机DC上绘图的关键技巧:
void CPrintView::DrawMyReport(CDC* pDC, CPrintInfo* pInfo)
{
// 1. 获取可打印区域(单位:设备像素)
int nHorzRes = pDC->GetDeviceCaps(HORZRES); // 可打印宽度
int nVertRes = pDC->GetDeviceCaps(VERTRES); // 可打印高度
CRect rectPrintable(0, 0, nHorzRes, nVertRes);
// 2. 设置映射模式(关键!)
// MM_TEXT模式下,1逻辑单位=1设备像素,但不同打印机DPI不同,直接画会很小。
// 推荐使用 MM_ANISOTROPIC 或 MM_HIENGLISH 等物理映射模式。
pDC->SetMapMode(MM_HIMETRIC); // 1逻辑单位 = 0.01毫米
// 设置窗口范围和视口范围,进行缩放
pDC->SetWindowExt(10000, 10000); // 逻辑窗口大小为 100 x 100 毫米
pDC->SetViewportExt(nHorzRes, nVertRes); // 视口大小就是可打印区域像素
// 3. 计算边距(例如,四边各留2厘米)
const double dMarginCm = 2.0;
// 将厘米转换为当前映射模式下的逻辑单位(MM_HIMETRIC下,1厘米=1000逻辑单位)
int nMargin = (int)(dMarginCm * 1000);
CRect rectContent(nMargin, -nMargin, 10000 - nMargin, -(10000 - nMargin)); // 注意Y轴向上为正
// 4. 开始绘制你的内容
// 画一个边框
pDC->Rectangle(rectContent);
// 设置字体(注意:打印机字体需要创建,最好在OnBeginPrinting中创建并缓存)
CFont fontPrint;
fontPrint.CreatePointFont(200, _T("宋体"), pDC); // 20点字体
CFont* pOldFont = pDC->SelectObject(&fontPrint);
// 绘制文本
CString strTitle = _T("销售报表");
pDC->TextOut(rectContent.left + 1000, rectContent.top - 1500, strTitle); // 标题位置
// 5. 绘制表格、数据等...这里就是你的业务逻辑了
// 例如,从 m_pCPrintFrame->m_pMainDlg 获取数据,然后循环绘制
// 6. 恢复旧的GDI对象
pDC->SelectObject(pOldFont);
// fontPrint 会在析构时自动删除
}
关于OnEndPrintPreview,这里有个大坑需要处理:预览窗口关闭时,必须正确清理并恢复主窗口,否则程序可能会崩溃或失去响应。
void CPrintView::OnEndPrintPreview(CDC* pDC, CPrintInfo* pInfo, POINT point, CPreviewView* pView)
{
// 1. 调用基类,完成MFC内部的预览清理
CScrollView::OnEndPrintPreview(pDC, pInfo, point, pView);
// 2. 恢复主窗口指针到原始对话框(这里和CPrintFrame::OnClose作用类似,双重保险)
if (m_pCPrintFrame && m_pCPrintFrame->m_pMainDlg) {
CWinApp* pApp = AfxGetApp();
pApp->m_pMainWnd = m_pCPrintFrame->m_pMainDlg;
m_pCPrintFrame->m_pMainDlg->ShowWindow(SW_SHOW);
}
// 3. 销毁预览框架窗口
if (m_pCPrintFrame) {
// 注意:不要直接delete,通过发送消息或DestroyWindow
m_pCPrintFrame->PostMessage(WM_CLOSE);
}
}
5. 第三步:搞定预览——定制预览视图(CPrintPreviewView)
原始代码中的CPrintPreviewView主要是为了重写预览窗口工具栏上两个按钮的行为:“关闭”和“直接打印”。因为默认的CPreviewView会认为主框架窗口是它自己,关闭时可能会错误地结束整个应用。我们需要把它“拉回”到我们的自定义框架。
这个类相对简单,继承CPreviewView并重写两个命令处理函数即可:
// CPrintPreviewView.cpp
void CPrintPreviewView::OnPreviewClose()
{
// 获取我们自定义的框架窗口
CPrintFrame* pFrame = (CPrintFrame*)GetParentFrame();
if (pFrame) {
// 将应用主窗口恢复为原来的对话框
CWinApp* pApp = AfxGetApp();
pApp->m_pMainWnd = pFrame->m_pMainDlg;
// 显示主对话框
if (pFrame->m_pMainDlg) pFrame->m_pMainDlg->ShowWindow(SW_SHOW);
// 销毁预览框架
pFrame->DestroyWindow();
}
}
void CPrintPreviewView::OnPreviewPrint()
{
// 直接转发打印命令给我们的打印视图
if (m_pPrintView) {
m_pPrintView->SendMessage(WM_COMMAND, ID_FILE_PRINT);
}
// 打印完成后,同样需要关闭预览窗口
OnPreviewClose();
}
6. 第四步:串联一切——在对话框里触发打印和预览
最后,我们需要在原来的主对话框里,添加两个按钮:“打印预览”和“直接打印”。并为它们添加事件处理函数。
假设你的对话框类叫CMyDialog,上面有两个按钮IDC_BTN_PREVIEW和IDC_BTN_PRINT。
// 在 MyDialog.cpp 中
#include "CPrintFrame.h" // 包含我们的打印框架头文件
void CMyDialog::OnBnClickedBtnPreview()
{
// 禁用按钮,防止重复点击(可选)
GetDlgItem(IDC_BTN_PREVIEW)->EnableWindow(FALSE);
// 创建打印预览框架窗口,传入this指针(主对话框)和TRUE表示预览
// 注意:这里new出来的对象,会在其OnClose中自行销毁,我们不用手动delete。
CPrintFrame* pPrintFrame = new CPrintFrame(this, TRUE);
if (pPrintFrame && pPrintFrame->m_hWnd) {
// 窗口创建成功,流程会由CPrintFrame::OnCreate接管
} else {
// 创建失败,清理资源
delete pPrintFrame;
AfxMessageBox(_T("创建打印预览窗口失败!"));
GetDlgItem(IDC_BTN_PREVIEW)->EnableWindow(TRUE);
}
}
void CMyDialog::OnBnClickedBtnPrint()
{
// 直接打印的逻辑类似,但传入FALSE
CPrintFrame* pPrintFrame = new CPrintFrame(this, FALSE);
if (!pPrintFrame || !pPrintFrame->m_hWnd) {
delete pPrintFrame;
AfxMessageBox(_T("启动打印失败!"));
}
// 直接打印窗口会自动销毁,无需其他操作
}
7. 实战进阶:动态纸张配置与多页打印技巧
掌握了基础框架后,我们再来解决两个实际开发中高频出现的问题:如何让用户动态选择纸张,以及如何打印多页内容。
动态纸张配置的关键在于修改DEVMODE结构体,并在调用DoPreparePrinting之前或之后应用它。一个更好的做法是重写CPrintView的OnPreparePrinting,在调用DoPreparePrinting之后修改设备模式,因为用户可能在打印对话框中更改了设置。
BOOL CPrintView::OnPreparePrinting(CPrintInfo* pInfo)
{
// 先调用标准对话框,获取用户可能的初始选择
if (!DoPreparePrinting(pInfo)) {
return FALSE;
}
// 获取打印机的DEVMODE
CDC printDC;
printDC.Attach(pInfo->m_pPD->GetPrinterDC());
LPDEVMODE lpDevMode = (LPDEVMODE)::GlobalLock(pInfo->m_pPD->m_pd.hDevMode);
if (lpDevMode) {
// 这里可以根据你的业务逻辑,强制或修改纸张设置
// 例如,从对话框获取用户选择的纸张类型
if (m_pCPrintFrame->m_pMainDlg->GetUserSelectedPaper() == PAPER_A5) {
lpDevMode->dmPaperSize = DMPAPER_A5;
lpDevMode->dmOrientation = DMORIENT_PORTRAIT;
} else if (m_pCPrintFrame->m_pMainDlg->GetUserSelectedOrientation() == LANDSCAPE) {
lpDevMode->dmOrientation = DMORIENT_LANDSCAPE;
}
// 重要:修改后需要更新设备上下文
::GlobalUnlock(pInfo->m_pPD->m_pd.hDevMode);
printDC.ResetDC(lpDevMode); // 重置DC使设置生效
}
printDC.Detach();
return TRUE;
}
多页打印的核心是准确计算页数,并在OnPrint中根据pInfo->m_nCurPage绘制不同的页。计算页数通常在OnBeginPrinting中进行,因为这里你已经获得了打印机DC,可以精确计算内容高度。
void CPrintView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo)
{
// 假设我们有一个函数 CalculateTotalPages,根据数据行数和每页行数计算总页数
int nTotalRows = m_pCPrintFrame->m_pMainDlg->GetDataRowCount();
int nRowsPerPage = CalculateRowsPerPage(pDC); // 根据字体和页面高度计算每页行数
int nTotalPages = (nTotalRows + nRowsPerPage - 1) / nRowsPerPage; // 向上取整
pInfo->SetMaxPage(nTotalPages); // 设置最大页数
}
void CPrintView::OnPrint(CDC* pDC, CPrintInfo* pInfo)
{
int nCurrentPage = pInfo->m_nCurPage;
int nRowsPerPage = ...; // 需要和OnBeginPrinting中计算的一致
// 计算当前页要显示的数据范围
int nStartRow = (nCurrentPage - 1) * nRowsPerPage;
int nEndRow = min(nStartRow + nRowsPerPage, GetTotalRows()) - 1;
// 绘制页眉(如页码“第X页/共Y页”)
DrawPageHeader(pDC, nCurrentPage, pInfo->GetMaxPage());
// 循环绘制当前页的数据行
for (int i = nStartRow; i <= nEndRow; ++i) {
CString strRowData = GetRowData(i);
// 计算这一行在页面上的Y坐标
int yPos = /* 根据i, nStartRow, 行高计算 */;
pDC->TextOut(marginLeft, yPos, strRowData);
}
// 绘制页脚
DrawPageFooter(pDC);
}
8. 避坑指南与性能优化建议
走通整个流程后,还有一些细节和坑点需要注意,这些往往是决定功能是否稳健、体验是否流畅的关键。
第一个大坑:GDI资源泄漏。 在OnPrint里频繁创建字体、画笔、画刷,如果页面很多,会导致GDI对象耗尽,程序崩溃。最佳实践是在OnBeginPrinting中创建所有打印需要的GDI对象并缓存起来,在OnEndPrinting中统一删除。
class CPrintView {
CFont m_fontTitle, m_fontBody;
CPen m_penGrid;
// ...
};
void CPrintView::OnBeginPrinting(CDC* pDC, CPrintInfo* pInfo)
{
// 创建打印专用字体,使用点单位更精确
m_fontTitle.CreatePointFont(240, _T("黑体"), pDC); // 24点
m_fontBody.CreatePointFont(180, _T("宋体"), pDC); // 18点
m_penGrid.CreatePen(PS_SOLID, 5, RGB(200, 200, 200)); // 0.5点宽的灰色网格线
}
void CPrintView::OnEndPrinting(CDC* pDC, CPrintInfo* pInfo)
{
// 删除GDI对象
m_fontTitle.DeleteObject();
m_fontBody.DeleteObject();
m_penGrid.DeleteObject();
}
void CPrintView::DrawMyReport(CDC* pDC, CPrintInfo* pInfo)
{
// 直接使用成员变量,无需每次创建
CFont* pOldFont = pDC->SelectObject(&m_fontTitle);
// ... 绘制
pDC->SelectObject(pOldFont);
}
第二个坑:坐标系统混乱。 屏幕DC和打印机DC的DPI(每英寸像素数)通常不同。如果你用像素坐标硬编码,在屏幕上预览正常的布局,打印出来可能很小或很大。务必使用映射模式(如MM_HIMETRIC, MM_LOENGLISH)或根据GetDeviceCaps(LOGPIXELSX/Y)进行比例换算,来确保打印尺寸符合物理尺寸(厘米/英寸)。
第三个坑:预览模式下的刷新问题。 有时在预览窗口缩放或翻页时,内容会显示不全或残留。这通常是因为在OnPrint或OnDraw中没有正确处理背景擦除或视图区域。确保在绘制前调用pDC->FillSolidRect(rectClip, RGB(255,255,255))填充背景,并且你的绘制代码能够正确响应pInfo->m_rectDraw这个裁剪区域。
性能建议:对于数据量非常大的报表,不要在OnBeginPrinting时就计算所有页面的布局,这可能导致界面卡顿。可以改为在OnPrint中动态计算当前页的内容。更高级的做法是使用后台线程准备打印数据,主线程只负责绘制。另外,对于复杂的图形(如图表),可以考虑在OnBeginPrinting中预先渲染到位图上,在OnPrint中直接贴图,这会快很多。
最后,记得在各种边界条件下测试:没有默认打印机时、用户取消打印对话框时、打印过程中拔掉打印机时、预览窗口被异常关闭时。良好的错误处理和资源清理,是企业级应用稳定性的基石。这套框架我已经在多个生产环境中验证过,只要你理解了每个环节的作用,并根据自己的业务数据填充DrawMyReport这样的函数,就能构建出强大且稳定的对话框打印功能。
698



被折叠的 条评论
为什么被折叠?



