刘光辉
9 小时以前 0dfe84494048ce27ba8449831782128412d3eb13
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
<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>