前端实现docx与pdf预览

我的运行环境:react

实现原理:pdf用iframe,docx用docx-preview插件(不支持预览doc)

相关代码:

复制代码
import { renderAsync } from 'docx-preview';
const DocxViewer = ({ blob }: { blob?: Blob }) => {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (blob && containerRef.current) {
      renderAsync(blob, containerRef.current, undefined, {
        className: 'docx-content',
        inWrapper: true,
      })
    }
  }, [blob]);

  return (
    <div style={{ maxHeight: '80vh', overflow: 'auto', }}>
       <style>{`
        .docx-container section.docx-content {
          padding: 10px !important; /* 强制覆盖原文档的页边距 */
          width: 100% !important;
          min-height: auto !important;
          box-shadow: none !important;
        }
        /* 兼容可能存在的 wrapper 结构 */
        .docx-container {
           background: transparent !important;
           padding: 0 !important;
        }
        .docx-content-wrapper {
          padding: 0 !important;
          background: transparent !important;
        }
      `}</style>
      <div ref={containerRef} className="docx-container"></div>
    </div>
  );
};
<Modal
  open={previewState.open}
  title={previewState.title || '文件预览'}
  footer={null}
  onCancel={() => setPreviewState(prev => ({ ...prev, open: false }))}
  width="80%"
  destroyOnClose
  style={{ top: 20 }}
>
  {previewState.type === 'pdf' && (
    <iframe src={previewState.url} style={{ width: '100%', height: '80vh', border: 'none' }} />
  )}
  {previewState.type === 'docx' && (
    <DocxViewer blob={previewState.blob} />
  )}
</Modal>

效果示例:


完整代码:

复制代码
import React, {useEffect, useRef, useState} from 'react';
import { message, Upload, Modal, Button, Space, Tooltip } from 'antd';
import { renderAsync } from 'docx-preview';
import { PageContainer, ProForm, ProFormText, ProFormSelect, ProFormTextArea, ProFormUploadButton, ProCard } from '@ant-design/pro-components';
import type { ProFormInstance } from '@ant-design/pro-components';
import { history, request, useLocation } from '@umijs/max';
import { addCase, getCase, updateCase } from '@/services/case';
import { getDictValueEnum } from '@/services/system/dict';


const DocxViewer = ({ blob }: { blob?: Blob }) => {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (blob && containerRef.current) {
      renderAsync(blob, containerRef.current, undefined, {
        className: 'docx-content',
        inWrapper: true,
      })
    }
  }, [blob]);

  return (
    <div style={{ maxHeight: '80vh', overflow: 'auto', }}>
       <style>{`
        .docx-container section.docx-content {
          padding: 10px !important; /* 强制覆盖原文档的页边距 */
          width: 100% !important;
          min-height: auto !important;
          box-shadow: none !important;
        }
        /* 兼容可能存在的 wrapper 结构 */
        .docx-container {
           background: transparent !important;
           padding: 0 !important;
        }
        .docx-content-wrapper {
          padding: 0 !important;
          background: transparent !important;
        }
      `}</style>
      <div ref={containerRef} className="docx-container"></div>
    </div>
  );
};

