<script lang="ts" setup>
|
import type { NormalizedOfficeDocumentConfig, OpenOfficeDocumentConfig } from './types';
|
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicModal, useModal } from '@jnpf/ui/modal';
|
|
import { CloseOutlined, LoadingOutlined, PrinterOutlined } from '@ant-design/icons-vue';
|
import { Button, InputNumber, Space, Spin, Tag, Tooltip } from 'ant-design-vue';
|
|
import { getOnlyOfficeConfig, getOnlyOfficeSaveStatus } from '#/api/x/onlyoffice';
|
import OnlyOfficeEditor from '#/components/OnlyOfficeEditor/src/Editor.vue';
|
import { router } from '#/router';
|
import { onlineUtils } from '#/utils/jnpf';
|
import { DmsPrintError, printDmsDocument, printManagedDmsDocument } from '#/views/x/dms/shared/dmsHiprint';
|
|
import type { DmsPrintAttempt } from '#/views/x/dms/shared/dmsPrintAttempt';
|
import { finishDmsPrintAttempt, prepareDmsPrintAttempt } from '#/views/x/dms/shared/dmsPrintAttempt';
|
|
import { withDmsPreviewTicketRefresh } from './helpers/dmsPreviewTicket';
|
import { normalizeOfficeDocumentConfig, resolveFileName, resolveOnlyOfficeEditorMode, validateOfficeDocumentConfig } from './helpers/officeDocument';
|
|
const emitter = onlineUtils.getEmitter();
|
const [registerModal, { closeModal, openModal }] = useModal();
|
const { createConfirm, createMessage } = useMessage();
|
|
const config = ref<NormalizedOfficeDocumentConfig | null>(null);
|
const editorConfig = ref<null | Record<string, any>>(null);
|
const loading = ref(false);
|
const errorMessage = ref('');
|
const closing = ref(false);
|
const modified = ref(false);
|
const unsavedChanges = ref(false);
|
const saving = ref(false);
|
const printing = ref(false);
|
const pendingPrintConfirmation = ref(false);
|
let reprintFinished = false;
|
const managedPrintAttempt = ref<DmsPrintAttempt | null>(null);
|
let managedQuantityAlreadyCounted = false;
|
const selectedCopies = ref<number | null>(1);
|
const submittedCopies = ref(1);
|
const printQuantity = computed(() => config.value?.dmsPrint?.printQuantity);
|
const remainingCopies = computed(() => Math.max(0, (printQuantity.value?.total ?? 0) - (printQuantity.value?.printed ?? 0)));
|
const maxCopies = computed(() => Math.min(999, remainingCopies.value));
|
const printLabel = computed(() =>
|
printing.value ? '正在打印' : pendingPrintConfirmation.value ? '重试记录确认' : managedPrintAttempt.value ? '恢复打印任务' : '打印',
|
);
|
const editorReady = ref(false);
|
const editorRef = ref<null | { grabFocus: () => void; requestClose: () => void }>(null);
|
const awaitingEditorClose = ref(false);
|
const releasingSession = ref(false);
|
const releasePending = ref(false);
|
const validatingClose = ref(false);
|
const confirmingClose = ref(false);
|
const savePhase = ref<'validating' | null>(null);
|
const operationMaskMessage = computed(() => {
|
if (releasingSession.value) return '正在关闭文档...';
|
if (validatingClose.value || savePhase.value === 'validating') return '模板校验中...';
|
return '';
|
});
|
// 递增标记:关闭或再次打开后让在途的 config 请求失效(照搬 GlobalReportViewModal 的做法)
|
let openRequestId = 0;
|
let lastSaveTime: null | number = null;
|
let resolveSubmitClose: (() => void) | undefined;
|
let submitClosePromise: Promise<void> | undefined;
|
function prepareForSubmit(): Promise<void> {
|
if (submitClosePromise) return submitClosePromise;
|
if (closing.value) return Promise.resolve();
|
if (!editorReady.value || loading.value || errorMessage.value) return Promise.reject(new Error(errorMessage.value || '附件编辑器尚未就绪'));
|
// requestClose delegates unsaved-change handling to Document Server. Only its close acknowledgement
|
// may unmount the editor; never use the modal's discard-unsaved branch for submission.
|
submitClosePromise = new Promise<void>((resolve, reject) => {
|
let stopWaiting: (() => void) | undefined;
|
const timer = setTimeout(() => {
|
stopWaiting?.();
|
resolveSubmitClose = undefined;
|
reject(new Error('文档尚未完成保存或关闭,请在编辑器中确认后重试'));
|
}, 60_000);
|
resolveSubmitClose = () => {
|
clearTimeout(timer);
|
stopWaiting?.();
|
resolve();
|
};
|
if (saving.value)
|
stopWaiting = watch(saving, (value) => {
|
if (!value) {
|
stopWaiting?.();
|
requestEditorClose();
|
}
|
});
|
else requestEditorClose();
|
}).finally(() => {
|
submitClosePromise = undefined;
|
});
|
return submitClosePromise;
|
}
|
let saveBaselineReady = false;
|
let savePollingId = 0;
|
let saveMonitorActive = false;
|
let saveMonitorDeadline = 0;
|
let releasePollingId = 0;
|
let printRequestId = 0;
|
let closeConfirmHandle: null | { destroy: () => void } = null;
|
let closeConfirmId = 0;
|
let qianpiyeSavedFile: null | NormalizedOfficeDocumentConfig['file'] = null;
|
|
const SAVE_CONFIRM_TIMEOUT_MILLIS = 60_000;
|
const SAVE_STATUS_POLL_INTERVAL_MILLIS = 500;
|
const SESSION_RELEASE_TIMEOUT_MILLIS = 60_000;
|
|
/** 后端可能把 edit 降级为 view(格式不可编辑 / 他人正持锁 / 上次保存失败),据此在标题上给出提示 */
|
const actualMode = ref<'edit' | 'view'>('edit');
|
|
function clearCloseConfirmState() {
|
confirmingClose.value = false;
|
closeConfirmHandle = null;
|
}
|
|
function dismissCloseConfirm() {
|
closeConfirmId++;
|
closeConfirmHandle?.destroy();
|
clearCloseConfirmState();
|
}
|
|
function showCloseConfirm(options: Omit<Parameters<typeof createConfirm>[0], 'onCancel' | 'onOk'>, onConfirm: () => any) {
|
const currentOpenRequestId = openRequestId;
|
const currentConfirmId = ++closeConfirmId;
|
const isCurrent = () => currentOpenRequestId === openRequestId && currentConfirmId === closeConfirmId;
|
let resumeEditing = false;
|
confirmingClose.value = true;
|
closeConfirmHandle = createConfirm({
|
...options,
|
// 默认会把焦点还给宿主关闭按钮;确认框退场后再交回跨域文档编辑器。
|
focusTriggerAfterClose: false,
|
onCancel: () => {
|
if (!isCurrent()) return;
|
resumeEditing = true;
|
clearCloseConfirmState();
|
},
|
afterClose: async () => {
|
await nextTick();
|
if (!resumeEditing || !isCurrent() || closing.value || releasePending.value || !editorReady.value) return;
|
editorRef.value?.grabFocus();
|
},
|
onOk: () => {
|
if (!isCurrent()) return;
|
clearCloseConfirmState();
|
return onConfirm();
|
},
|
}) as unknown as { destroy: () => void };
|
}
|
|
async function handleOpen(configValue: OpenOfficeDocumentConfig) {
|
if (printing.value || pendingPrintConfirmation.value) {
|
createMessage.error('请先完成当前打印及记录确认');
|
return;
|
}
|
const currentRequestId = ++openRequestId;
|
printRequestId++;
|
|
try {
|
validateOfficeDocumentConfig(configValue);
|
} catch (error: any) {
|
createMessage.error(error?.message || '文档打开参数无效');
|
configValue?.onOpenError?.(error);
|
return;
|
}
|
|
reprintFinished = false;
|
managedPrintAttempt.value = null;
|
managedQuantityAlreadyCounted = false;
|
config.value = normalizeOfficeDocumentConfig(configValue);
|
selectedCopies.value = config.value.dmsPrint?.printQuantity
|
? Math.min(1, Math.max(0, config.value.dmsPrint.printQuantity.total - config.value.dmsPrint.printQuantity.printed))
|
: (config.value.dmsPrint?.copies ?? 1);
|
actualMode.value = resolveOnlyOfficeEditorMode(config.value.mode);
|
dismissCloseConfirm();
|
editorConfig.value = null;
|
editorReady.value = false;
|
errorMessage.value = '';
|
modified.value = false;
|
unsavedChanges.value = false;
|
closing.value = false;
|
awaitingEditorClose.value = false;
|
releasingSession.value = false;
|
releasePending.value = false;
|
saving.value = false;
|
printing.value = false;
|
validatingClose.value = false;
|
savePhase.value = null;
|
lastSaveTime = null;
|
saveBaselineReady = false;
|
savePollingId++;
|
releasePollingId++;
|
saveMonitorActive = false;
|
saveMonitorDeadline = 0;
|
qianpiyeSavedFile = null;
|
loading.value = true;
|
|
try {
|
await nextTick();
|
if (closing.value || currentRequestId !== openRequestId) return;
|
openModal(true);
|
} catch (error: any) {
|
if (currentRequestId !== openRequestId) return;
|
loading.value = false;
|
closing.value = true;
|
config.value = null;
|
createMessage.error(error?.message || '无法打开文档窗口');
|
configValue.onOpenError?.(error);
|
return;
|
}
|
|
try {
|
// 保存完成只靠 lastSaveTime 变化确认,因此编辑器可操作前必须先取得基线。
|
// 放在 config 之前,查询失败时不会让后端先签发编辑租约却没有 DS 会话来维持心跳。
|
if (resolveOnlyOfficeEditorMode(config.value.mode) === 'edit') {
|
const statusRes = await getOnlyOfficeSaveStatus(configValue.file.fileId, config.value.fileType);
|
if (currentRequestId !== openRequestId) return;
|
const status = (statusRes as any)?.data ?? statusRes;
|
lastSaveTime = typeof status?.lastSaveTime === 'number' ? status.lastSaveTime : null;
|
saveBaselineReady = true;
|
}
|
|
const previewConfig = config.value;
|
const loadConfig = () =>
|
getOnlyOfficeConfig(
|
{
|
bizDataId: previewConfig.bizDataId,
|
bizModule: previewConfig.bizModule,
|
bizScene: previewConfig.bizScene,
|
dmsAccessTicket: previewConfig.dmsAccessTicket,
|
fileId: previewConfig.file.fileId,
|
fileName: resolveFileName(previewConfig),
|
fileType: previewConfig.fileType,
|
mode: previewConfig.mode,
|
...(previewConfig.locateControlTag ? { locateControlTag: previewConfig.locateControlTag } : {}),
|
...(previewConfig.repairBindingId ? { repairBindingId: previewConfig.repairBindingId } : {}),
|
},
|
...(previewConfig.refreshDmsPreviewTicket ? ([{ errorMessageMode: 'none' }] as const) : ([] as const)),
|
);
|
const res = previewConfig.refreshDmsPreviewTicket
|
? await withDmsPreviewTicketRefresh(previewConfig, () => currentRequestId === openRequestId, loadConfig)
|
: await loadConfig();
|
if (currentRequestId !== openRequestId) return;
|
|
const data = (res as any)?.data ?? res;
|
if (!data?.document?.key || !data?.token) {
|
throw new Error('后端返回的编辑器配置不完整(缺 document.key 或 token)');
|
}
|
actualMode.value = data.editorConfig?.mode === 'view' ? 'view' : 'edit';
|
editorConfig.value = data;
|
config.value?.registerSubmitPreparation?.(prepareForSubmit);
|
} catch (error: any) {
|
if (currentRequestId !== openRequestId) return;
|
errorMessage.value = error?.message || '获取编辑器配置失败';
|
} finally {
|
if (currentRequestId === openRequestId) loading.value = false;
|
}
|
}
|
|
async function finalizeClose() {
|
if (closing.value) return true;
|
closing.value = true;
|
dismissCloseConfirm();
|
openRequestId++;
|
printRequestId++;
|
savePollingId++;
|
releasePollingId++;
|
saveMonitorActive = false;
|
saveMonitorDeadline = 0;
|
saving.value = false;
|
printing.value = false;
|
savePhase.value = null;
|
unsavedChanges.value = false;
|
awaitingEditorClose.value = false;
|
releasingSession.value = false;
|
releasePending.value = false;
|
closeModal();
|
|
// destroy-on-close 会卸载编辑器组件、触发它的 destroyEditor
|
editorConfig.value = null;
|
editorReady.value = false;
|
|
const current = config.value;
|
if (current && modified.value) {
|
current.onSave?.({
|
// 签批页的保存状态会携带后端另存为生成的 fileItem;普通场景保持原附件回调契约。
|
file: current.mode === 'qianpiye' && qianpiyeSavedFile ? qianpiyeSavedFile : current.file,
|
modified: true,
|
});
|
}
|
current?.registerSubmitPreparation?.(undefined);
|
current?.onClose?.();
|
resolveSubmitClose?.();
|
resolveSubmitClose = undefined;
|
return true;
|
}
|
|
const showDmsPrintButton = computed(() => config.value?.fileType === 'dms' && Boolean(config.value.dmsPrint));
|
|
async function errorText(error: unknown) {
|
if (error instanceof DmsPrintError) return error.message;
|
const responseData = (error as any)?.response?.data;
|
if (responseData instanceof Blob) {
|
try {
|
const body = JSON.parse(await responseData.text());
|
if (body?.msg || body?.message) return body.msg || body.message;
|
} catch {
|
// 非 JSON 错误体使用下方的通用提示。
|
}
|
}
|
return (error as any)?.message || '';
|
}
|
|
async function finishReprintPreview(current: OpenOfficeDocumentConfig) {
|
if (config.value !== current || reprintFinished || closing.value) return;
|
reprintFinished = true;
|
printing.value = false;
|
createMessage.success('重打发送成功,不平衡登记已完成');
|
try {
|
await current.dmsPrint?.onPrintSuccess?.();
|
} catch {
|
createMessage.error('执行已完成,但列表刷新失败,请手动刷新');
|
}
|
if (config.value === current && !closing.value) await finalizeClose();
|
}
|
function handleReprintCompleted(value: { printRecordId: string; requestId: string }) {
|
const current = config.value;
|
const reprint = current?.dmsPrint?.reprint;
|
if (current && reprint?.requestId === value.requestId && reprint?.printRecordId === value.printRecordId) void finishReprintPreview(current);
|
}
|
|
async function handleDmsPrint() {
|
const current = config.value;
|
if (!current?.dmsPrint || printing.value || !editorConfig.value || !editorReady.value) return;
|
|
const requestId = ++printRequestId;
|
const isCurrent = () => requestId === printRequestId && config.value === current && !closing.value;
|
printing.value = true;
|
try {
|
if (current.dmsPrint.reprint) {
|
await current.dmsPrint.reprint.send();
|
await finishReprintPreview(current);
|
return;
|
}
|
const finishPrint = async () => {
|
if (managedPrintAttempt.value || current.dmsPrint?.confirmPrint) {
|
pendingPrintConfirmation.value = true;
|
if (managedPrintAttempt.value) await finishDmsPrintAttempt(managedPrintAttempt.value);
|
else await current.dmsPrint?.confirmPrint?.({ copies: submittedCopies.value });
|
pendingPrintConfirmation.value = false;
|
}
|
if (current.dmsPrint?.printQuantity) {
|
if (!managedQuantityAlreadyCounted) current.dmsPrint.printQuantity.printed += submittedCopies.value;
|
selectedCopies.value = Math.min(1, Math.max(0, current.dmsPrint.printQuantity.total - current.dmsPrint.printQuantity.printed));
|
}
|
createMessage.success(
|
managedPrintAttempt.value || current.dmsPrint?.confirmPrint ? '打印客户端已确认提交,打印记录处理完成' : '打印任务已发送至打印客户端',
|
);
|
managedPrintAttempt.value = null;
|
managedQuantityAlreadyCounted = false;
|
// 刷新失败不能使已确认的打印重新发送。
|
try {
|
await current.dmsPrint?.onPrintSuccess?.();
|
} catch {
|
createMessage.error('打印已确认,但列表刷新失败,请手动刷新');
|
}
|
if (isCurrent() && current.dmsPrint?.closeOnPrintSuccess) await finalizeClose();
|
};
|
if (pendingPrintConfirmation.value) {
|
await finishPrint();
|
return;
|
}
|
const copies = current.dmsPrint.printQuantity ? selectedCopies.value : (current.dmsPrint.copies ?? 1);
|
if (
|
!managedPrintAttempt.value &&
|
(!Number.isInteger(copies) || copies === null || copies < 1 || copies > (current.dmsPrint.printQuantity ? maxCopies.value : 999))
|
) {
|
throw new Error('本次打印份数必须为正整数,且不能超过剩余份数或 999');
|
}
|
submittedCopies.value = managedPrintAttempt.value?.copies ?? copies!;
|
if (current.dmsPrint.printTaskId) {
|
// 原任务的份数是冻结的;另一窗口/刷新恢复不能用新的输入覆盖它。
|
const attempt = await prepareDmsPrintAttempt(current.dmsPrint, submittedCopies.value);
|
managedPrintAttempt.value = attempt;
|
managedQuantityAlreadyCounted = attempt.state !== 'OPEN';
|
submittedCopies.value = attempt.copies;
|
selectedCopies.value = attempt.copies;
|
await printManagedDmsDocument(current.file.name, current.dmsPrint, attempt);
|
await finishPrint();
|
return;
|
}
|
if (current.dmsPrint.printQuantity && current.dmsPrint.confirmPrint) {
|
throw new Error('打印台账配置需要更新,请更新并发布打印按钮后重新打开预览');
|
}
|
if (!(await onlineUtils.isHiprintClientReady())) {
|
if (isCurrent()) createMessage.error('打印客户端未连接,请启动客户端后重试');
|
return;
|
}
|
if (!isCurrent()) return;
|
await current.dmsPrint.beforePrint?.({ copies: submittedCopies.value });
|
if (!isCurrent()) return;
|
await printDmsDocument(
|
current.file.name,
|
{
|
...current.dmsPrint,
|
copies: submittedCopies.value,
|
onPrintSuccess: finishPrint,
|
},
|
isCurrent,
|
);
|
} catch (error) {
|
if (!isCurrent()) return;
|
const message = await errorText(error);
|
if (!isCurrent()) return;
|
if (pendingPrintConfirmation.value) {
|
createMessage.error(`打印客户端已确认提交,但记录确认失败:${message || '网络异常'}。请点击“重试记录确认”,不会再次打印。`);
|
return;
|
}
|
if (/权限|forbidden|403/i.test(message)) createMessage.error('无当前文件的打印权限');
|
else if (/凭证|票据|ticket|过期/i.test(message)) createMessage.error('打印凭证已过期,请重新打印');
|
else if (/页码|page/i.test(message)) createMessage.error(message || '打印页码超出文档范围');
|
else if (/参数|变量/.test(message)) createMessage.error(message || '页面打印参数无效,请检查后重试');
|
else if (/转换|convert/i.test(message)) createMessage.error('文件转换失败,请稍后重试');
|
else createMessage.error(message || '打印失败,请检查客户端和打印机');
|
} finally {
|
if (isCurrent()) printing.value = false;
|
}
|
}
|
|
async function waitForSessionRelease() {
|
const current = config.value;
|
if (!current || closing.value || releasingSession.value) return false;
|
|
// 从此刻起当前编辑会话只允许释放;让在途 config、保存观察和关闭校验全部失效。
|
const currentOpenRequestId = ++openRequestId;
|
const pollingId = ++releasePollingId;
|
const isCurrentSession = () => !closing.value && currentOpenRequestId === openRequestId && pollingId === releasePollingId;
|
const deadline = Date.now() + SESSION_RELEASE_TIMEOUT_MILLIS;
|
|
releasePending.value = true;
|
releasingSession.value = true;
|
awaitingEditorClose.value = false;
|
errorMessage.value = '';
|
savePollingId++;
|
saveMonitorActive = false;
|
saveMonitorDeadline = 0;
|
saving.value = false;
|
savePhase.value = null;
|
editorConfig.value = null;
|
editorReady.value = false;
|
|
// 先让 OnlyOfficeEditor 卸载并执行 destroyEditor,DS 断连后才可能回调 status 2/4。
|
await nextTick();
|
|
try {
|
for (;;) {
|
if (!isCurrentSession()) return false;
|
try {
|
const res = await getOnlyOfficeSaveStatus(current.file.fileId, current.fileType);
|
if (!isCurrentSession()) return false;
|
const status = (res as any)?.data ?? res;
|
if (status?.sessionStatus === 'IDLE') {
|
const savedAt = typeof status?.lastSaveTime === 'number' ? status.lastSaveTime : null;
|
if (savedAt !== null && savedAt !== lastSaveTime) {
|
captureQianpiyeSavedFile(current, status);
|
}
|
releasePending.value = false;
|
return finalizeClose();
|
}
|
} catch {
|
// DS 的最终回调或服务端写回可能仍在进行,窗口内继续查询。
|
}
|
|
const remaining = deadline - Date.now();
|
if (remaining <= 0) break;
|
await new Promise((resolve) => setTimeout(resolve, Math.min(SAVE_STATUS_POLL_INTERVAL_MILLIS, remaining)));
|
}
|
|
if (isCurrentSession()) {
|
errorMessage.value = '文档会话尚未结束,请稍后重试关闭';
|
createMessage.error(errorMessage.value);
|
}
|
return false;
|
} finally {
|
if (pollingId === releasePollingId) releasingSession.value = false;
|
}
|
}
|
|
function requestEditorClose() {
|
const editor = editorRef.value;
|
if (!editor) return finalizeClose();
|
awaitingEditorClose.value = true;
|
editor.requestClose();
|
return false;
|
}
|
|
async function handleClose() {
|
if (printing.value || pendingPrintConfirmation.value) {
|
createMessage.error('打印及记录确认尚未完成,请等待或重试记录确认');
|
return false;
|
}
|
if (closing.value || releasingSession.value || saving.value || validatingClose.value || confirmingClose.value) return false;
|
if (releasePending.value) return waitForSessionRelease();
|
const currentOpenRequestId = openRequestId;
|
const isCurrentSession = () => !closing.value && currentOpenRequestId === openRequestId;
|
const closeCurrentSession = () => {
|
if (!isCurrentSession()) return false;
|
return finalizeClose();
|
};
|
const beforeClose = config.value?.beforeClose;
|
if (actualMode.value === 'edit' && beforeClose) {
|
validatingClose.value = true;
|
try {
|
await nextTick();
|
await beforeClose();
|
} catch (error: any) {
|
if (!isCurrentSession()) return false;
|
const validationMessage = error?.message || '模板校验失败,请确认是否仍然关闭';
|
const discardUnsavedChanges = unsavedChanges.value;
|
showCloseConfirm(
|
{
|
cancelText: '返回标注',
|
content: discardUnsavedChanges ? `${validationMessage}\n关闭后,当前未保存的文档更改也将丢失。` : validationMessage,
|
iconType: 'warning',
|
okText: '仍然关闭',
|
title: '标注未完成',
|
},
|
discardUnsavedChanges ? waitForSessionRelease : requestEditorClose,
|
);
|
return false;
|
} finally {
|
validatingClose.value = false;
|
}
|
}
|
if (!isCurrentSession()) return false;
|
if (actualMode.value === 'edit' && unsavedChanges.value) {
|
showCloseConfirm(
|
{
|
cancelText: '继续编辑',
|
content: '关闭后,当前未保存的文档更改将丢失。',
|
iconType: 'warning',
|
okText: '放弃并关闭',
|
okType: 'danger',
|
title: '放弃未保存的更改?',
|
},
|
waitForSessionRelease,
|
);
|
return false;
|
}
|
if (actualMode.value === 'edit') return requestEditorClose();
|
return closeCurrentSession();
|
}
|
|
function handleEditorClose() {
|
if (saving.value) return;
|
if (awaitingEditorClose.value) {
|
void waitForSessionRelease();
|
return;
|
}
|
void handleClose();
|
}
|
|
function handleEditorModified(value: boolean) {
|
// DS 官方定义中 false 只表示更改已发往编辑服务,撤销、重做和普通编辑都会触发,不能当作手动保存。
|
const shouldMonitorSave = !value && unsavedChanges.value;
|
unsavedChanges.value = value;
|
if (value) modified.value = true;
|
if (shouldMonitorSave) void monitorSaveStatus();
|
}
|
|
async function handleConfirmedSave(current: NormalizedOfficeDocumentConfig, pollingId: number) {
|
if (pollingId !== savePollingId || closing.value) return;
|
unsavedChanges.value = false;
|
|
if (!current.onManualSave) {
|
createMessage.success('文档保存成功');
|
return;
|
}
|
|
saving.value = true;
|
savePhase.value = 'validating';
|
try {
|
await nextTick();
|
let validationTimeout: ReturnType<typeof setTimeout> | undefined;
|
try {
|
await Promise.race([
|
current.onManualSave(),
|
new Promise<never>((_, reject) => {
|
validationTimeout = setTimeout(() => reject(new Error('模板校验超时,请检查')), SAVE_CONFIRM_TIMEOUT_MILLIS);
|
}),
|
]);
|
} catch (error: any) {
|
if (pollingId !== savePollingId || closing.value) return;
|
createMessage.warning(error?.message || '模板校验未通过,请检查');
|
} finally {
|
if (validationTimeout !== undefined) clearTimeout(validationTimeout);
|
}
|
if (pollingId === savePollingId && !closing.value) createMessage.success('文档保存成功');
|
} finally {
|
if (pollingId === savePollingId) {
|
saving.value = false;
|
savePhase.value = null;
|
}
|
}
|
}
|
|
function captureQianpiyeSavedFile(current: NormalizedOfficeDocumentConfig, status: any) {
|
if (current.mode !== 'qianpiye' || !status?.file?.fileId) return;
|
qianpiyeSavedFile = status.file;
|
}
|
|
async function monitorSaveStatus() {
|
const current = config.value;
|
if (!current || closing.value || saving.value || actualMode.value !== 'edit' || !saveBaselineReady) return;
|
|
saveMonitorDeadline = Date.now() + SAVE_CONFIRM_TIMEOUT_MILLIS;
|
if (saveMonitorActive) return;
|
|
const pollingId = savePollingId;
|
saveMonitorActive = true;
|
try {
|
for (;;) {
|
if (pollingId !== savePollingId || closing.value) return;
|
try {
|
const res = await getOnlyOfficeSaveStatus(current.file.fileId, current.fileType);
|
if (pollingId !== savePollingId || closing.value) return;
|
const status = (res as any)?.data ?? res;
|
const savedAt = typeof status?.lastSaveTime === 'number' ? status.lastSaveTime : null;
|
if (!saveBaselineReady) {
|
lastSaveTime = savedAt;
|
saveBaselineReady = true;
|
continue;
|
}
|
if (savedAt !== null && savedAt !== lastSaveTime) {
|
captureQianpiyeSavedFile(current, status);
|
lastSaveTime = savedAt;
|
await handleConfirmedSave(current, pollingId);
|
return;
|
}
|
} catch {
|
// 回调写回可能尚未完成,下一轮继续查询。
|
}
|
const remaining = saveMonitorDeadline - Date.now();
|
if (remaining <= 0) return;
|
await new Promise((resolve) => setTimeout(resolve, Math.min(SAVE_STATUS_POLL_INTERVAL_MILLIS, remaining)));
|
}
|
} finally {
|
if (pollingId === savePollingId) saveMonitorActive = false;
|
}
|
}
|
|
function handleEditorError(message: string) {
|
editorReady.value = false;
|
errorMessage.value = message;
|
}
|
|
function handleEditorReady() {
|
if (!closing.value && editorConfig.value) editorReady.value = true;
|
}
|
|
let removeRouteHook: (() => void) | null = null;
|
|
onMounted(() => {
|
emitter.on('OPEN_OFFICE_DOCUMENT', handleOpen);
|
emitter.on('DMS_REPRINT_COMPLETED', handleReprintCompleted);
|
// 弹窗挂在 app.vue 上、不属于任何路由,切页面时不会跟着卸载:不主动收掉的话,
|
// 全屏编辑器会盖在新页面上,且一直占着 DS 的编辑锁与长连接。
|
// 只认成功且真的换了地址的导航——失败的导航(被守卫拦下、重复跳转)页面没动,跟着关就成了误伤。
|
removeRouteHook = router.afterEach((to, from, failure) => {
|
if (failure || to.fullPath === from.fullPath) return;
|
if (closing.value || !config.value || printing.value || pendingPrintConfirmation.value) return;
|
if (releasingSession.value || releasePending.value) return;
|
if (actualMode.value === 'edit' && editorConfig.value) {
|
void waitForSessionRelease();
|
return;
|
}
|
void finalizeClose();
|
});
|
});
|
|
onUnmounted(() => {
|
config.value?.registerSubmitPreparation?.(undefined);
|
dismissCloseConfirm();
|
emitter.off('OPEN_OFFICE_DOCUMENT', handleOpen);
|
emitter.off('DMS_REPRINT_COMPLETED', handleReprintCompleted);
|
removeRouteHook?.();
|
removeRouteHook = null;
|
});
|
</script>
|
|
<template>
|
<BasicModal
|
:close-func="handleClose"
|
:closable="false"
|
:default-fullscreen="true"
|
:footer="null"
|
:keyboard="true"
|
class="global-office-document-modal"
|
destroy-on-close
|
@register="registerModal">
|
<template #title>
|
<div class="global-office-document-modal__header">
|
<div class="header-main">
|
<span class="header-title">{{ config?.title }}</span>
|
<!-- 只标注状态,不解释原因:降级可能来自格式、他人持锁、上次保存失败或无编辑权,
|
把这些抖给用户既没用也可能泄露他人操作状态。真要排查看后端日志。 -->
|
<Tag v-if="editorConfig && actualMode === 'view'" color="var(--primary-color)" class="header-readonly-tag">只读</Tag>
|
</div>
|
<Space class="header-actions" :size="10" wrap>
|
<template v-if="showDmsPrintButton && printQuantity">
|
<span data-test="print-quantity-summary"
|
>总份数:{{ printQuantity.total }} 已打印份数:{{ printQuantity.printed }} 剩余份数:{{ remainingCopies }}</span
|
>
|
<label for="dms-print-copies">本次打印份数</label>
|
<InputNumber
|
id="dms-print-copies"
|
:value="selectedCopies ?? undefined"
|
@update:value="(value) => (selectedCopies = typeof value === 'number' ? value : null)"
|
aria-label="本次打印份数"
|
:min="1"
|
:max="maxCopies"
|
:precision="0"
|
:disabled="printing || pendingPrintConfirmation || !!managedPrintAttempt || maxCopies < 1"
|
style="width: 90px" />
|
</template>
|
<span v-if="config?.dmsPrint?.reprint">重打份数:1 份</span>
|
<Tooltip v-if="showDmsPrintButton" :title="printLabel" placement="bottom">
|
<Button
|
:aria-label="printLabel"
|
class="header-print-button"
|
type="text"
|
:disabled="!editorConfig || !editorReady || loading || printing"
|
@click="handleDmsPrint">
|
<LoadingOutlined v-if="printing" data-test="dms-print-loading-icon" spin />
|
<span v-else-if="pendingPrintConfirmation">重试记录确认</span>
|
<span v-else-if="managedPrintAttempt">恢复打印任务</span>
|
<PrinterOutlined v-else data-test="dms-print-icon" />
|
</Button>
|
</Tooltip>
|
<Tooltip title="关闭" placement="bottom">
|
<Button aria-label="关闭" class="header-close-button" type="text" @click="handleClose">
|
<CloseOutlined />
|
</Button>
|
</Tooltip>
|
</Space>
|
</div>
|
</template>
|
|
<div class="global-office-document-modal__content">
|
<div v-if="errorMessage" class="state-pane">{{ errorMessage }}</div>
|
<div v-else-if="loading || !editorConfig" class="state-pane">正在获取编辑器配置...</div>
|
<OnlyOfficeEditor
|
v-else
|
:key="editorConfig.document.key"
|
:config="editorConfig"
|
ref="editorRef"
|
@close="handleEditorClose"
|
@error="handleEditorError"
|
@modified="handleEditorModified"
|
@ready="handleEditorReady" />
|
<div v-if="operationMaskMessage" class="document-saving-mask" data-test="document-saving-mask" :data-phase="savePhase || 'validating-close'">
|
<Spin size="large" :tip="operationMaskMessage" />
|
</div>
|
</div>
|
</BasicModal>
|
</template>
|
|
<style lang="scss" scoped>
|
.global-office-document-modal__header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
flex-wrap: wrap;
|
gap: 8px;
|
width: 100%;
|
|
.header-main {
|
display: flex;
|
gap: 8px;
|
align-items: center;
|
min-width: 0;
|
}
|
|
.header-title {
|
min-width: 0;
|
overflow: hidden;
|
font-weight: 600;
|
text-overflow: ellipsis;
|
white-space: nowrap;
|
}
|
|
/* 标题过长时省略号只能吃在标题上,标签得原样留住——它比标题更要紧 */
|
.header-readonly-tag {
|
flex-shrink: 0;
|
margin-inline-end: 0;
|
}
|
|
.header-actions {
|
flex-shrink: 0;
|
}
|
|
.header-close-button,
|
.header-print-button {
|
width: 32px;
|
height: 32px;
|
padding: 0;
|
}
|
}
|
|
.global-office-document-modal__content {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
min-height: 0;
|
background: #fff;
|
|
.document-saving-mask {
|
position: fixed;
|
inset: 0;
|
z-index: 1100;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: rgb(255 255 255 / 65%);
|
}
|
|
.state-pane {
|
display: flex;
|
flex: 1;
|
align-items: center;
|
justify-content: center;
|
color: rgb(0 0 0 / 45%);
|
}
|
}
|
</style>
|
|
<style lang="scss">
|
/* 非 scoped:BasicModal 的 DOM 是 teleport 到 body 的,scoped 的作用域属性够不着,
|
按透传下去的 class 收敛即可(同 Preview.vue 的做法)。
|
编辑器要占满整个弹窗,所以把 ScrollContainer 那层的内边距与留白全部清掉——
|
来源是 @jnpf/ui 的 .ant-modal-body > .scrollbar { padding: 20px 10px }
|
与 ScrollContainer 自带的 .scrollbar__wrap { margin-bottom: 18px } */
|
.global-office-document-modal {
|
.ant-modal-header {
|
height: 48px;
|
padding: 8px 16px;
|
background-color: var(--component-background);
|
border-bottom: 1px solid var(--border-color-base1);
|
}
|
|
.ant-modal-body {
|
padding: 0 !important;
|
|
> .scroll-container {
|
padding: 0 !important;
|
|
> .scrollbar__wrap {
|
margin-bottom: 0 !important;
|
|
> .scrollbar__view {
|
height: 100%;
|
overflow: hidden;
|
|
/* ModalWrapper#setModalHeight 会给这层算一个 inline height
|
(全屏时 = innerHeight − modalFooterHeight − modalHeaderHeight − 28,
|
后两个是 57 与经验值),和真实可用高度差出几十像素,表现为弹窗底部一条空白。
|
编辑器要的是"填满剩余空间"而不是一个算出来的固定值,故用 !important 盖掉
|
inline 样式,交给 .fullscreen-modal 既有的 flex 布局去撑。 */
|
> div {
|
height: 100% !important;
|
max-height: none !important;
|
}
|
}
|
}
|
}
|
}
|
}
|
</style>
|