| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963 |
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
- import { createPortal } from "react-dom";
- import {
- ArrowLeft,
- ChevronDown,
- ChevronUp,
- Expand,
- FileWarning,
- Link2,
- Maximize2,
- Minimize2,
- RefreshCw,
- Search,
- Shrink,
- X,
- } from "lucide-react";
- import { renderAsync } from "docx-preview";
- import { HttpError } from "../../shared/api/errors";
- import { AllowedAction, DocumentPasswordAction, DocumentType } from "../../shared/api/enums";
- import { useCtrlWheelZoom } from "../../shared/hooks/useCtrlWheelZoom";
- import { useDragPan } from "../../shared/hooks/useDragPan";
- import {
- cachePassword,
- getCachedPassword,
- requestDocumentPassword,
- withDocumentPassword,
- } from "../../shared/state/documentPassword";
- import { attachmentApi } from "../attachments/attachmentApi";
- import type { DocumentSummary } from "../documents/documentTypes";
- import type { DocumentRelationItem } from "../documents/documentRelationTypes";
- import { documentApi } from "../documents/documentApi";
- import { invalidateDetail } from "../documents/documentState";
- import { tipProps } from "../../shared/ui/hoverTip";
- import { documentMutationApi } from "../mutations/documentMutationApi";
- import { notifyContentChanged } from "../../shared/state/contentRefresh";
- import { acquirePreview } from "./fileState";
- import { PdfViewer } from "./PdfViewer";
- import {
- renderParagraphWithHighlights,
- useKeywordSearch,
- } from "./keywordSearch";
- const PDF_MIME = "application/pdf";
- const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
- // 图片类扩展名:后端原图直出(image/* MIME),前端 <img> 渲染
- const IMAGE_PREVIEW_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "bmp", "webp"]);
- function previewError(value: unknown, requestId = "unknown"): HttpError {
- if (value instanceof HttpError) return value;
- return new HttpError({
- httpStatus: 0,
- code: "INTERNAL_ERROR",
- message: value instanceof Error ? value.message : "文件预览失败",
- details: null,
- requestId,
- cause: value,
- });
- }
- function errorText(error: HttpError, extension: string): string {
- if (error.code === "PREVIEW_UNAVAILABLE" || error.httpStatus === 415) {
- return ["doc", "wps"].includes(extension.toLowerCase())
- ? "当前DOC/WPS格式暂不支持在线预览,请下载原始文件。"
- : "当前文件类型暂不支持在线预览";
- }
- if (error.httpStatus === 403 || error.code === "DOCUMENT_VIEW_FORBIDDEN") return "当前账号无权查看该文件";
- if (error.code === "DOCUMENT_PASSWORD_REQUIRED") return "该方案已设置密码保护,查看需输入方案密码";
- if (error.code === "DOCUMENT_PASSWORD_INVALID") return "方案密码不正确";
- if (error.httpStatus === 404 || error.code === "FILE_NOT_FOUND") return "文件不存在或已被删除";
- if (error.httpStatus === 500) return "预览服务暂时不可用";
- if (error.httpStatus === 0) return "网络连接失败或文档渲染失败";
- return error.message;
- }
- function sanitizeRenderedContent(container: HTMLElement): void {
- container.querySelectorAll("a").forEach((anchor) => {
- const href = anchor.getAttribute("href") ?? "";
- if (href.trim().toLowerCase().startsWith("javascript:")) anchor.removeAttribute("href");
- if (!/^(https?:|mailto:|#)/i.test(href)) anchor.removeAttribute("href");
- anchor.setAttribute("rel", "noopener noreferrer");
- if (/^https?:/i.test(href)) anchor.setAttribute("target", "_blank");
- });
- container.querySelectorAll("img").forEach((image) => {
- const src = image.getAttribute("src") ?? "";
- if (src && !/^(blob:|data:)/i.test(src)) image.removeAttribute("src");
- // 图片可点击放大(见 ImageLightbox):渲染后统一给出提示光标
- image.style.cursor = "zoom-in";
- });
- }
- // ─── 图片浏览遮罩:点击文档内图片后全屏查看 ──────────────────────────────────
- // Ctrl+滚轮缩放(乘法步进,范围 0.2~8 倍)+ 按住拖拽平移 + 右上角关闭按钮。
- const IMAGE_LIGHTBOX_MIN = 0.2;
- const IMAGE_LIGHTBOX_MAX = 8;
- function ImageLightbox({ src, onClose }: { src: string; onClose: () => void }) {
- const [scale, setScale] = useState(1);
- const [offset, setOffset] = useState({ x: 0, y: 0 });
- const rootRef = useRef<HTMLDivElement>(null);
- const dragRef = useRef<{ pointerId: number; startX: number; startY: number; baseX: number; baseY: number } | null>(null);
- // Ctrl+滚轮缩放:React onWheel 是 passive 合成事件无法 preventDefault,
- // 须以原生 wheel 监听拦截浏览器页面缩放快捷键(与 useCtrlWheelZoom 同理,
- // 但图片缩放用乘法步进与更宽范围,故独立实现)。
- useEffect(() => {
- const element = rootRef.current;
- if (!element) return;
- const handler = (event: WheelEvent) => {
- if (!event.ctrlKey) return;
- event.preventDefault();
- // 阻断冒泡:modal 模式下 lightbox 渲染在弹窗遮罩根(modalZoom 监听
- // 挂载点)内部,不阻断会让一次滚轮同时缩放图片与底层文档。
- event.stopPropagation();
- setScale((current) => {
- const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
- const next = Math.round(current * factor * 100) / 100;
- return Math.min(IMAGE_LIGHTBOX_MAX, Math.max(IMAGE_LIGHTBOX_MIN, next));
- });
- };
- element.addEventListener("wheel", handler, { passive: false });
- return () => element.removeEventListener("wheel", handler);
- }, []);
- return (
- <div
- ref={rootRef}
- role="dialog"
- aria-label="图片浏览"
- className="fixed inset-0 z-[100] flex items-center justify-center overflow-hidden select-none"
- style={{ background: "rgba(var(--dms-deep-rgb),.88)" }}
- >
- <img
- src={src}
- alt="文档图片"
- draggable={false}
- className="max-w-[90%] max-h-[90%] object-contain shadow-2xl"
- style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`, cursor: "grab" }}
- onPointerDown={(event) => {
- if (event.button !== 0) return;
- event.preventDefault();
- dragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, baseX: offset.x, baseY: offset.y };
- event.currentTarget.setPointerCapture(event.pointerId);
- }}
- onPointerMove={(event) => {
- const drag = dragRef.current;
- if (!drag || drag.pointerId !== event.pointerId) return;
- setOffset({ x: drag.baseX + event.clientX - drag.startX, y: drag.baseY + event.clientY - drag.startY });
- }}
- onPointerUp={() => { dragRef.current = null; }}
- onPointerCancel={() => { dragRef.current = null; }}
- />
- <button
- type="button"
- onClick={onClose}
- aria-label="关闭图片浏览"
- {...tipProps("关闭图片浏览")}
- className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-sm text-white transition-colors hover:bg-white/25"
- >
- <X size={24} />
- </button>
- </div>
- );
- }
- /**
- * 图片直出预览面板(显式像素尺寸)。
- *
- * onload 时按容器余量算一次 fit 基准尺寸(等比、不放大),此后 img 显示尺寸
- * = fit × zoom(区域缩放倍率由上层传入,直接放大布局尺寸,滚动范围真实增长、
- * 滚动条自然出现)。maxWidth/maxHeight:none 覆盖 Tailwind preflight 的
- * img{max-width:100%},保持显示尺寸不被百分比约束改写。居中用 flex 子项
- * margin:auto(溢出时退化为 0,不裁起点),溢出方向始终可由容器
- * overflow-auto 滚动到达;拖动平移由 useDragPan 提供。
- */
- function ImageViewPane({ url, alt, onZoomActivate, onFitRatio, zoom = 1 }: {
- url: string;
- alt: string;
- onZoomActivate: (src: string) => void;
- /** 原尺寸/适配尺寸比上报(= 1/fit 缩放比),上层据此把缩放上限提到 3 倍比 */
- onFitRatio?: (ratio: number) => void;
- /** 区域缩放倍率(Ctrl+滚轮):img 显示尺寸 = fit × zoom */
- zoom?: number;
- }) {
- const [fit, setFit] = useState<{ w: number; h: number } | null>(null);
- const panRef = useDragPan<HTMLDivElement>();
- return (
- <div ref={panRef} className="absolute inset-0 flex overflow-auto p-6">
- {/* 点击进入全屏浏览遮罩(复用 ImageLightbox);区域内 Ctrl+滚轮缩放 +
- 拖动平移(拖动不触发点击放大) */}
- <img
- src={url}
- alt={alt}
- draggable={false}
- onClick={() => onZoomActivate(url)}
- onLoad={(event) => {
- const container = panRef.current;
- const img = event.currentTarget;
- if (!container) return;
- // p-6 上下/左右各 24px 余量;等比缩到容器内且不超出原始尺寸
- const scale = Math.min(
- 1,
- (container.clientWidth - 48) / img.naturalWidth,
- (container.clientHeight - 48) / img.naturalHeight,
- );
- onFitRatio?.(1 / scale);
- setFit({
- w: Math.max(1, Math.round(img.naturalWidth * scale)),
- h: Math.max(1, Math.round(img.naturalHeight * scale)),
- });
- }}
- className="shadow-sm cursor-zoom-in"
- style={{
- width: fit ? Math.max(1, Math.round(fit.w * zoom)) : undefined,
- height: fit ? Math.max(1, Math.round(fit.h * zoom)) : undefined,
- margin: "auto",
- flex: "none",
- maxWidth: "none",
- maxHeight: "none",
- // 尺寸就绪前隐藏,避免未约束的自然尺寸闪现
- display: fit ? "block" : "none",
- }}
- />
- </div>
- );
- }
- export function FilePreviewModal({ item, onClose, mode = "side", asideRef, style, onZoomMaxChange }: {
- item: DocumentSummary;
- onClose: () => void;
- mode?: "side" | "modal";
- /**
- * 仅 side 模式生效:把外部 ref 与 style 合并到 panel 根 aside。
- * modal 模式是临时全屏弹窗(含遮罩),不参与外部区域缩放。
- */
- asideRef?: React.Ref<HTMLElement>;
- style?: React.CSSProperties;
- /**
- * 仅 side 模式生效:缩放上限变更上抛给外部区域缩放 hook(App rightZoom)。
- * 上限 = 3 × 原尺寸/适配尺寸比(适应宽度会把超宽文档先缩小,固定上限
- * 会导致宽文档永远放不回原尺寸)。modal 模式上限由内部 modalZoom 自管。
- */
- onZoomMaxChange?: (max: number) => void;
- }) {
- const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
- const [imageBlob, setImageBlob] = useState<Blob | null>(null);
- const [error, setError] = useState<HttpError | null>(null);
- const [loading, setLoading] = useState(true);
- const [retryVersion, setRetryVersion] = useState(0);
- const [contentText, setContentText] = useState<string | null>(null);
- const [contentLoading, setContentLoading] = useState(false);
- // modal 窗口状态:normal 常规弹窗 / maximized 最大化 / fullscreen 最大化且隐藏标题栏
- const [windowState, setWindowState] = useState<"normal" | "maximized" | "fullscreen">("normal");
- // 补充说明:modal 模式下展示;具备编辑权限的用户可编辑保存
- const [description, setDescription] = useState<string | null>(null);
- const [descriptionDraft, setDescriptionDraft] = useState("");
- const [savingDescription, setSavingDescription] = useState(false);
- const [descriptionMessage, setDescriptionMessage] = useState("");
- // 图片浏览遮罩:点击 docx 渲染区内的图片后全屏查看(Ctrl+滚轮缩放)
- const [lightboxSrc, setLightboxSrc] = useState<string | null>(null);
- // useRef<T | null>(null) 取可写引用(setDocxContainer 需要与其他 ref 合并赋值)
- const docxContainer = useRef<HTMLDivElement | null>(null);
- // 统一交互:查看弹窗(modal)内容区 Ctrl+滚轮缩放。倍率经 contentZoom 传入
- // 各渲染面板,由面板直接放大内容的布局尺寸(PDF 显示盒 / img 显式像素 /
- // docx 占位层),滚动范围随之真实增长、滚动条自然出现——不能在包装层上用
- // transform: scale(不参与布局,放大后滚动条不出现、居中空白跟着放大),
- // 也不能用 CSS zoom(挂派生尺寸包装层是视觉 no-op,实测已弃用)。
- // 上限按文档动态调整(applyFitRatio,目标 = 原尺寸的 3 倍),初始 3;
- // side 模式沿用外部 rightZoom,上限经 onZoomMaxChange 上抛。
- const modalZoom = useCtrlWheelZoom<HTMLDivElement>(1, 3);
- const docxPanRef = useDragPan<HTMLDivElement>();
- // docx 内容自然尺寸(渲染完成后测量一次):缩放时占位层按 自然尺寸×zoom
- // 撑开滚动范围,内层按自然尺寸渲染后 transform 放大(docx 是 pt 固定排版,
- // 无法像 PDF/img 那样逐元素放大布局,是三个面板中唯一保留 transform 的)
- const [docxSize, setDocxSize] = useState<{ w: number; h: number } | null>(null);
- // docxContainer 仅是 renderAsync 渲染目标;拖动平移挂在外层滚动容器上
- const setDocxContainer = (node: HTMLDivElement | null) => {
- docxContainer.current = node;
- };
- const requestVersion = useRef(0);
- const isModal = mode === "modal";
- // 缩放上限动态化:子面板(PdfViewer/ImageViewPane)测得 原尺寸/适配尺寸 比
- // 后上报,上限 = 3 × 比值(即最多放大到原尺寸的 3 倍,适应宽度缩得越多
- // 上限越高);modal 作用于内部 modalZoom,side 上抛给 App 的 rightZoom。
- // docx-preview 回退分支在 ignoreWidth(side)下拿不到页面原始宽度,不上报,
- // 保持初始上限 3(实际 doc/docx 几乎都被后端转为 PDF,走 PdfViewer 分支)。
- const applyFitRatio = useCallback((ratio: number) => {
- const max = Math.max(1, Math.round(ratio * 3 * 100) / 100);
- if (isModal) modalZoom.setMax(max);
- else onZoomMaxChange?.(max);
- }, [isModal, onZoomMaxChange, modalZoom.setMax]);
- // PDF 预览固定 pdfjs 渲染(弹窗与侧栏一致):canvas 位图天然不可选中文字、
- // 滚动容器可拖动平移,且文内高亮/跳转依赖 pdfjs 文本层坐标——这三个要求
- // iframe 内置查看器(独立文档,样式与事件均无法注入)一个都做不到,
- // 故引擎切换(原 DMS_PDF_VIEWER_ENGINE)退役,pdfUrl/iframe 分支一并移除。
- // 关联方案跳转:modal 模式下点击关联项后在同一弹窗内切换查看目标方案;
- // side 模式不展示关联区块,navigated 恒为 null,current 即 item。
- const [navigated, setNavigated] = useState<DocumentSummary | null>(null);
- const current = navigated ?? item;
- // 切换文档(含关联跳转)时复位缩放上限为初始 3,新文档渲染完成后会再次上报精确值
- useEffect(() => { applyFitRatio(1); }, [current.id, applyFitRatio]);
- // 关联清单(仅 modal + 非附件加载;附件不可发起关联,后端对附件返回 404)
- const [relations, setRelations] = useState<DocumentRelationItem[]>([]);
- const [relationsError, setRelationsError] = useState("");
- const [relationJumping, setRelationJumping] = useState(false);
- useEffect(() => { setNavigated(null); }, [item.id]);
- // 列表全量可见(需求3):无权限方案允许打开预览面板,但不发任何请求,
- // 直接呈现"无权限访问"提示;后端预览/详情接口仍独立拦截作为第二道防线。
- const canView = current.allowedActions.includes(AllowedAction.VIEW);
- // ─── side 模式:与中间列表 1:1 平分 + 左边缘拖拽调宽 ──────────────────────
- // null = 未拖动,aside 用 flex-1 与中间列(同为 flex-1)平分剩余空间;
- // 拖动后记录显式像素宽,中间列继续 flex-1 吸收变化,两侧内容自适应。
- const MIN_WIDTH = 360;
- const [sideWidth, setSideWidth] = useState<number | null>(null);
- const asideEl = useRef<HTMLElement | null>(null);
- // 拖动中不改 aside 宽度(避免 PDF/DOCX 渲染器随宽度实时重排导致卡顿),
- // 只移动全屏蓝色指示线标识新边界(transform 合成层动画,零重排零重渲染),
- // 松手时一次性提交最终宽度。
- const guideEl = useRef<HTMLDivElement | null>(null);
- const dragState = useRef({ active: false, x: 0, width: 0, latest: 0 });
- // side 模式:外部 style 仅携带区域缩放倍率(App rightZoom);倍率只传给
- // 内容渲染面板(与弹窗 modalZoom 同款),面板标题栏等装饰不参与缩放,
- // 其余样式合并进 aside 根。
- const { zoom: sideZoomRaw, ...asideRestStyle } = style ?? {};
- const sideZoom = typeof sideZoomRaw === "number" ? sideZoomRaw : 1;
- // 内容区当前缩放倍率:modal 用内部 modalZoom,side 用外部 rightZoom
- const contentZoom = isModal ? modalZoom.zoom : sideZoom;
- // 外部 asideRef(区域缩放)与内部 ref 合并到同一节点。
- const setSideAside = (node: HTMLElement | null) => {
- asideEl.current = node;
- if (!asideRef) return;
- if (typeof asideRef === "function") asideRef(node);
- else (asideRef as { current: HTMLElement | null }).current = node;
- };
- // 手柄持有指针捕获:即使鼠标滑入预览区内的 iframe(独立文档,事件不冒泡到 window),
- // pointermove/up 仍派发到手柄本尊——这是"经常拖不动"的根因修复。
- const handleDragEnd = () => {
- if (!dragState.current.active) return;
- dragState.current.active = false;
- if (guideEl.current) guideEl.current.style.display = "none";
- document.body.style.cursor = "";
- document.body.style.userSelect = "";
- setSideWidth(dragState.current.latest);
- };
- // 图片预览:<img> 只消费对象 URL,切换/卸载时释放。
- const imageUrl = useMemo(
- () => (imageBlob ? URL.createObjectURL(imageBlob) : null),
- [imageBlob],
- );
- useEffect(() => {
- if (!imageUrl) return;
- return () => URL.revokeObjectURL(imageUrl);
- }, [imageUrl]);
- // modal 模式下额外拉取 detail 以展示提取文本(contentText 仅在 detail DTO 暴露)。
- // side 模式宽度不足以分栏,跳过拉取以节省请求。
- useEffect(() => {
- setContentText(null);
- setContentLoading(false);
- setDescription(null);
- if (!isModal || !canView) return;
- const controller = new AbortController();
- setContentLoading(true);
- const isAttachment = current.documentType === DocumentType.ATTACHMENT;
- const request = isAttachment
- ? attachmentApi.detail(current.id, controller.signal)
- : documentApi.detail(current.id, controller.signal, getCachedPassword(current.id));
- request
- .then((detail) => {
- if (!controller.signal.aborted) {
- setContentText(detail.contentText ?? null);
- setDescription(detail.description ?? null);
- }
- })
- .catch(() => {
- // 静默失败:右侧降级为"暂无正文"
- })
- .finally(() => {
- if (!controller.signal.aborted) setContentLoading(false);
- });
- return () => controller.abort();
- }, [current.id, current.documentType, isModal, canView, retryVersion]);
- // 关联方案清单:仅 modal 模式且非附件时加载(附件不可发起关联,直接跳过)。
- useEffect(() => {
- setRelations([]);
- setRelationsError("");
- if (!isModal || !canView || current.documentType === DocumentType.ATTACHMENT) return;
- const controller = new AbortController();
- documentApi.listRelations(current.id, controller.signal, getCachedPassword(current.id))
- .then((result) => {
- if (!controller.signal.aborted) setRelations(result.items);
- })
- .catch(() => {
- if (!controller.signal.aborted) setRelationsError("关联方案加载失败");
- });
- return () => controller.abort();
- }, [current.id, current.documentType, isModal, canView]);
- /** 跳转查看关联方案:带密码锁重试;成功后整个弹窗切换为目标方案 */
- const jumpToRelation = async (relation: DocumentRelationItem) => {
- if (relationJumping) return;
- setRelationJumping(true);
- setRelationsError("");
- try {
- const detail = await withDocumentPassword(
- relation.relatedDocumentId,
- relation.relatedDocumentName,
- DocumentPasswordAction.VIEW,
- (password) => documentApi.detail(relation.relatedDocumentId, undefined, password),
- );
- setNavigated(detail);
- } catch (value) {
- if (value instanceof HttpError) {
- setRelationsError(
- value.code === "DOCUMENT_PASSWORD_REQUIRED" || value.code === "DOCUMENT_PASSWORD_INVALID"
- ? "该方案已设置密码保护,已取消跳转"
- : value.httpStatus === 403 || value.code === "DOCUMENT_VIEW_FORBIDDEN"
- ? "当前账号无权查看该方案"
- : value.message,
- );
- } else {
- setRelationsError("跳转失败,请稍后重试");
- }
- } finally {
- setRelationJumping(false);
- }
- };
- // 按空行(≥1 个空行)分段;过滤纯空白段。
- const paragraphs = useMemo(() => {
- if (!contentText) return [];
- return contentText
- .split(/\n\s*\n+/)
- .map((segment) => segment.trim())
- .filter((segment) => segment.length > 0);
- }, [contentText]);
- // 正文搜索:在提取文本上做跨空白(含换行)匹配,主要解决 PDF 转换后
- // 关键词被换行符拆开导致直接 indexOf 漏匹配的问题。仅 modal 模式展示
- // 搜索 UI;side 模式下 contentText 始终为 null,hook 内部为空操作。
- // 决策1:pdfjs 渲染激活时以 PDF 侧实际可定位的命中数为计数权威;
- // 提取文本面板保留自身高亮,序号与 PDF 侧共用同一 currentIndex。
- const [pdfMatchCount, setPdfMatchCount] = useState<number | null>(null);
- const pdfSearchActive = !!pdfBlob;
- const search = useKeywordSearch(contentText, paragraphs, pdfSearchActive ? pdfMatchCount : null);
- const textPanelRef = useRef<HTMLElement>(null);
- // 补充说明:加载/保存成功后草稿与服务端值对齐
- useEffect(() => {
- setDescriptionDraft(description ?? "");
- setDescriptionMessage("");
- }, [description]);
- const canEditDescription = isModal && current.allowedActions.includes(AllowedAction.EDIT);
- const saveDescription = async () => {
- if (!canEditDescription || savingDescription) return;
- setSavingDescription(true);
- setDescriptionMessage("");
- try {
- const detail = await withDocumentPassword(
- current.id,
- current.documentName,
- DocumentPasswordAction.EDIT,
- (password) => documentMutationApi.updateDescription(
- current.id,
- descriptionDraft.trim() || null,
- undefined,
- password,
- ),
- );
- setDescription(detail.description ?? null);
- invalidateDetail(
- current.documentType === DocumentType.ATTACHMENT ? "attachment" : "document",
- current.id,
- );
- notifyContentChanged(
- current.documentType === DocumentType.ATTACHMENT ? "attachments" : "documents",
- );
- setDescriptionMessage("说明已保存。");
- } catch (value) {
- setDescriptionMessage(
- value instanceof HttpError
- ? `${value.message}${value.requestId ? `(错误编号:${value.requestId})` : ""}`
- : "说明保存失败,请稍后重试",
- );
- } finally {
- setSavingDescription(false);
- }
- };
- // 当前命中切换时滚动到右侧文本面板的可视区域。用 ref 限定查找范围,
- // 避免与同时挂载的 side 预览实例的 DOM 冲突。
- useEffect(() => {
- if (search.totalCount === 0) return;
- const root = textPanelRef.current;
- if (!root) return;
- const hit = root.querySelector<HTMLElement>(
- 'mark[data-search-hit="current"]',
- );
- if (hit) hit.scrollIntoView({ block: "center" });
- }, [search.currentIndex, search.totalCount]);
- useEffect(() => {
- if (!canView) {
- // 无权限:不发预览请求,直接给出提示(复用 403 文案路径)。
- setPdfBlob(null);
- setPdfMatchCount(null);
- setImageBlob(null);
- setError(new HttpError({
- httpStatus: 403,
- code: "DOCUMENT_VIEW_FORBIDDEN",
- message: "当前账号无权查看该文件",
- details: null,
- requestId: "",
- }));
- setLoading(false);
- return;
- }
- const version = ++requestVersion.current;
- const preview = acquirePreview(
- current.id,
- `${current.rowVersion}:${current.updatedAt}:${retryVersion}`,
- getCachedPassword(current.id),
- );
- let responseRequestId = "unknown";
- setPdfBlob(null);
- setPdfMatchCount(null);
- setImageBlob(null);
- setError(null);
- setLoading(true);
- setLightboxSrc(null);
- setDocxSize(null);
- if (docxContainer.current) docxContainer.current.replaceChildren();
- void preview.promise.then(async (result) => {
- responseRequestId = result.requestId;
- if (version !== requestVersion.current) return;
- const mime = (result.contentType || result.blob.type || "").split(";", 1)[0].trim().toLowerCase();
- const extension = current.fileExtension.trim().toLowerCase();
- // 预览:PDF/DOC/DOCX/WPS/XLS/XLSX/ET 统一转换返回 application/pdf;图片原图直出 image/*
- if ((extension === "pdf" || extension === "doc" || extension === "docx" || extension === "wps"
- || extension === "xls" || extension === "xlsx" || extension === "et") && mime === PDF_MIME) {
- if (version !== requestVersion.current) return;
- setPdfBlob(result.blob);
- return;
- }
- if (IMAGE_PREVIEW_EXTENSIONS.has(extension) && mime.startsWith("image/")) {
- if (version !== requestVersion.current) return;
- setImageBlob(result.blob);
- return;
- }
- if (extension === "docx") {
- // 受控回退:仅当后端明确返回 DOCX 时使用 docx-preview
- if (mime !== DOCX_MIME) {
- throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { expected: DOCX_MIME, actual: mime }, requestId: result.requestId });
- }
- const buffer = await result.blob.arrayBuffer();
- if (version !== requestVersion.current || !docxContainer.current) return;
- docxContainer.current.replaceChildren();
- await renderAsync(buffer, docxContainer.current, undefined, {
- inWrapper: true,
- ignoreWidth: mode === "side",
- ignoreHeight: true,
- breakPages: true,
- useBase64URL: true,
- });
- if (version !== requestVersion.current || !docxContainer.current) return;
- sanitizeRenderedContent(docxContainer.current);
- // 测量自然尺寸供缩放占位层使用(此时无占位/缩放约束,scrollWidth/Height
- // 即内容真实布局尺寸;docxContainer 自身的 transform 不影响该读数)
- setDocxSize({
- w: docxContainer.current.scrollWidth,
- h: docxContainer.current.scrollHeight,
- });
- return;
- }
- throw new HttpError({ httpStatus: result.httpStatus, code: "INTERNAL_ERROR", message: "预览文件类型与响应内容不一致", details: { extension, actual: mime }, requestId: result.requestId });
- }).catch((value: unknown) => {
- if (version !== requestVersion.current) return;
- const httpError = previewError(value, responseRequestId);
- // 设密主案:弹窗获取方案密码,验证通过后带密码重试预览
- if (
- httpError.code === "DOCUMENT_PASSWORD_REQUIRED" ||
- httpError.code === "DOCUMENT_PASSWORD_INVALID"
- ) {
- void requestDocumentPassword(current.id, current.documentName, DocumentPasswordAction.VIEW)
- .then((password) => {
- if (version !== requestVersion.current) return;
- if (!password) {
- setError(httpError);
- return;
- }
- cachePassword(current.id, password);
- setRetryVersion((current) => current + 1);
- });
- return;
- }
- setError(httpError);
- }).finally(() => {
- if (version === requestVersion.current) setLoading(false);
- });
- return () => {
- requestVersion.current += 1;
- preview.release();
- if (docxContainer.current) docxContainer.current.replaceChildren();
- };
- }, [current.id, current.rowVersion, current.updatedAt, retryVersion, mode, canView]);
- const previewChildren = (
- <>
- {loading && <div className="absolute inset-0 z-10 flex items-center justify-center text-base font-black" style={{ color: "var(--dms-deep)", background: "rgba(235,244,253,.9)" }}><RefreshCw size={15} className="animate-spin mr-2" />正在加载预览…</div>}
- {pdfBlob && (
- <PdfViewer
- blob={pdfBlob}
- documentName={current.documentName}
- onImageActivate={setLightboxSrc}
- searchKeyword={search.keyword}
- currentMatchIndex={search.currentIndex}
- onMatchCount={setPdfMatchCount}
- onFitRatio={applyFitRatio}
- zoom={contentZoom}
- />
- )}
- {imageUrl && (
- <ImageViewPane
- key={imageUrl}
- url={imageUrl}
- alt={current.documentName}
- onZoomActivate={setLightboxSrc}
- onFitRatio={applyFitRatio}
- zoom={contentZoom}
- />
- )}
- {/* docx 滚动容器:拖动平移与图片点击委托都挂在这层 */}
- <div
- ref={(node) => { docxPanRef.current = node; }}
- // 文档内图片点击 → 全屏图片浏览遮罩:docx-preview 动态渲染的 img 无法逐个绑定
- // prop,在容器上事件委托统一接管(PDF 为 canvas 渲染,无 img,天然不触发)。
- onClick={(event) => {
- const target = event.target;
- if (target instanceof HTMLImageElement && target.src) setLightboxSrc(target.src);
- }}
- className="h-full overflow-auto"
- >
- {/* 缩放占位层:transform 不参与布局,须显式按 自然尺寸×zoom 撑开滚动范围;
- 内层按自然尺寸渲染后 transform 放大(origin 左上,章节居中关系随占位层
- 等比保持)。未测量(渲染中/非 docx)时退化为普通流式容器。 */}
- <div style={docxSize
- ? { width: Math.round(docxSize.w * contentZoom), height: Math.round(docxSize.h * contentZoom), minHeight: "100%" }
- : { minHeight: "100%" }}
- >
- <div
- ref={setDocxContainer}
- className={`q3-docx-preview [&_.docx-wrapper]:!bg-[#d8e8f5] [&_section.docx]:!shadow-sm ${mode === "side" ? "[&_.docx-wrapper]:!p-3 [&_section.docx]:!max-w-full" : "[&_.docx-wrapper]:!p-8"}`}
- style={docxSize
- ? { width: docxSize.w, height: docxSize.h, transform: `scale(${contentZoom})`, transformOrigin: "0 0" }
- : undefined}
- />
- </div>
- </div>
- {/* 错误提示必须绝对定位铺满:上方 docx 滚动容器带 h-full,即使为空也占满
- 内容区,流式提示会被 overflow-hidden 裁掉而不可见(403 无权/415 不支持等同理) */}
- {error && <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-8 text-center" style={{ color: "var(--dms-deep)" }}>
- <FileWarning size={38} className="mx-auto mb-3 opacity-60" />
- <div className="text-base font-black leading-6">{errorText(error, current.fileExtension)}</div>
- {error.requestId && <div className="text-sm font-bold opacity-60 mt-2">错误编号:{error.requestId}</div>}
- {error.httpStatus !== 401 && <button onClick={() => setRetryVersion((value) => value + 1)} className="mt-4 px-3 py-1.5 text-base font-black border" style={{ borderColor: "var(--dms-bright)" }}>重试预览</button>}
- </div>}
- </>
- );
- const textPanel = (
- <aside ref={textPanelRef} className="w-[360px] flex-shrink-0 flex flex-col border-l bg-white min-h-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.2)" }}>
- {/* 补充说明:具备编辑权限的可编辑保存,其他用户只读展示 */}
- <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.15)" }}>
- <div className="text-sm font-black mb-2" style={{ color: "var(--dms-deep)" }}>补充说明</div>
- {canEditDescription ? (
- <>
- <textarea
- value={descriptionDraft}
- onChange={(event) => setDescriptionDraft(event.target.value)}
- placeholder="输入文档补充说明…"
- className="w-full h-28 px-2.5 py-2 text-base font-bold leading-6 rounded-sm outline-none resize-none border"
- style={{ color: "var(--dms-deep)", borderColor: "rgba(var(--dms-line-rgb),.3)", background: "rgba(var(--dms-line-rgb),.03)" }}
- />
- <div className="flex items-center gap-2 mt-2">
- <button
- type="button"
- onClick={() => void saveDescription()}
- disabled={savingDescription}
- className="px-3 py-1 text-base font-black text-white rounded-sm disabled:opacity-60"
- style={{ background: "var(--dms-bright)" }}
- >
- {savingDescription ? "保存中…" : "保存说明"}
- </button>
- <button
- type="button"
- onClick={() => { setDescriptionDraft(description ?? ""); setDescriptionMessage(""); }}
- disabled={savingDescription}
- className="px-3 py-1 text-base font-black border rounded-sm disabled:opacity-60"
- style={{ color: "var(--dms-deep)", borderColor: "rgba(var(--dms-line-rgb),.35)" }}
- >
- 重置
- </button>
- {descriptionMessage && (
- <span className="text-base font-bold opacity-70 break-all" style={{ color: "var(--dms-deep)" }}>{descriptionMessage}</span>
- )}
- </div>
- </>
- ) : (
- <div className="max-h-56 overflow-y-auto text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "var(--dms-deep)" }}>
- {description?.trim() ? description : "暂无说明"}
- </div>
- )}
- </div>
- {/* 关联方案:modal 模式展示,点击后在本弹窗内跳转查看目标方案 */}
- {(relations.length > 0 || relationsError) && (
- <div className="px-4 py-3 border-b flex-shrink-0" style={{ borderColor: "rgba(var(--dms-line-rgb),.15)" }} data-testid="preview-relations">
- <div className="text-base font-black mb-2 flex items-center gap-1" style={{ color: "var(--dms-deep)" }}><Link2 size={13} />关联方案</div>
- {relationsError && <div className="text-base font-bold opacity-70 mb-1" style={{ color: "var(--dms-deep)" }}>{relationsError}</div>}
- <div className="space-y-1">
- {relations.map((relation) => (
- <button
- key={relation.relatedDocumentId}
- type="button"
- disabled={relationJumping}
- onClick={() => void jumpToRelation(relation)}
- className="block w-full text-left px-2.5 py-1.5 text-base font-bold rounded-sm transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.18)] disabled:opacity-60"
- style={{ color: "var(--dms-deep)" }}
- {...tipProps("点击查看该方案")}
- >
- {relation.relationType} · {relation.relatedDocumentName}
- </button>
- ))}
- </div>
- </div>
- )}
- <div className="px-4 py-3 text-base font-black flex-shrink-0" style={{ color: "var(--dms-deep)", borderBottom: "1px solid rgba(var(--dms-line-rgb),.15)" }}>
- 提取文本
- </div>
- <div className="flex-1 min-h-0 overflow-y-auto px-4 py-3 space-y-2.5">
- {contentLoading && <div className="text-sm font-bold opacity-60">正在加载正文…</div>}
- {!contentLoading && paragraphs.length === 0 && (
- <div className="text-sm font-bold opacity-60">暂无正文</div>
- )}
- {search.paragraphMatches.map((info, index) => {
- const hasCurrent = info.globalIndices.includes(search.currentIndex);
- return (
- <div
- key={index}
- className={`rounded-sm px-2.5 py-2 ${hasCurrent ? "ring-1 ring-orange-400" : ""}`}
- style={{ background: "rgba(var(--dms-line-rgb),.04)" }}
- >
- <div className="text-sm leading-6 whitespace-pre-wrap break-words" style={{ color: "var(--dms-deep)" }}>
- {renderParagraphWithHighlights(info, search.currentIndex)}
- </div>
- <div className="mt-1.5 text-base font-black opacity-50" style={{ color: "var(--dms-deep)" }}>
- 第 {index + 1} 段 · 共 {info.text.length} 字{info.ranges.length > 0 && ` · 命中 ${info.ranges.length} 处`}
- </div>
- </div>
- );
- })}
- </div>
- </aside>
- );
- const panel = (
- <aside
- ref={!isModal ? setSideAside : undefined}
- aria-label={mode === "modal" ? "完整文档查看" : "文档右侧预览"}
- className={mode === "modal"
- ? windowState === "normal"
- ? "w-[min(1400px,95vw)] h-[90vh] flex flex-col bg-white shadow-2xl min-h-0"
- : "fixed inset-0 z-[80] flex flex-col bg-white min-h-0"
- : sideWidth == null
- ? "relative flex-1 min-w-[360px] flex flex-col border-l bg-white min-h-0"
- : "relative flex-shrink-0 flex flex-col border-l bg-white min-h-0"}
- style={{ borderColor: "rgba(var(--dms-line-rgb),.2)", ...(isModal ? null : { ...asideRestStyle, ...(sideWidth == null ? null : { width: sideWidth }) }) }}
- >
- {!isModal && (
- <div
- role="separator"
- aria-orientation="vertical"
- aria-label="拖动调整预览区宽度"
- {...tipProps("拖动调整预览区宽度")}
- className="absolute left-0 top-0 bottom-0 z-10 w-2.5 -ml-[5px] cursor-col-resize transition-colors hover:bg-[rgba(var(--dms-bright-rgb),0.35)]"
- onPointerDown={(event) => {
- if (event.button !== 0) return;
- event.preventDefault();
- const node = asideEl.current;
- if (!node) return;
- const width = node.offsetWidth;
- dragState.current = { active: true, x: event.clientX, width, latest: width };
- event.currentTarget.setPointerCapture(event.pointerId);
- if (guideEl.current) {
- guideEl.current.style.display = "block";
- guideEl.current.style.transform = `translateX(${event.clientX}px)`;
- }
- document.body.style.cursor = "col-resize";
- document.body.style.userSelect = "none";
- }}
- onPointerMove={(event) => {
- if (!dragState.current.active) return;
- event.preventDefault();
- const node = asideEl.current;
- if (!node) return;
- const { x, width } = dragState.current;
- const containerWidth = node.parentElement?.clientWidth ?? window.innerWidth;
- // 给左面板(~296px)与压缩后的中间列表至少保留 ~520px,
- // 挤压时列表名称/部门列以 truncate 收缩为"XXX…",不会被挤没。
- const max = Math.max(MIN_WIDTH, containerWidth - 520);
- // 绝对式计算:始终 = 拖动起始宽 + (起点X - 当前X) 总位移,
- // 不可把结果累加回 width——该公式已含总位移,累加会越拖越快。
- const next = Math.min(max, Math.max(MIN_WIDTH, width + (x - event.clientX)));
- dragState.current.latest = next;
- // 指示线停在钳制后的新边界,与松手实际生效宽度一致。
- if (guideEl.current) {
- guideEl.current.style.transform = `translateX(${x - (next - width)}px)`;
- }
- }}
- onPointerUp={handleDragEnd}
- onPointerCancel={handleDragEnd}
- />
- )}
- {!(isModal && windowState === "fullscreen") && (
- <div
- className={`px-4 py-3 flex gap-3 text-white ${isModal ? "items-center" : "items-start"}`}
- style={{ background: "var(--dms-deep)" }}
- onDoubleClick={isModal ? (event) => {
- if ((event.target as HTMLElement).closest("input, button")) return;
- setWindowState((current) => (current === "maximized" ? "normal" : "maximized"));
- } : undefined}
- >
- {isModal && navigated && (
- <button
- type="button"
- onClick={() => setNavigated(null)}
- aria-label="返回原方案"
- {...tipProps(`返回原方案:${item.documentName}`)}
- className="flex-shrink-0 flex items-center gap-1 px-2 py-1 text-base font-black border border-white/40 rounded-sm"
- >
- <ArrowLeft size={13} />返回
- </button>
- )}
- <div className="min-w-0 flex-1">
- <div className="text-base font-black truncate" {...tipProps(current.documentName)}>{mode === "modal" ? `文件查看 · ${current.documentName}` : current.documentName}</div>
- <div className="text-base font-bold opacity-70 mt-1">{current.fileExtension?.toUpperCase() || "无扩展名"} · {new Date(current.updatedAt).toLocaleString("zh-CN")}</div>
- </div>
- {isModal && (
- <div className="flex items-center gap-1.5 flex-shrink-0">
- <div className="relative">
- <Search size={12} className="absolute left-1.5 top-1/2 -translate-y-1/2 opacity-60" style={{ color: "var(--dms-deep)" }} />
- <input
- value={search.keyword}
- onChange={(e) => search.setKeyword(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- if (e.shiftKey) search.gotoPrev();
- else search.gotoNext();
- } else if (e.key === "Escape") {
- e.preventDefault();
- search.setKeyword("");
- }
- }}
- placeholder="搜索正文"
- className="w-52 pl-6 pr-2 py-1 text-base rounded-sm bg-white/95 placeholder-black/45 text-black"
- />
- </div>
- <span className="text-base font-bold opacity-90 whitespace-nowrap min-w-[44px] text-center">
- {search.keyword.trim() ? `${search.totalCount === 0 ? 0 : search.currentIndex + 1} / ${search.totalCount}` : ""}
- </span>
- <button onClick={search.gotoPrev} disabled={search.totalCount === 0} aria-label="上一处命中" className="disabled:opacity-40"><ChevronUp size={16} /></button>
- <button onClick={search.gotoNext} disabled={search.totalCount === 0} aria-label="下一处命中" className="disabled:opacity-40"><ChevronDown size={16} /></button>
- </div>
- )}
- {isModal && (
- <button
- type="button"
- onClick={() => setWindowState((current) => (current === "maximized" ? "normal" : "maximized"))}
- aria-label={windowState === "maximized" ? "还原窗口" : "最大化窗口"}
- {...tipProps(windowState === "maximized" ? "还原窗口(或双击标题栏)" : "最大化窗口(或双击标题栏)")}
- className="flex-shrink-0">
- {windowState === "maximized" ? <Minimize2 size={16} /> : <Maximize2 size={16} />}
- </button>
- )}
- {isModal && (
- <button type="button" onClick={() => setWindowState("fullscreen")} aria-label="全屏展示" {...tipProps("全屏展示(隐藏标题栏)")} className="flex-shrink-0">
- <Expand size={16} />
- </button>
- )}
- <button aria-label="关闭预览" onClick={onClose} className="flex-shrink-0"><X size={17} /></button>
- </div>
- )}
- {isModal && windowState === "fullscreen" && (
- <button
- type="button"
- onClick={() => setWindowState("normal")}
- className="absolute top-3 right-4 z-30 flex items-center gap-1.5 px-3 py-1.5 text-base font-black text-white rounded-sm shadow-lg"
- style={{ background: "rgba(var(--dms-deep-rgb),.85)" }}
- >
- <Shrink size={14} />退出全屏
- </button>
- )}
- <div className={`flex-1 min-h-0 overflow-hidden relative ${isModal ? "flex flex-row" : ""}`} style={{ background: "var(--dms-canvas)" }}>
- {isModal ? (
- /* 外层 flex 子项容器把内容区限制在文本面板左侧(textPanel 360px),
- absolute inset-0 相对本层定位 */
- <div className="flex-1 min-w-0 relative overflow-hidden">
- {/* 内容区:倍率经 contentZoom 传入各渲染面板放大真实布局尺寸,
- 滚动条随内容增长自然出现,标题栏等面板装饰不参与缩放。
- Ctrl+滚轮监听不挂本层——modal 模式统一挂在弹窗遮罩根
- (见下方 createPortal 分支),标题栏/提取文本面板/遮罩上的
- 滚轮同样缩放文档并阻止浏览器原生整页缩放。 */}
- <div className="absolute inset-0">
- {previewChildren}
- </div>
- </div>
- ) : (
- /* side 模式同款:外部 rightZoom 的倍率同样只传给内容渲染面板,
- 标题栏等面板装饰不随缩放(监听仍在 aside 上,事件自内容区冒泡) */
- <div className="absolute inset-0">
- {previewChildren}
- </div>
- )}
- {isModal && textPanel}
- </div>
- </aside>
- );
- if (mode === "modal") {
- // Portal 到 body:modal 弹窗必须脱离调用方布局树。共享资料库(F4AttachmentLibrary)
- // 渲染在中栏容器内——那里挂着区域缩放(CSS zoom + Ctrl+滚轮监听),弹窗留在其内
- // 会导致一次滚轮双命中两个监听器(文档与整个中栏一同缩放),且 CSS zoom 祖先会把
- // fixed 遮罩一并缩放。portal 到 body 后 wheel 冒泡链上只有 modalZoom,视觉不变。
- // modalZoom.ref 挂遮罩根:弹窗全域(标题栏/提取文本面板/遮罩)Ctrl+滚轮统一缩放
- // 文档并 preventDefault,与 side 模式(监听挂 aside 根)语义一致。
- return createPortal(
- <div ref={modalZoom.ref} className="fixed inset-0 z-[80] flex items-center justify-center" style={{ background: "rgba(var(--dms-deep-rgb),.58)" }}><div>{panel}</div>{lightboxSrc && <ImageLightbox key={lightboxSrc} src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}</div>,
- document.body,
- );
- }
- // 指示线挂在 aside 外层,与面板同级统一渲染
- return (
- <>
- <div
- ref={guideEl}
- aria-hidden
- className="fixed left-0 top-0 bottom-0 z-[90] w-0.5 pointer-events-none"
- style={{ display: "none", background: "var(--dms-bright)", willChange: "transform" }}
- />
- {panel}
- {/* 图片浏览遮罩在内容区之外渲染:docx 缩放后内层带 transform,
- transform 祖先会成为 fixed 定位的包含块 */}
- {lightboxSrc && <ImageLightbox key={lightboxSrc} src={lightboxSrc} onClose={() => setLightboxSrc(null)} />}
- </>
- );
- }
|