const CaseCreatePage: React.FC = () => {
  const formRef = useRef<ProFormInstance>();
  const [caseTypeOptions, setCaseTypeOptions] = useState<any>([])
  const [previewState, setPreviewState] = useState<{
    open: boolean;
    type: 'pdf' | 'docx' | 'other';
    url?: string;
    blob?: Blob;
    title?: string;
  }>({ open: false, type: 'other' });

  const location = useLocation();
  const searchParams = new URLSearchParams(location.search);
  const editId = searchParams.get('id');
  const [initValues, setInitValues] = useState<any>({
    case_name: '',
    case_type: undefined,
    case_desc: '',
    scene_desc: '',
  });
  useEffect(() => {
    getDictValueEnum('case_type').then((data) => {
      setCaseTypeOptions(data);
    });
  }, [])
  useEffect(() => {
    if (editId) {
      getCase(editId).then((res) => {
        if (res?.code === 200) {
          const d = res.data || {};
          const formData = {
            case_name: d.case_name,
            case_type: d.case_type,
            case_desc: d.case_desc,
            scene_desc: d.case_scene,
            scene: d.scene_img ? [{
              uid: '-1',
              name: '场景图片',
              status: 'done',
              url: d.scene_img,
            }] : [],
            records: d.case_note ? [{
              uid: '-1',
              name: '笔录文件',
              status: 'done',
              url: d.case_note,
            }] : [],
          };
          setInitValues(formData);
          formRef.current?.setFieldsValue(formData);
        }
      }).catch(() => {});
    }
  }, [editId])
  return (
    <PageContainer title={editId ? `编辑案例 #${editId}` : '新建案例'}>
      <ProForm
        formRef={formRef}
        layout="horizontal"
        grid
        initialValues={initValues}
        submitter={{
          render: (_, dom) => <div style={{ display: 'flex', justifyContent: 'center', gap: 12 }}>{dom}</div>,
        }}
        onFinish={async (values) => {
          // 处理图片和文件上传结果,转换为字符串URL
          let sceneUrl = '';
          if (values.scene && values.scene.length > 0) {
            // 如果是新上传的,取 response.data (根据 customRequest 逻辑)
            // 如果是回显的,取 url
            const file = values.scene[0];
            sceneUrl = file.url || (file.response && file.response.data) || '';
          }

          let recordUrl = '';
          if (values.records && values.records.length > 0) {
             const file = values.records[0];
             recordUrl = file.url || (file.response && file.response.data) || '';
          }

          const payload = {
            ...values,
            scene_img: sceneUrl,
            case_note: recordUrl,
            case_scene: values.scene_desc, // 映射回后端字段
          };

          if (editId) {
            const res = await updateCase(editId, payload);
            if (res?.code === 200) {
              message.success('案例更新成功');
              history.push('/training/cases');
              return true;
            }
            message.error(res?.msg || '更新失败');
            return false;
          } else {
            const res = await addCase(payload);
            if (res?.code === 200) {
              message.success('案例创建成功');
              history.push('/training/cases');
              return true;
            }
            message.error(res?.msg || '创建失败');
            return false;
          }
        }}
      >
        <ProFormText name="case_name" label="案例名称" rules={[{ required: true, message: '请输入案例名称' }]} colProps={{ span: 24 }} />
        <ProFormSelect
          name="case_type"
          label="案例类型"
          colProps={{ span: 24 }}
          rules={[{ required: true, message: '请选择案例类型' }]}
          valueEnum={caseTypeOptions}
          showSearch
        />
        {/* <ProFormSelect
            options={caseTypeOptions}
            name="projectId"
            label='项目名称'
            colProps={{ md: 12, xl: 24 }}
            labelCol={{ span: 6 }}
            wrapperCol={{ span: 18 }}
            showSearch
            rules={[
                { required: true, message: '请选择项目名称' },
            ]}
        /> */}
        <ProFormTextArea name="case_desc" label="案例描述" colProps={{ span: 24 }} fieldProps={{ rows: 6 }} />

        <ProCard title="笔录文件管理" headerBordered bordered style={{ marginBottom: 16 }}>
          <ProFormUploadButton
            name="records"
            label="笔录上传(PDF/Word)"
            max={1}
            fieldProps={{
              accept: '.pdf,.docx',
              multiple: false,
              listType: 'text',
              showUploadList: { showPreviewIcon: true, showRemoveIcon: true, showDownloadIcon: true },
              customRequest: async (options: any) => {
                const { file, onSuccess, onError, onProgress } = options;
                try {
                  const formData = new FormData();
                  formData.append('file', file as Blob);
                  const res = await request('/vadmin/system/upload/file/to/oss', {
                    method: 'POST',
                    data: formData,
                    requestType: 'form',
                    onUploadProgress: (event: any) => {
                      const percent = event?.percent || (event?.loaded && event?.total ? (event.loaded / event.total) * 100 : 0);
                      onProgress?.({ percent });
                    },
                  });
                  const url = res?.data;
                  if (url) {
                    (file as any).url = url;
                  }
                  onSuccess?.(res, file);
                  message.success('笔录上传成功');
                } catch (e: any) {
                  message.error(e?.message || '笔录上传失败');
                  onError?.(e);
                }
              },
              onPreview: async (file: any) => {
                const url = file?.url || file?.thumbUrl || (file?.originFileObj ? URL.createObjectURL(file.originFileObj) : '');
                const name = (file.name || '').toLowerCase();
                if (!url && !file.originFileObj) {
                  message.info('暂无预览内容');
                  return;
                }

                if (url.endsWith('.pdf')) {
                  setPreviewState({
                    open: true,
                    type: 'pdf',
                    url: url,
                    title: file.name
                  });
                } else if (url.endsWith('.docx')) {
                  let blob = file.originFileObj;
                  if (!blob && url) {
                      try {
                          const res = await fetch(url);
                          blob = await res.blob();
                      } catch (e) {
                          message.error('获取文件失败');
                          return;
                      }
                  }
                  setPreviewState({
                    open: true,
                    type: 'docx',
                    blob: blob,
                    title: file.name
                  });
                } else {
                   if (url) window.open(url);
                }
              },
              onDownload: async (file: any) => {
                const url = file?.url || (file?.originFileObj ? URL.createObjectURL(file.originFileObj) : '');
                if (url) {
                  window.open(url);
                } else {
                  message.info('暂无下载链接');
                }
              },
              onChange: ({ file, fileList }) => {
                if (fileList.length > 1) {
                  fileList.splice(0, fileList.length - 1);
                }
                if (file.status === 'removed') {
                  message.success('已删除笔录');
                }
              },
              beforeUpload: (file) => {
                const name = file.name.toLowerCase();
                const ok = name.endsWith('.pdf') || name.endsWith('.doc') || name.endsWith('.docx');
                if (!ok) {
                  message.error('仅支持上传 PDF/Word 文件');
                  return Upload.LIST_IGNORE as any;
                }
                if (file.size / 1024 / 1024 > 20) {
                  message.error('文件大小不能超过20MB');
                  return Upload.LIST_IGNORE as any;
                }
                return true;
              },
            }}
            formItemProps={{
              extra: '支持PDF/Word,单个文件≤20MB;可预览、下载、删除;重新上传覆盖当前文件',
            }}
            colProps={{ span: 24 }}
          />
        </ProCard>

        <ProCard title="场景背景管理" headerBordered bordered style={{ marginBottom: 16 }}>
          <ProFormUploadButton
            name="scene"
            label="场景背景图片(JPG/PNG)"
            max={1}
            fieldProps={{
              accept: '.jpg,.jpeg,.png',
              listType: 'picture-card',
              customRequest: async (options: any) => {
                const { file, onSuccess, onError, onProgress } = options;
                try {
                  const formData = new FormData();
                  formData.append('file', file as Blob);
                  const res = await request('/vadmin/system/upload/image/to/oss', {
                    method: 'POST',
                    data: formData,
                    // 不手动设置 Content-Type,浏览器会自动添加 multipart 边界
                    requestType: 'form',
                    onUploadProgress: (event: any) => {
                      const percent = event?.percent || (event?.loaded && event?.total ? (event.loaded / event.total) * 100 : 0);
                      onProgress?.({ percent });
                    },
                  });
                  const url = res?.data;
                  if (url) {
                    // Antd 会把 response 挂到 file.response;设置 url 便于预览
                    (file as any).url = url;
                    
                  }
                  onSuccess?.(res, file);
                  message.success('图片上传成功');
                } catch (e: any) {
                  message.error(e?.message || '图片上传失败');
                  onError?.(e);
                }
              },
              beforeUpload: (file) => {
                const name = file.name.toLowerCase();
                const ok = name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.png');
                if (!ok) {
                  message.error('仅支持上传 JPG/PNG 图片');
                  return Upload.LIST_IGNORE as any;
                }
                if (file.size / 1024 / 1024 > 10) {
                  message.error('图片大小不能超过10MB');
                  return Upload.LIST_IGNORE as any;
                }
                return true;
              },
            }}
            formItemProps={{
              extra: '推荐分辨率1920x1080,≤10MB;支持替换:重新上传会覆盖当前图片',
            }}
            colProps={{ span: 24 }}
          />

          <ProFormTextArea
            name="scene_desc"
            label="场景描述(选填)"
            placeholder="例如:某单位会议室、调查室等,补充环境设定细节"
            colProps={{ span: 24 }}
            fieldProps={{ rows: 4, maxLength: 200, showCount: true }}
          />
        </ProCard>
      </ProForm>
      <Modal
        open={previewState.open}
        title={previewState.title || '文件预览'}
        footer={null}
        onCancel={() => setPreviewState(prev => ({ ...prev, open: false }))}
        width="80%"
        destroyOnClose
        style={{ top: 20 }}
      >
        {previewState.type === 'pdf' && (
          <iframe src={previewState.url} style={{ width: '100%', height: '80vh', border: 'none' }} />
        )}
        {previewState.type === 'docx' && (
          <DocxViewer blob={previewState.blob} />
        )}
      </Modal>
    </PageContainer>
  );
};

export default CaseCreatePage;
相关推荐
GDAL1 小时前
Vue3 Computed 深入讲解(聚焦 Vue3 特性)
前端·javascript·vue.js
Moment1 小时前
半年时间使用 Tiptap 开发一个和飞书差不多效果的协同文档 😍😍😍
前端·javascript·后端
前端加油站1 小时前
记一个前端导出excel受限问题
前端·javascript
da_vinci_x1 小时前
PS 生成式扩展:从 iPad 到带鱼屏,游戏立绘“全终端”适配流
前端·人工智能·游戏·ui·aigc·技术美术·游戏美术
一壶纱1 小时前
uni-app 中配置 UnoCSS
前端·vue.js
坐吃山猪1 小时前
Electron02-Hello
开发语言·javascript·ecmascript
步履不停_1 小时前
告别输入密码!打造基于 VS Code 的极致远程开发工作流
前端·visual studio code
狗哥哥1 小时前
Vue 3 企业级表格组件体系设计实战
前端
尘世中一位迷途小书童1 小时前
JavaScript 一些小特性:让你的代码更优雅高效
前端·javascript·架构