<script lang="ts" setup>
|
import type { NormalizedOfficeDocumentConfig, OpenOfficeDocumentConfig } from './types';
|
|
import { nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicModal, useModal } from '@jnpf/ui/modal';
|
|
import { CloseOutlined } from '@ant-design/icons-vue';
|
import { Button, 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 { normalizeOfficeDocumentConfig, resolveFileName, validateOfficeDocumentConfig } from './helpers/officeDocument';
|
|
const emitter = onlineUtils.getEmitter();
|
const [registerModal, { closeModal, openModal }] = useModal();
|
const { 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 saving = ref(false);
|
const editorRef = ref<{ requestClose: () => void } | null>(null);
|
// 递增标记:关闭或再次打开后让在途的 config 请求失效(照搬 GlobalReportViewModal 的做法)
|
let openRequestId = 0;
|
let lastSaveTime: null | number = null;
|
let saveBaselineReady = false;
|
let savePollingId = 0;
|
|
/** 后端可能把 edit 降级为 view(格式不可编辑 / 他人正持锁 / 上次保存失败),据此在标题上给出提示 */
|
const actualMode = ref<'edit' | 'view'>('edit');
|
|
async function handleOpen(configValue: OpenOfficeDocumentConfig) {
|
const currentRequestId = ++openRequestId;
|
|
try {
|
validateOfficeDocumentConfig(configValue);
|
} catch (error: any) {
|
console.error(error?.message || error);
|
return;
|
}
|
|
config.value = normalizeOfficeDocumentConfig(configValue);
|
editorConfig.value = null;
|
errorMessage.value = '';
|
modified.value = false;
|
closing.value = false;
|
saving.value = false;
|
lastSaveTime = null;
|
saveBaselineReady = false;
|
savePollingId++;
|
loading.value = true;
|
|
await nextTick();
|
if (closing.value || currentRequestId !== openRequestId) return;
|
openModal(true);
|
|
try {
|
const res = await getOnlyOfficeConfig({
|
bizDataId: configValue.bizDataId,
|
bizModule: configValue.bizModule,
|
bizScene: configValue.bizScene,
|
dmsAccessTicket: configValue.dmsAccessTicket,
|
fileId: configValue.file.fileId,
|
fileName: resolveFileName(configValue),
|
fileType: configValue.fileType,
|
mode: config.value.mode,
|
});
|
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';
|
if (actualMode.value === 'edit') {
|
try {
|
const statusRes = await getOnlyOfficeSaveStatus(configValue.file.fileId);
|
if (currentRequestId !== openRequestId) return;
|
const status = (statusRes as any)?.data ?? statusRes;
|
lastSaveTime = status?.lastSaveTime ?? null;
|
saveBaselineReady = true;
|
} catch {
|
// 初始状态查询失败不影响编辑;手动保存时会重新查询。
|
}
|
}
|
editorConfig.value = data;
|
} 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;
|
openRequestId++;
|
savePollingId++;
|
saving.value = false;
|
closeModal();
|
|
// destroy-on-close 会卸载编辑器组件、触发它的 destroyEditor
|
editorConfig.value = null;
|
|
const current = config.value;
|
if (current && modified.value) {
|
// 只报告"编辑期间有过改动"。后端的写回是 DS 在全员退出后异步回调触发的,
|
// 前端无从得知何时完成——真要拿新 fileSize,业务侧得自己重拉附件(见 types.ts 注释)
|
current.onSave?.({ file: current.file, modified: true });
|
}
|
current?.onClose?.();
|
return true;
|
}
|
|
async function handleClose() {
|
if (closing.value || saving.value) return false;
|
if (!editorRef.value) return finalizeClose();
|
editorRef.value.requestClose();
|
return false;
|
}
|
|
function handleEditorClose() {
|
if (saving.value) return;
|
void finalizeClose();
|
}
|
|
function handleEditorModified(value: boolean) {
|
if (value) modified.value = true;
|
}
|
|
async function handleEditorSave() {
|
const current = config.value;
|
if (!current || closing.value || saving.value || actualMode.value !== 'edit') return;
|
const pollingId = ++savePollingId;
|
saving.value = true;
|
try {
|
for (let attempt = 0; attempt < 24; attempt++) {
|
if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, 250));
|
if (pollingId !== savePollingId || closing.value) return;
|
try {
|
const res = await getOnlyOfficeSaveStatus(current.file.fileId);
|
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) {
|
lastSaveTime = savedAt;
|
createMessage.success('文档保存成功');
|
return;
|
}
|
} catch {
|
// 回调写回可能尚未完成,下一轮继续查询。
|
}
|
}
|
if (pollingId === savePollingId && !closing.value) createMessage.error('文档保存确认超时,请重试');
|
} finally {
|
if (pollingId === savePollingId) saving.value = false;
|
}
|
}
|
|
function handleEditorError(message: string) {
|
errorMessage.value = message;
|
}
|
|
let removeRouteHook: (() => void) | null = null;
|
|
onMounted(() => {
|
emitter.on('OPEN_OFFICE_DOCUMENT', handleOpen);
|
// 弹窗挂在 app.vue 上、不属于任何路由,切页面时不会跟着卸载:不主动收掉的话,
|
// 全屏编辑器会盖在新页面上,且一直占着 DS 的编辑锁与长连接。
|
// 只认成功且真的换了地址的导航——失败的导航(被守卫拦下、重复跳转)页面没动,跟着关就成了误伤。
|
removeRouteHook = router.afterEach((to, from, failure) => {
|
if (failure || to.fullPath === from.fullPath) return;
|
if (closing.value || !config.value) return;
|
void finalizeClose();
|
});
|
});
|
|
onUnmounted(() => {
|
emitter.off('OPEN_OFFICE_DOCUMENT', handleOpen);
|
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">
|
<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"
|
ref="editorRef"
|
:config="editorConfig"
|
@close="handleEditorClose"
|
@error="handleEditorError"
|
@modified="handleEditorModified"
|
@save="handleEditorSave" />
|
<div v-if="saving" class="document-saving-mask" data-test="document-saving-mask">
|
<Spin size="large" tip="文档保存中..." />
|
</div>
|
</div>
|
</BasicModal>
|
</template>
|
|
<style lang="scss" scoped>
|
.global-office-document-modal__header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
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 {
|
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>
|