diff --git a/src/components/OfficePreview/index.js b/src/components/OfficePreview/index.js
new file mode 100644
index 0000000..e925e82
--- /dev/null
+++ b/src/components/OfficePreview/index.js
@@ -0,0 +1,193 @@
+import { useEffect, useRef, useState } from "react";
+import { Modal, Empty, Alert } from "antd";
+import { renderAsync } from "docx-preview";
+import "./index.less";
+
+const FILE_TYPE_MAP = {
+ docx: "word",
+ pdf: "pdf",
+};
+
+/** 根据 url/类型识别文件类型,仅支持 docx/pdf */
+function resolveFileType(url, type) {
+ if (type && FILE_TYPE_MAP[type]) return type;
+ const ext = (url || "").split("?")[0].split(".").pop()?.toLowerCase();
+ return FILE_TYPE_MAP[ext] ? ext : "";
+}
+
+/** 相对地址拼接文件服务前缀 */
+function resolveUrl(raw) {
+ if (!raw) return "";
+ const u = String(raw).trim();
+ if (/^https?:\/\//i.test(u)) return u;
+ const base = window.fileUrl || "";
+ return base ? `${base}${u}` : u;
+}
+
+/** 渲染 Word 文档到容器 */
+async function renderDocx(url, container) {
+ if (!container) throw new Error("预览容器尚未就绪,请稍后重试");
+ const res = await fetch(url, { credentials: "same-origin" });
+ if (!res.ok) throw new Error(`文档加载失败:${res.status} ${res.statusText}`);
+ const blob = await res.blob();
+ await renderAsync(blob, container, container, {
+ className: "office-preview-docx",
+ inWrapper: true,
+ ignoreWidth: false,
+ ignoreHeight: false,
+ ignoreFonts: false,
+ breakPages: true,
+ renderHeaders: true,
+ renderFooters: true,
+ renderFootnotes: true,
+ renderEndnotes: true,
+ });
+}
+
+/** 文档预览组件:支持 Word(docx)、PDF,支持弹窗/页面两种模式 */
+export default function OfficePreview({
+ url,
+ fileType = "auto",
+ mode = "modal",
+ open = false,
+ onCancel,
+ title = "文档预览",
+ width = 1000,
+ className = "",
+ style = {},
+ emptyText = "暂无文档",
+}) {
+ const containerRef = useRef(null);
+ const aliveRef = useRef(true);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [ready, setReady] = useState(false);
+ const resolvedUrl = resolveUrl(url);
+ const type = resolveFileType(resolvedUrl, fileType);
+
+ useEffect(() => {
+ aliveRef.current = true;
+ return () => {
+ aliveRef.current = false;
+ };
+ }, []);
+
+ useEffect(() => {
+ if (mode === "modal" && !open) {
+ setReady(false);
+ return;
+ }
+ if (!resolvedUrl || type !== "docx") return;
+
+ setReady(false);
+ setError("");
+
+ let rafId = null;
+ let cancelled = false;
+
+ const tryRender = () => {
+ if (cancelled) return;
+ const node = containerRef.current;
+ if (!node) {
+ rafId = requestAnimationFrame(tryRender);
+ return;
+ }
+ setLoading(true);
+ renderDocx(resolvedUrl, node)
+ .then(() => {
+ if (aliveRef.current && !cancelled) {
+ setReady(true);
+ }
+ })
+ .catch((e) => {
+ console.error("OfficePreview 文档渲染失败", e);
+ if (aliveRef.current && !cancelled) {
+ setError(e?.message || "文档渲染失败,请稍后重试");
+ }
+ })
+ .finally(() => {
+ if (aliveRef.current && !cancelled) {
+ setLoading(false);
+ }
+ });
+ };
+
+ tryRender();
+
+ return () => {
+ cancelled = true;
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ const node = containerRef.current;
+ if (node) node.innerHTML = "";
+ };
+ }, [resolvedUrl, type, open, mode]);
+
+ const renderContent = () => {
+ if (!resolvedUrl) {
+ return
2026-08-18 新增:用于兜底 WpsEditor 等第三方 SDK 在运行时抛出的 + * "Cannot read properties of undefined (reading 'officeType')" 等未捕获异常。 + * 第三方 SDK(如 WebOfficeSDK)在内部事件回调中可能因后端数据缺失而抛 + * TypeError,本组件在 React 渲染树顶层将其捕获,渲染降级 UI, + * 避免 React DevTools runtime error overlay 弹出红屏。
+ */ +export default class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidCatch(error, info) { + // 打印详细错误便于排查,但不让 React DevTools 弹出红屏 + // eslint-disable-next-line no-console + console.warn("[ErrorBoundary] 捕获到子组件错误:", error, info); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + return ( +