import type { FileAccess } from './fileAccess';
|
import type { fileItem } from './props';
|
|
import type { UploadEditContext, UploadEditDraft } from '#/api/onlineDev/uploadEdit';
|
|
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
|
|
import { message, Modal } from 'ant-design-vue';
|
|
import {
|
beginTemporaryUploadEdit,
|
changeUploadEdit,
|
cleanupTemporaryUploadEdit,
|
getUploadOfficeOptions,
|
loadSharedUploadEdit,
|
loadRevisionTemporaryResult,
|
loadUploadEdit,
|
updateUploadEditLease,
|
} from '#/api/onlineDev/uploadEdit';
|
import { getDocumentServerUrl } from '#/components/OnlyOfficeEditor/src/loadDocsApi';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import { allowsFileAction } from './fileAccess';
|
import { workflowFileSubmitPendingKey } from './filePermissionContext';
|
import { uploadEditFormKey } from './uploadEditContext';
|
import { uploadEditLoadingKey } from './uploadEditLoading';
|
|
let diagnosticInstanceSequence = 0;
|
|
export function useUploadOnlineEdit(
|
props: any,
|
readFiles: () => fileItem[],
|
applyFiles: (files: fileItem[]) => void,
|
access: () => FileAccess | undefined = () => props.fileAccess,
|
) {
|
const form = inject(uploadEditFormKey, undefined);
|
const beginWindowLoading = inject(uploadEditLoadingKey, undefined);
|
const submitting = inject(workflowFileSubmitPendingKey, () => false);
|
const options = ref({ previewEnabled: true, editEnabled: false });
|
const optionsReady = ref(false);
|
const draft = ref<UploadEditDraft>();
|
const busy = ref(false);
|
const editorOpen = ref(false);
|
const editPhase = ref<'' | 'opening' | 'syncing'>('');
|
const failure = ref('');
|
const syncWarning = ref('');
|
const contentChangedInBackground = ref(false);
|
const pendingUploads = ref(0);
|
// 未进入工作稿的原生上传不能被后续权限刷新重新加载的旧工作稿覆盖。
|
const nativeChanges = ref(false);
|
let uploadQueue = Promise.resolve();
|
let initialLoad = Promise.resolve();
|
const pendingRevisionResult = ref<{
|
leaseId: string;
|
before: string;
|
request: number;
|
epoch: number;
|
fileId: string;
|
}>();
|
const revisionLeases = new Set<string>();
|
let generation = 0;
|
let disposed = false;
|
let editorGeneration = 0;
|
const context = computed(() => ({
|
...form?.context(),
|
modelId: form?.context().modelId || '',
|
field: props.editField,
|
parentField: props.editParentField || undefined,
|
rowId: props.editRowId || undefined,
|
}));
|
const dmsAudit = computed(
|
() =>
|
!!(context.value.dmsAudit || draft.value?.dmsAudit) &&
|
((!context.value.parentField && context.value.field === 'wenjian') ||
|
(context.value.parentField === 'jilu_mingxi' && context.value.field === 'jilu_wenjian')),
|
);
|
const controlled = computed(() => access() !== undefined);
|
const incompatibility = computed(() =>
|
dmsAudit.value && !!context.value.recordId && !controlled.value ? '该审核单不符合当前共享附件协议,无法继续编辑或提交,请重新新建审核单' : '',
|
);
|
const backgroundPaused = computed(() => controlled.value && submitting());
|
let backgroundEpoch = 0;
|
watch(backgroundPaused, () => backgroundEpoch++, { flush: 'sync' });
|
const sharedReadable = computed(
|
() =>
|
controlled.value &&
|
!!form &&
|
!!context.value.modelId &&
|
!!context.value.field &&
|
props.type === 'annex' &&
|
!!props.onlineEdit &&
|
tracked.value &&
|
allowsFileAction(access(), 'VIEW') &&
|
!nativeChanges.value,
|
);
|
let leaseTimer: ReturnType<typeof setInterval> | undefined;
|
let activeLease: undefined | UploadEditDraft;
|
let prepareEditor: (() => Promise<void>) | undefined;
|
let closingEditor: Promise<unknown> = Promise.resolve();
|
const loadShared = (value: UploadEditContext, background = false) =>
|
dmsAudit.value && form?.loadShared ? form.loadShared(value) : loadSharedUploadEdit(value, background);
|
let heartbeating = false;
|
const stopHeartbeat = () => {
|
if (leaseTimer) clearInterval(leaseTimer);
|
leaseTimer = undefined;
|
};
|
async function releaseLease(lease: UploadEditDraft) {
|
if (!lease.shared || !lease.leaseId) return;
|
if (activeLease?.leaseId === lease.leaseId) {
|
stopHeartbeat();
|
activeLease = undefined;
|
}
|
await updateUploadEditLease('close', lease.draftId, lease.leaseId);
|
}
|
function startHeartbeat(lease: UploadEditDraft) {
|
if (!lease.shared || !lease.leaseId) return;
|
stopHeartbeat();
|
activeLease = lease;
|
leaseTimer = setInterval(async () => {
|
if (heartbeating) return;
|
heartbeating = true;
|
try {
|
await updateUploadEditLease('heartbeat', lease.draftId, lease.leaseId!);
|
} catch {
|
if (activeLease?.leaseId !== lease.leaseId) return;
|
stopHeartbeat();
|
if (!disposed) {
|
failure.value = '编辑会话续期失败,请关闭文档并重新打开表单核对保存结果';
|
message.error(failure.value);
|
}
|
} finally {
|
heartbeating = false;
|
}
|
}, 30_000);
|
}
|
const tracked = computed(() => !!context.value.recordId && (!context.value.parentField || !!context.value.rowId));
|
const eligible = computed(
|
() =>
|
!!props.onlineEdit &&
|
!!getDocumentServerUrl() &&
|
!!form &&
|
!!context.value.modelId &&
|
!!context.value.field &&
|
(controlled.value || (!props.disabled && !props.detailed)) &&
|
props.type === 'annex',
|
);
|
const editAllowed = computed(() => (allowsFileAction(access(), 'EDIT') || allowsFileAction(access(), 'REVIEW')) && allowsFileAction(access(), 'VIEW'));
|
const reviewOnly = computed(() => controlled.value && !allowsFileAction(access(), 'EDIT') && allowsFileAction(access(), 'REVIEW'));
|
const editingEligible = computed(() => eligible.value && editAllowed.value && !nativeChanges.value && !incompatibility.value);
|
const enabled = computed(() => editingEligible.value && optionsReady.value && options.value.editEnabled);
|
// 开关表示字段能力;普通附件仅后台恢复工作稿,不因初始化进入编辑提交屏障。
|
const needsEditPreparation = computed(() => !!draft.value || readFiles().some((file) => /\.docx?$/i.test(file.name)));
|
const blocked = computed(
|
() =>
|
!!incompatibility.value ||
|
!!pendingRevisionResult.value ||
|
editorOpen.value ||
|
pendingUploads.value > 0 ||
|
(dmsAudit.value
|
? !!failure.value || !!editPhase.value
|
: editingEligible.value && needsEditPreparation.value && (!optionsReady.value || busy.value || !!failure.value)),
|
);
|
// 只比较恢复相关动作,等价权限刷新、下载开关变化不会重载工作稿。
|
const discardPermissions = computed(() =>
|
JSON.stringify([
|
access() === undefined,
|
...(['VIEW', 'EDIT', 'REVIEW', 'ADD', 'REPLACE', 'REMOVE'] as const).map((action) => allowsFileAction(access(), action)),
|
]),
|
);
|
const permissionEpoch = ref(0);
|
const discardDecision = ref({ epoch: -1, allowed: false });
|
watch(discardPermissions, () => permissionEpoch.value++, { flush: 'sync' });
|
const canDiscard = computed(() => enabled.value && tracked.value && discardDecision.value.epoch === permissionEpoch.value && discardDecision.value.allowed);
|
const canEdit = (file: fileItem) =>
|
editAllowed.value &&
|
(enabled.value || (dmsAudit.value && editingEligible.value)) &&
|
(reviewOnly.value ? /\.docx$/i.test(file.name) : /\.docx?$/i.test(file.name)) &&
|
(tracked.value ? !!draft.value : !!file.uploadReceipt || !!file.revisionEditReceipt);
|
// 只在提交受阻时输出最近40条状态历史;不记录文件、URL、租约、凭据或接口响应。
|
const diagnosticInstance = ++diagnosticInstanceSequence;
|
const diagnosticStartedAt = Date.now();
|
const diagnosticHistory: Record<string, unknown>[] = [];
|
let prepareAttempt = 0;
|
function recordDiagnostic(event: string, details: Record<string, unknown> = {}) {
|
try {
|
const current = context.value;
|
diagnosticHistory.push({
|
time: new Date().toISOString(),
|
elapsedMs: Date.now() - diagnosticStartedAt,
|
event,
|
...details,
|
context: {
|
modelId: current.modelId,
|
recordId: current.recordId,
|
taskId: current.taskId,
|
field: current.field,
|
parentField: current.parentField,
|
rowId: current.rowId,
|
},
|
state: {
|
generation,
|
editorGeneration,
|
permissionEpoch: permissionEpoch.value,
|
disposed,
|
editorOpen: editorOpen.value,
|
busy: busy.value,
|
pendingUploads: pendingUploads.value,
|
editPhase: editPhase.value,
|
pendingRevisionResult: !!pendingRevisionResult.value,
|
optionsReady: optionsReady.value,
|
serverEditEnabled: options.value.editEnabled,
|
onlineEdit: !!props.onlineEdit,
|
disabled: !!props.disabled,
|
detailed: !!props.detailed,
|
controlled: controlled.value,
|
eligible: eligible.value,
|
editingEligible: editingEligible.value,
|
needsEditPreparation: needsEditPreparation.value,
|
enabled: enabled.value,
|
sharedReadable: sharedReadable.value,
|
tracked: tracked.value,
|
nativeChanges: nativeChanges.value,
|
backgroundPaused: backgroundPaused.value,
|
permissionStatus: access()?.status,
|
editAllowed: editAllowed.value,
|
hasFailure: !!failure.value,
|
hasDraft: !!draft.value,
|
draftRevision: draft.value?.revision,
|
},
|
});
|
if (diagnosticHistory.length > 40) diagnosticHistory.shift();
|
} catch {
|
// 诊断失败不能影响审批或附件状态。
|
}
|
}
|
function rejectPrepare(reason: string, message: string): never {
|
recordDiagnostic('prepare:blocked', { reason, prepareAttempt });
|
try {
|
// 序列化保存触发瞬间的快照,避免控制台展开对象时看到后来的值。
|
console.warn('[UploadEditSubmitGuard]', JSON.stringify({ version: 1, instance: diagnosticInstance, reason, prepareAttempt, history: diagnosticHistory }));
|
} catch {
|
// 控制台不可用也必须保留原拦截结果。
|
}
|
throw new Error(message);
|
}
|
watch(
|
[busy, editorOpen, pendingUploads, editPhase, optionsReady, failure, nativeChanges, backgroundPaused, pendingRevisionResult],
|
() => recordDiagnostic('state:changed'),
|
{ flush: 'sync' },
|
);
|
function accept(value: UploadEditDraft, epoch: number, confirmContent = true) {
|
if (confirmContent) contentChangedInBackground.value = false;
|
if (value.canDiscard !== undefined) discardDecision.value = { epoch, allowed: value.canDiscard === true };
|
draft.value = value;
|
// Equal polling results must not invalidate preview requests or fire native field-change events.
|
if (JSON.stringify(readFiles()) !== JSON.stringify(value.files)) applyFiles(value.files);
|
}
|
watch(
|
() => sharedReadable.value && editPhase.value === 'syncing',
|
(syncing, _previous, onCleanup) => {
|
if (syncing && beginWindowLoading) onCleanup(beginWindowLoading());
|
},
|
{ flush: 'sync' },
|
);
|
function formalFile(file: fileItem) {
|
const baseline = draft.value?.baselineFiles;
|
if (!baseline) return file;
|
const formalId = draft.value?.editOrigins?.[file.fileId] || file.fileId;
|
return baseline.find((item) => item.fileId === formalId);
|
}
|
watch(
|
() => [props.onlineEdit, props.disabled, props.detailed, JSON.stringify(context.value)],
|
() => {
|
const pending = pendingRevisionResult.value;
|
pendingRevisionResult.value = undefined;
|
if (pending) void cleanupTemporaryUploadEdit(pending.leaseId).catch(() => undefined);
|
nativeChanges.value = false;
|
contentChangedInBackground.value = false;
|
stopHeartbeat();
|
activeLease = undefined;
|
editorGeneration++;
|
editorOpen.value = false;
|
},
|
{ flush: 'sync' },
|
);
|
watch(
|
() => [props.onlineEdit, props.disabled, props.detailed, JSON.stringify(context.value), editAllowed.value, sharedReadable.value],
|
async (values, previous, onCleanup) => {
|
const request = ++generation;
|
const keys = ['onlineEdit', 'disabled', 'detailed', 'context', 'editAllowed', 'sharedReadable'];
|
recordDiagnostic('load:start', { request, changed: keys.filter((_key, index) => !previous || values[index] !== previous[index]) });
|
editPhase.value = '';
|
const epoch = permissionEpoch.value;
|
draft.value = undefined;
|
failure.value = '';
|
optionsReady.value = false;
|
busy.value = false;
|
if (dmsAudit.value) {
|
// Editor configuration is independent of shared-content reads and form submission.
|
void getUploadOfficeOptions()
|
.then((response) => {
|
if (!disposed && request === generation) {
|
options.value = response.data;
|
optionsReady.value = true;
|
}
|
})
|
.catch(() => undefined);
|
let finishInitialLoad!: () => void;
|
initialLoad = new Promise<void>((resolve) => (finishInitialLoad = resolve));
|
onCleanup(finishInitialLoad);
|
try {
|
if (sharedReadable.value) {
|
const response = await loadShared(context.value, true);
|
if (!disposed && request === generation) {
|
accept(response.data, epoch, false);
|
syncWarning.value = '';
|
}
|
}
|
} catch {
|
if (!disposed && request === generation) syncWarning.value = '共享附件暂未同步,提交时将重新核验';
|
} finally {
|
finishInitialLoad();
|
}
|
return;
|
}
|
if (!getDocumentServerUrl()) {
|
recordDiagnostic('load:skipped-no-server', { request });
|
return;
|
}
|
busy.value = needsEditPreparation.value && (eligible.value || sharedReadable.value);
|
const finishWindowLoading = sharedReadable.value && busy.value ? beginWindowLoading?.() : undefined;
|
if (finishWindowLoading) onCleanup(finishWindowLoading);
|
let finishInitialLoad!: () => void;
|
initialLoad = new Promise<void>((resolve) => (finishInitialLoad = resolve));
|
onCleanup(finishInitialLoad);
|
try {
|
recordDiagnostic('load:options-request', { request });
|
const response = await getUploadOfficeOptions();
|
recordDiagnostic('load:options-response', { request, stale: disposed || request !== generation });
|
if (disposed || request !== generation) return;
|
options.value = response.data;
|
optionsReady.value = true;
|
if (sharedReadable.value || (enabled.value && tracked.value)) {
|
recordDiagnostic('load:draft-request', { request, shared: sharedReadable.value });
|
const response = await (sharedReadable.value ? loadShared(context.value) : loadUploadEdit(context.value));
|
recordDiagnostic('load:draft-response', { request, stale: disposed || request !== generation });
|
if (!disposed && request === generation && (sharedReadable.value || editAllowed.value)) accept(response.data, epoch);
|
}
|
} catch {
|
recordDiagnostic('load:failed', { request, stale: disposed || request !== generation });
|
if (!disposed && request === generation && needsEditPreparation.value && (eligible.value || sharedReadable.value))
|
failure.value = '工作附件加载失败,请重新打开表单';
|
} finally {
|
if (request === generation) busy.value = false;
|
finishWindowLoading?.();
|
finishInitialLoad();
|
recordDiagnostic('load:finished', { request, stale: disposed || request !== generation });
|
}
|
},
|
{ immediate: true, flush: 'sync' },
|
);
|
|
// 仅刷新恢复决定,不采用返回附件,不改变编辑器会话/原生表单暂存值。
|
watch(
|
() => [permissionEpoch.value, draft.value, busy.value, editorOpen.value, backgroundPaused.value],
|
async (_value, _old, onCleanup) => {
|
if (
|
backgroundPaused.value ||
|
!enabled.value ||
|
!tracked.value ||
|
!draft.value ||
|
busy.value ||
|
editorOpen.value ||
|
discardDecision.value.epoch === permissionEpoch.value
|
)
|
return;
|
const current = draft.value;
|
const epoch = permissionEpoch.value;
|
const request = generation;
|
let cancelled = false;
|
onCleanup(() => {
|
cancelled = true;
|
});
|
let allowed = false;
|
try {
|
const response = await (sharedReadable.value ? loadShared(context.value, true) : loadUploadEdit(context.value));
|
allowed = response.data.draftId === current.draftId && response.data.revision === current.revision && response.data.canDiscard === true;
|
} catch {
|
// 核验失败只关闭恢复入口,实际动作仍由服务端重新授权。
|
}
|
if (!disposed && !cancelled && request === generation && epoch === permissionEpoch.value && current === draft.value)
|
discardDecision.value = { epoch, allowed };
|
},
|
);
|
|
async function replace(files: fileItem[]) {
|
if (incompatibility.value) throw new Error(incompatibility.value);
|
// 普通附件未进入编辑屏障;发生真实修改时仍先取得工作稿版本,避免覆盖历史结果。
|
if ((dmsAudit.value && sharedReadable.value) || (!needsEditPreparation.value && tracked.value && editingEligible.value)) {
|
const request = generation;
|
const before = JSON.stringify(readFiles());
|
pendingUploads.value++;
|
try {
|
await initialLoad;
|
if (disposed || request !== generation || before !== JSON.stringify(readFiles())) throw new Error('表单或附件已变化,请重试');
|
} finally {
|
pendingUploads.value--;
|
}
|
}
|
if (access() !== undefined) {
|
const before = new Set(readFiles().map((file) => file.fileId));
|
const after = new Set(files.map((file) => file.fileId));
|
const added = [...after].filter((id) => !before.has(id)).length;
|
const removed = [...before].filter((id) => !after.has(id)).length;
|
if (
|
(added && removed && !allowsFileAction(access(), 'REPLACE')) ||
|
(added > removed && !allowsFileAction(access(), 'ADD')) ||
|
(removed > added && !allowsFileAction(access(), 'REMOVE'))
|
)
|
throw new Error('没有附件操作权限');
|
}
|
if (
|
disposed ||
|
props.disabled ||
|
props.detailed ||
|
editorOpen.value ||
|
(dmsAudit.value ? !!editPhase.value || !!failure.value : editingEligible.value && (!optionsReady.value || busy.value || failure.value))
|
)
|
throw new Error('附件当前不可修改');
|
if (!enabled.value || !tracked.value) {
|
if (tracked.value && (controlled.value || eligible.value) && !enabled.value) nativeChanges.value = true;
|
applyFiles(files);
|
return;
|
}
|
if (!draft.value) throw new Error('工作附件尚未加载');
|
const request = generation;
|
const epoch = permissionEpoch.value;
|
recordDiagnostic('files:update-start', { request });
|
busy.value = true;
|
try {
|
const result = await changeUploadEdit('files', { ...commandVersion(), files });
|
if (!disposed && request === generation) accept(result.data, epoch);
|
} catch (error) {
|
if (!disposed && request === generation) failure.value = '附件更新失败,请重新打开表单核对';
|
throw error;
|
} finally {
|
if (!disposed && request === generation) busy.value = false;
|
}
|
}
|
function add(file: fileItem) {
|
const request = generation;
|
recordDiagnostic('upload:queued', { request });
|
pendingUploads.value++;
|
const task = uploadQueue.then(async () => {
|
if (disposed || request !== generation) return;
|
if (props.limit && readFiles().length >= props.limit) throw new Error('附件数量已达到上限');
|
await replace([...readFiles(), file]);
|
});
|
uploadQueue = task
|
.catch((error) => {
|
message.error(error?.message || '附件更新失败');
|
})
|
.finally(() => {
|
pendingUploads.value--;
|
});
|
return uploadQueue;
|
}
|
// 模板授权请求也参与提交屏障;请求期间上下文、附件或权限改变时不采用旧结果。
|
async function replaceFrom(load: () => Promise<fileItem[]>) {
|
if (!enabled.value || blocked.value) throw new Error('附件当前不可修改');
|
const request = generation;
|
const epoch = permissionEpoch.value;
|
const before = JSON.stringify(readFiles());
|
recordDiagnostic('template:start', { request });
|
pendingUploads.value++;
|
try {
|
const files = await load();
|
if (disposed || request !== generation || epoch !== permissionEpoch.value || before !== JSON.stringify(readFiles()))
|
throw new Error('表单或附件已变化,请重新选择模板');
|
await replace(files);
|
} finally {
|
pendingUploads.value--;
|
}
|
}
|
async function retryRevisionResult() {
|
const pending = pendingRevisionResult.value;
|
if (!pending) return;
|
recordDiagnostic('revision:sync-start', { request: pending.request });
|
editPhase.value = 'syncing';
|
busy.value = true;
|
try {
|
const response = await loadRevisionTemporaryResult(pending.leaseId);
|
if (
|
!disposed &&
|
pending === pendingRevisionResult.value &&
|
pending.request === generation &&
|
pending.epoch === permissionEpoch.value &&
|
editAllowed.value &&
|
pending.before === JSON.stringify(readFiles())
|
) {
|
const saved = response.data.files?.[0];
|
if (!response.data.revisionTemporary || response.data.leaseId !== pending.leaseId || response.data.files?.length !== 1 || !saved?.revisionEditReceipt)
|
throw new Error('附件编辑结果不完整,请重试同步');
|
// 只替换打开时的目标附件;服务端另存后fileId会变化。
|
const before = JSON.parse(pending.before) as fileItem[];
|
const target = pending.fileId;
|
applyFiles(before.map((item) => (item.fileId === target ? saved : item)));
|
}
|
if (pending === pendingRevisionResult.value) {
|
pendingRevisionResult.value = undefined;
|
failure.value = '';
|
}
|
await cleanupTemporaryUploadEdit(pending.leaseId).catch(() => undefined);
|
revisionLeases.delete(pending.leaseId);
|
} catch {
|
if (!disposed && pending === pendingRevisionResult.value && pending.request === generation)
|
failure.value = '附件编辑结果尚未同步,请重试同步后再保存表单';
|
} finally {
|
if (!disposed && pending.request === generation) {
|
busy.value = false;
|
editPhase.value = '';
|
}
|
}
|
}
|
function discardRevisionResult() {
|
const pending = pendingRevisionResult.value;
|
if (!pending || busy.value) return;
|
Modal.confirm({
|
title: '放弃本次附件编辑',
|
content: '保留表单中的原附件,放弃尚未同步的编辑结果?',
|
onOk: async () => {
|
if (pending !== pendingRevisionResult.value || busy.value) return;
|
await cleanupTemporaryUploadEdit(pending.leaseId);
|
revisionLeases.delete(pending.leaseId);
|
pendingRevisionResult.value = undefined;
|
failure.value = '';
|
},
|
});
|
}
|
function commandVersion() {
|
return {
|
draftId: draft.value!.draftId,
|
revision: draft.value!.revision,
|
...(dmsAudit.value ? { context: { ...context.value }, contentToken: draft.value!.contentToken, metadataToken: draft.value!.metadataToken } : {}),
|
};
|
}
|
async function open(file: fileItem) {
|
if (!canEdit(file) || blocked.value) return;
|
const request = generation;
|
const epoch = permissionEpoch.value;
|
const openingEpoch = epoch;
|
const editContext = { ...context.value };
|
const isTracked = tracked.value;
|
const before = JSON.stringify(readFiles());
|
recordDiagnostic('editor:open-start', { request });
|
editPhase.value = 'opening';
|
busy.value = true;
|
try {
|
if (dmsAudit.value) {
|
const response = await getUploadOfficeOptions();
|
options.value = response.data;
|
optionsReady.value = true;
|
if (!options.value.editEnabled) throw new Error('服务端未启用附件在线编辑');
|
}
|
let lease: UploadEditDraft;
|
if (tracked.value) {
|
if (!draft.value) throw new Error('工作附件尚未加载');
|
const response = await changeUploadEdit('begin', {
|
...commandVersion(),
|
fileId: file.fileId,
|
mode: reviewOnly.value ? 'review' : 'edit',
|
});
|
lease = response.data;
|
if (!disposed && request === generation && editAllowed.value) accept(lease, epoch);
|
} else {
|
const response = await beginTemporaryUploadEdit({ context: context.value, fileId: file.fileId, files: [file] });
|
lease = response.data;
|
if (lease.revisionTemporary && lease.leaseId) revisionLeases.add(lease.leaseId);
|
}
|
if (disposed || request !== generation || !editAllowed.value) {
|
if (!isTracked && lease.leaseId) await cleanupTemporaryUploadEdit(lease.leaseId);
|
await releaseLease(lease);
|
return;
|
}
|
if (!lease.leaseId || !lease.source) throw new Error('编辑会话创建失败');
|
editorOpen.value = true;
|
const editorRequest = ++editorGeneration;
|
startHeartbeat(lease);
|
onlineUtils.openOfficeDocument({
|
file: { ...lease.source, fileId: lease.leaseId },
|
fileType: 'annex',
|
mode: lease.mode === 'review' ? 'review' : 'edit',
|
...(dmsAudit.value
|
? {
|
registerSubmitPreparation: (prepare: (() => Promise<void>) | undefined) => {
|
prepareEditor = prepare;
|
},
|
onManualSave: async () => {
|
if (!isTracked || disposed || request !== generation) return;
|
const response = await loadShared(editContext, true);
|
if (!disposed && request === generation && allowsFileAction(access(), 'VIEW')) accept(response.data, permissionEpoch.value);
|
form?.sharedChanged?.();
|
},
|
}
|
: {}),
|
bizScene: 'visualdev.uploadFile',
|
bizModule: 'visualdev',
|
bizDataId: lease.draftId,
|
title: tracked.value || lease.revisionTemporary ? file.name : `${file.name}(未关联表单,请先保存表单后再编辑)`,
|
onOpenError: () => {
|
recordDiagnostic('editor:open-error', { request, editorRequest });
|
if (!disposed && editorRequest === editorGeneration) editorOpen.value = false;
|
if (!isTracked && lease.leaseId) void cleanupTemporaryUploadEdit(lease.leaseId).catch(() => undefined);
|
void releaseLease(lease).catch(() => {
|
failure.value = '编辑会话释放失败,请稍后重新打开表单';
|
});
|
},
|
onClose: () => {
|
recordDiagnostic('editor:close-callback', { request, editorRequest });
|
const epoch = permissionEpoch.value;
|
if (!disposed && request === generation) {
|
busy.value = true;
|
editPhase.value = 'syncing';
|
}
|
const finish = isTracked
|
? releaseLease(lease).then(async () => {
|
if (disposed || request !== generation) return;
|
recordDiagnostic('editor:close-draft-request', { request, editorRequest });
|
const response = await (lease.shared ? loadShared(editContext) : loadUploadEdit(editContext));
|
recordDiagnostic('editor:close-draft-response', { request, editorRequest });
|
if (!disposed && request === generation && (lease.shared ? allowsFileAction(access(), 'VIEW') : editAllowed.value))
|
accept(response.data, epoch);
|
})
|
: lease.revisionTemporary
|
? (() => {
|
if (disposed || request !== generation || before !== JSON.stringify(readFiles())) {
|
revisionLeases.delete(lease.leaseId!);
|
return cleanupTemporaryUploadEdit(lease.leaseId!);
|
}
|
pendingRevisionResult.value = { leaseId: lease.leaseId!, before, request, epoch: openingEpoch, fileId: file.fileId };
|
return retryRevisionResult();
|
})()
|
: cleanupTemporaryUploadEdit(lease.leaseId!);
|
closingEditor = finish
|
.catch(() => {
|
if (!disposed && request === generation) failure.value = '附件状态同步失败,请重新打开表单核对';
|
})
|
.finally(() => {
|
if (!disposed && request === generation) {
|
busy.value = false;
|
editPhase.value = '';
|
}
|
if (!disposed && editorRequest === editorGeneration) editorOpen.value = false;
|
recordDiagnostic('editor:close-finished', { request, editorRequest });
|
if (dmsAudit.value) form?.sharedChanged?.();
|
});
|
},
|
});
|
} catch (error: any) {
|
if (!disposed && request === generation) editorOpen.value = false;
|
if (activeLease) await releaseLease(activeLease).catch(() => undefined);
|
message.error(error?.message || '无法打开在线编辑');
|
} finally {
|
if (!disposed && request === generation && editPhase.value === 'opening') {
|
busy.value = false;
|
editPhase.value = '';
|
}
|
}
|
}
|
function discard() {
|
if (!canDiscard.value || blocked.value || !draft.value) return;
|
const request = generation;
|
const epoch = permissionEpoch.value;
|
const version = commandVersion();
|
Modal.confirm({
|
title: '放弃附件修改',
|
content: '恢复上次成功保存表单时的附件及内容?',
|
onOk: async () => {
|
if (
|
disposed ||
|
request !== generation ||
|
epoch !== permissionEpoch.value ||
|
!draft.value ||
|
blocked.value ||
|
!canDiscard.value ||
|
draft.value.draftId !== version.draftId ||
|
draft.value.revision !== version.revision
|
)
|
return;
|
recordDiagnostic('discard:start', { request });
|
busy.value = true;
|
try {
|
const response = await changeUploadEdit('discard', version);
|
if (!disposed && request === generation && editAllowed.value) accept(response.data, epoch);
|
} finally {
|
if (!disposed && request === generation) busy.value = false;
|
}
|
},
|
});
|
}
|
async function refreshShared() {
|
if (backgroundPaused.value || !sharedReadable.value || disposed || busy.value || editorOpen.value || pendingUploads.value || nativeChanges.value) return;
|
const request = generation;
|
const refreshEpoch = backgroundEpoch;
|
const epoch = permissionEpoch.value;
|
const revision = draft.value?.revision;
|
try {
|
const response = await loadShared(context.value, true);
|
if (
|
!disposed &&
|
request === generation &&
|
refreshEpoch === backgroundEpoch &&
|
!backgroundPaused.value &&
|
!busy.value &&
|
!editorOpen.value &&
|
!nativeChanges.value &&
|
draft.value?.revision === revision
|
) {
|
if (dmsAudit.value) {
|
const changed = draft.value
|
? draft.value.contentToken !== response.data.contentToken
|
: JSON.stringify(readFiles().map((file) => file.fileId)) !== JSON.stringify(response.data.files.map((file) => file.fileId));
|
if (changed) contentChangedInBackground.value = true;
|
}
|
accept(response.data, epoch, false);
|
if (dmsAudit.value) syncWarning.value = '';
|
else failure.value = '';
|
}
|
} catch {
|
if (!disposed && request === generation && refreshEpoch === backgroundEpoch && !backgroundPaused.value)
|
if (dmsAudit.value) syncWarning.value = '共享附件暂未同步,提交时将重新核验';
|
else failure.value = '共享附件同步失败,请重新打开表单';
|
}
|
}
|
const refreshTimer = setInterval(() => {
|
if (!dmsAudit.value || !form?.loadShared) void refreshShared();
|
}, 15_000);
|
const unregister = form?.register({
|
refresh: async () => {
|
if (dmsAudit.value && form?.loadShared) await refreshShared();
|
},
|
settle: async () => {
|
if (!dmsAudit.value) return;
|
await uploadQueue;
|
if (editorOpen.value) {
|
if (!prepareEditor) rejectPrepare('editor-not-ready', '附件编辑器尚未就绪,请稍候再提交');
|
await prepareEditor!();
|
}
|
await closingEditor;
|
if (pendingRevisionResult.value) await retryRevisionResult();
|
},
|
prepare: async () => {
|
prepareAttempt++;
|
recordDiagnostic('prepare:start', { prepareAttempt });
|
if (dmsAudit.value) {
|
if (incompatibility.value) rejectPrepare('dms-incompatible', incompatibility.value);
|
if (pendingRevisionResult.value || editorOpen.value || pendingUploads.value || editPhase.value || failure.value)
|
rejectPrepare('dms-edit-pending', failure.value || '请关闭附件编辑器并等待保存完成后再提交表单');
|
if (sharedReadable.value) {
|
let latest: UploadEditDraft;
|
try {
|
const response = await loadShared(context.value, true);
|
latest = response.data;
|
} catch {
|
rejectPrepare('dms-content-unavailable', '暂时无法核验共享附件,请稍后重试提交');
|
}
|
const changed =
|
contentChangedInBackground.value ||
|
(draft.value
|
? draft.value.contentToken !== latest!.contentToken
|
: JSON.stringify(readFiles().map((f) => f.fileId)) !== JSON.stringify(latest!.files.map((f) => f.fileId)));
|
accept(latest!, permissionEpoch.value);
|
syncWarning.value = '';
|
if (latest!.editing)
|
rejectPrepare(
|
'dms-active-editor',
|
latest!.ownEditing ? '此附件在另一个编辑窗口尚未结束,请完成保存并关闭该窗口' : '此附件仍由其他人员编辑,请等待对方保存并结束编辑后提交',
|
);
|
if (changed) rejectPrepare('dms-content-changed', '共享附件已更新,请重新预览确认后提交');
|
}
|
return draft.value
|
? { draftId: draft.value.draftId, revision: draft.value.revision, contentToken: draft.value.contentToken, metadataToken: draft.value.metadataToken }
|
: undefined;
|
}
|
if (!needsEditPreparation.value && !editorOpen.value && !pendingRevisionResult.value) {
|
if (pendingUploads.value > 0) rejectPrepare('pending-uploads', '附件正在上传或更新,请稍候再提交表单');
|
recordDiagnostic('prepare:skipped', { prepareAttempt });
|
return;
|
}
|
if (pendingRevisionResult.value) rejectPrepare('revision-result-pending', '请先同步附件的编辑结果');
|
if (editingEligible.value && (!optionsReady.value || failure.value))
|
rejectPrepare(optionsReady.value ? 'editing-failure' : 'configuration-not-ready', failure.value || '附件编辑配置尚未加载');
|
if (editorOpen.value) rejectPrepare('editor-open', '请关闭附件编辑器并等待保存完成后再提交表单');
|
if (!enabled.value && !sharedReadable.value) {
|
recordDiagnostic('prepare:skipped', { prepareAttempt });
|
return;
|
}
|
if (busy.value || pendingUploads.value > 0) rejectPrepare(busy.value ? 'busy' : 'pending-uploads', '请关闭附件编辑器并等待保存完成后再提交表单');
|
if (failure.value) rejectPrepare('attachment-failure', failure.value);
|
if (tracked.value && !draft.value) rejectPrepare('draft-not-loaded', '工作附件尚未加载');
|
recordDiagnostic('prepare:passed', { prepareAttempt });
|
return draft.value ? { draftId: draft.value.draftId, revision: draft.value.revision } : undefined;
|
},
|
});
|
onBeforeUnmount(() => {
|
disposed = true;
|
editPhase.value = '';
|
for (const id of revisionLeases) void cleanupTemporaryUploadEdit(id).catch(() => undefined);
|
revisionLeases.clear();
|
stopHeartbeat();
|
clearInterval(refreshTimer);
|
// 页面异常离开时不提前解锁尚未完成保存的编辑器,由租约超时阻止迟到回调。
|
generation++;
|
unregister?.();
|
});
|
return {
|
editStatus: computed(() => (editPhase.value === 'opening' ? '正在打开文档…' : editPhase.value === 'syncing' ? '正在同步编辑结果…' : '')),
|
pendingRevisionResult,
|
retryRevisionResult,
|
discardRevisionResult,
|
enabled,
|
templateFeatureEnabled: computed(() => !!props.onlineEdit && optionsReady.value && options.value.editEnabled),
|
templateUnavailableReason: computed(() => {
|
if (incompatibility.value) return incompatibility.value;
|
if (!props.onlineEdit || props.type !== 'annex') return '当前上传组件未开启在线编辑';
|
if (!form || !context.value.modelId || !context.value.field) return '当前页面缺少表单或附件字段上下文';
|
if (!getDocumentServerUrl()) return '当前环境未配置在线编辑服务地址';
|
if (!editAllowed.value) return '没有附件查看或在线编辑权限';
|
if (!optionsReady.value) return failure.value || '正在加载在线编辑配置';
|
if (!options.value.editEnabled) return '服务端未启用附件在线编辑';
|
if (nativeChanges.value) return '请先保存当前附件修改,再选择模板';
|
return '';
|
}),
|
reviewOnly,
|
previewEnabled: computed(() => options.value.previewEnabled),
|
busy,
|
blocked,
|
failure: computed(() => incompatibility.value || failure.value),
|
canDiscard,
|
canEdit,
|
open,
|
replace,
|
replaceFrom,
|
add,
|
discard,
|
formalFile,
|
previousFile: (file: fileItem) => {
|
const previous =
|
formalFile(file) || (draft.value?.files.length === 1 && draft.value.baselineFiles?.length === 1 ? draft.value.baselineFiles[0] : undefined);
|
return dmsAudit.value && previous && previous.fileId !== file.fileId ? previous : undefined;
|
},
|
sharedSource: (file: fileItem) =>
|
dmsAudit.value &&
|
draft.value?.shared &&
|
draft.value.contentToken &&
|
draft.value.files.some((current) => current.fileId === file.fileId) &&
|
!draft.value.baselineFiles?.some((baseline) => baseline.fileId === file.fileId)
|
? { fileId: file.fileId, draftId: draft.value.draftId, contentToken: draft.value.contentToken, metadataToken: draft.value.metadataToken }
|
: undefined,
|
syncWarning,
|
accessContext: () => ({ ...context.value }),
|
};
|
}
|