<script setup lang="ts">
|
import type { OnlyOfficeBridge } from './onlyoffice/bridge';
|
|
import { ExclamationCircleFilled, LeftOutlined, ReloadOutlined, RightOutlined, SearchOutlined, UnorderedListOutlined } from '@ant-design/icons-vue';
|
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
import dayjs from 'dayjs';
|
import 'dayjs/locale/zh-cn';
|
import { computed, inject, nextTick, onBeforeUnmount, ref, watch } from 'vue';
|
|
import FieldDraftList from './components/FieldDraftList.vue';
|
import RepeatRowsPanel from './components/RepeatRowsPanel.vue';
|
import StepDirectory from './components/StepDirectory.vue';
|
import { useRecordFiller } from './composables/useRecordFiller';
|
import { parseTag } from './domain/tags';
|
import { STEP_PROGRESS_LABELS } from './domain/types';
|
|
const bridge = inject<OnlyOfficeBridge | null>('onlyOfficeBridge', null);
|
dayjs.locale('zh-cn');
|
const filler = bridge ? useRecordFiller(bridge) : null;
|
const store = filler?.store;
|
const directoryOpen = ref(false);
|
const shell = ref<HTMLElement | null>(null);
|
const activeSections = ref(['fields', 'repeats']);
|
const fieldFilter = ref('');
|
let locateWithoutSidebarScroll: string | null = null;
|
const disabled = computed(() => !filler || store?.status !== 'ready' || !filler.canFill());
|
const visibleFields = computed(() => filler?.visibleFields.value ?? store?.fields ?? []);
|
const visibleGroups = computed(() => filler?.visibleRepeatGroups.value ?? store?.repeatGroups ?? []);
|
const scalarFields = computed(() => visibleFields.value.filter((field) => parseTag(field.tag)?.type !== 'repeat'));
|
const repeatFields = computed(() => visibleFields.value.filter((field) => parseTag(field.tag)?.type === 'repeat'));
|
const orderedSteps = computed(() => [...(store?.stepStructure?.configuration.steps ?? [])].sort((left, right) => left.order - right.order));
|
const currentStepIndex = computed(() => orderedSteps.value.findIndex((step) => step.id === filler?.currentStepId.value));
|
const currentStep = computed(() => orderedSteps.value[currentStepIndex.value]);
|
const currentProgress = computed(() => filler?.stepProgress.value.find((item) => item.step.id === currentStep.value?.id));
|
const statusLabel = computed(() => STEP_PROGRESS_LABELS[currentProgress.value?.status ?? 'in-progress']);
|
const statusColor = computed(() => ({ complete: 'success', 'in-progress': 'default', problem: 'error' })[currentProgress.value?.status ?? 'in-progress']);
|
const connectionState = computed(() => (bridge ? (store?.status ?? 'initializing') : 'error'));
|
const showSourceSync = computed(() => Boolean(filler?.recordHostState.value));
|
const connectionStatus = computed<'default' | 'error' | 'processing' | 'success'>(() => {
|
if (connectionState.value === 'ready') return 'success';
|
if (connectionState.value === 'working') return 'processing';
|
if (connectionState.value === 'error') return 'error';
|
return 'default';
|
});
|
const connectionText = computed(() => {
|
if (connectionState.value === 'ready') return filler?.canFill() ? '已连接' : '当前记录仅可查看';
|
if (connectionState.value === 'working') return '处理中';
|
if (connectionState.value === 'error') return '连接异常';
|
return '加载中';
|
});
|
const conflictItems = computed(() =>
|
Object.keys(store?.conflicts ?? {}).map((tag) => {
|
const field = store?.fields.find((item) => item.tag === tag);
|
const parsed = parseTag(tag);
|
const stepId = parsed?.type === 'repeat' ? store?.stepStructure?.repeatGroupSteps[parsed.groupCode] : store?.stepStructure?.controlSteps[tag];
|
const step = orderedSteps.value.find((item) => item.id === stepId);
|
return { alias: field?.alias || tag, stepName: step?.name, tag };
|
}),
|
);
|
const changedSourceDifferences = computed(() => (filler?.sourceSyncPreview.value?.differences ?? []).filter((item) => item.changed));
|
const visibleSourceDifferences = computed(() => (filler?.sourceSyncPreview.value?.differences ?? []).filter(
|
(item) => item.changed || item.sourceOutcome === 'MISSING' || item.sourceOutcome === 'NOT_APPLICABLE',
|
));
|
const sourceClearReasons = ref<Record<string, string>>({});
|
const sourceSyncConfirmDisabled = computed(() => {
|
const reasons = Object.values(sourceClearReasons.value);
|
return (!changedSourceDifferences.value.length && !reasons.length)
|
|| reasons.some((reason) => !reason.trim() || reason.trim().length > 500);
|
});
|
|
function selectSourceClear(bindingId: string, selected: boolean) {
|
if (selected) sourceClearReasons.value[bindingId] = '';
|
else delete sourceClearReasons.value[bindingId];
|
}
|
function confirmSourceSync() {
|
if (sourceSyncConfirmDisabled.value) return;
|
void filler?.synchronizeSource(Object.fromEntries(Object.entries(sourceClearReasons.value).map(([id, reason]) => [id, reason.trim()])));
|
}
|
|
if (filler) {
|
watch(filler.sourceSyncPreview, () => { sourceClearReasons.value = {}; });
|
watch(
|
filler.recordHostState,
|
(state) => {
|
// 插件与宿主通常跨域,消息不含凭证;宿主会按记录和文档标识校验后才采用。
|
if (state) window.top?.postMessage(state, '*');
|
},
|
{ deep: true, immediate: true },
|
);
|
}
|
|
function moveStep(offset: number) {
|
const target = orderedSteps.value[currentStepIndex.value + offset];
|
if (target) filler?.selectStep(target.id);
|
}
|
|
async function selectFromDirectory(stepId: string) {
|
filler?.selectStep(stepId);
|
directoryOpen.value = false;
|
await filler?.focusFirstFieldInStep(stepId);
|
}
|
async function locateField(tag: string) {
|
locateWithoutSidebarScroll = tag;
|
try {
|
await filler?.focusField(tag);
|
await nextTick();
|
} finally {
|
if (locateWithoutSidebarScroll === tag) locateWithoutSidebarScroll = null;
|
}
|
}
|
function closeSourceSync() {
|
if (filler && !filler.sourceSyncLoading.value) filler.sourceSyncPreview.value = null;
|
}
|
function sourceTypeLabel(sourceType: string): string {
|
return sourceType === 'CROSS_TEMPLATE' ? '跨模板字段值' : '任务快照';
|
}
|
function getPopupContainer(trigger?: HTMLElement): HTMLElement {
|
return (trigger?.closest('.plugin-shell') as HTMLElement) || document.getElementById('app') || document.body;
|
}
|
watch(
|
() => store?.focusedTag,
|
async (tag) => {
|
if (!tag) return;
|
directoryOpen.value = false;
|
const section = parseTag(tag)?.type === 'repeat' ? 'repeats' : 'fields';
|
if (!activeSections.value.includes(section)) activeSections.value = [...activeSections.value, section];
|
await nextTick();
|
if (locateWithoutSidebarScroll === tag) {
|
locateWithoutSidebarScroll = null;
|
return;
|
}
|
const elements = shell.value?.querySelectorAll<HTMLElement>('[data-field-tag]') ?? [];
|
const target = Array.from(elements).find((element) => element.dataset.fieldTag === tag);
|
target?.scrollIntoView?.({ block: 'center' });
|
},
|
);
|
onBeforeUnmount(() => filler?.dispose());
|
</script>
|
<template>
|
<a-config-provider :get-popup-container="getPopupContainer" :locale="zhCN">
|
<main ref="shell" class="plugin-shell">
|
<header class="plugin-header">
|
<div class="status-line">
|
<a-badge class="connection-status" :status="connectionStatus" :text="connectionText" data-test="connection-status" />
|
<div class="status-actions">
|
<a-tooltip v-if="showSourceSync && !scalarFields.length" title="重新同步来源快照" placement="topRight">
|
<a-button
|
type="text"
|
aria-label="重新同步来源快照"
|
data-test="source-sync-button"
|
:disabled="store?.status !== 'ready' || !filler?.canSynchronizeSource.value"
|
:loading="filler?.sourceSyncLoading.value"
|
@click="filler?.previewSourceSync()">
|
<template #icon><ReloadOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
<span class="plugin-version" data-test="plugin-version">v0.1.0</span>
|
</div>
|
</div>
|
</header>
|
|
<section v-if="store && scalarFields.length" class="field-toolbar" data-test="field-toolbar">
|
<a-input v-model:value="fieldFilter" allow-clear class="field-filter" data-test="field-filter" placeholder="筛选字段名称或编码">
|
<template #prefix><SearchOutlined /></template>
|
</a-input>
|
<a-tooltip v-if="showSourceSync" title="重新同步来源快照" placement="topRight">
|
<a-button
|
type="text"
|
aria-label="重新同步来源快照"
|
data-test="source-sync-button"
|
:disabled="store.status !== 'ready' || !filler?.canSynchronizeSource.value"
|
:loading="filler?.sourceSyncLoading.value"
|
@click="filler?.previewSourceSync()">
|
<template #icon><ReloadOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
</section>
|
|
<section class="plugin-scroll-region" data-test="plugin-scroll-region">
|
<a-alert v-if="store?.status === 'error'" type="error" :message="store.message" show-icon />
|
<a-alert v-else-if="store?.repeatError" type="error" :message="store.repeatError" show-icon closable @close="store.repeatError = ''" />
|
<a-alert v-else-if="store?.sourceSyncError" type="error" :message="store.sourceSyncError" show-icon closable @close="store.sourceSyncError = ''" />
|
<a-alert v-else-if="store?.checkboxGroupError" type="error" :message="store.checkboxGroupError" show-icon />
|
<a-alert
|
v-else-if="store?.checkboxGroupNotice"
|
type="warning"
|
:message="store.checkboxGroupNotice"
|
show-icon
|
closable
|
@close="store.checkboxGroupNotice = ''" />
|
<a-alert
|
v-else-if="store?.documentSyncError"
|
type="error"
|
:message="store.documentSyncError"
|
show-icon
|
closable
|
@close="store.documentSyncError = ''" />
|
|
<section v-if="conflictItems.length" class="conflict-summary" aria-label="字段冲突">
|
<div class="conflict-summary-title">
|
<ExclamationCircleFilled /><strong>{{ conflictItems.length }} 个字段冲突</strong>
|
</div>
|
<button
|
v-for="item in conflictItems"
|
:key="item.tag"
|
type="button"
|
class="conflict-summary-item"
|
:data-conflict-tag="item.tag"
|
@click="locateField(item.tag)">
|
<span>{{ item.alias }}</span
|
><small v-if="item.stepName">{{ item.stepName }}</small>
|
</button>
|
</section>
|
|
<template v-if="store && filler?.isMultiStep.value">
|
<nav class="step-navigation" aria-label="步骤导航">
|
<a-tooltip title="上一步骤">
|
<a-button type="text" aria-label="上一步骤" :disabled="currentStepIndex <= 0" @click="moveStep(-1)"
|
><template #icon><LeftOutlined /></template
|
></a-button>
|
</a-tooltip>
|
<div class="current-step">
|
<span>{{ currentStep?.name }}</span>
|
<a-tag :color="statusColor">{{ statusLabel }}</a-tag>
|
</div>
|
<a-tooltip title="下一步骤">
|
<a-button type="text" aria-label="下一步骤" :disabled="currentStepIndex < 0 || currentStepIndex >= orderedSteps.length - 1" @click="moveStep(1)"
|
><template #icon><RightOutlined /></template
|
></a-button>
|
</a-tooltip>
|
<a-tooltip title="填写目录">
|
<a-button type="text" aria-label="打开填写目录" @click="directoryOpen = true">
|
<template #icon><UnorderedListOutlined /></template>
|
</a-button>
|
</a-tooltip>
|
</nav>
|
|
<StepDirectory
|
v-if="directoryOpen"
|
:current-step-id="filler.currentStepId.value"
|
:progress="filler.stepProgress.value"
|
@close="directoryOpen = false"
|
@select="selectFromDirectory" />
|
<a-collapse v-else v-model:active-key="activeSections" ghost class="step-sections">
|
<a-collapse-panel v-if="scalarFields.length" key="fields" header="普通字段">
|
<FieldDraftList
|
:conflicts="store.conflicts"
|
:disabled="disabled"
|
:errors="store.fieldErrors"
|
:fields="scalarFields"
|
:filter-input="fieldFilter"
|
:focused-tag="store.focusedTag"
|
@locate="locateField"
|
@resolve="store.resolveConflict"
|
@update="store.setDraft" />
|
</a-collapse-panel>
|
<a-collapse-panel v-if="repeatFields.length || visibleGroups.length" key="repeats" header="重复数据">
|
<RepeatRowsPanel
|
:append-disabled="disabled || !filler.canAppendRepeat()"
|
:conflicts="store.conflicts"
|
:disabled="disabled"
|
:errors="store.fieldErrors"
|
:fields="repeatFields"
|
:groups="visibleGroups"
|
:focused-tag="store.focusedTag"
|
@append="filler.appendRepeatRow($event)"
|
@delete="filler.deleteRepeatRow"
|
@locate="locateField"
|
@resolve="store.resolveConflict"
|
@restore="filler.restoreRepeatRow"
|
@update="store.setDraft" />
|
</a-collapse-panel>
|
<a-empty v-if="!scalarFields.length && !repeatFields.length && !visibleGroups.length" :image="false" description="当前步骤没有填写项" />
|
</a-collapse>
|
</template>
|
|
<template v-else-if="store">
|
<FieldDraftList
|
:conflicts="store.conflicts"
|
:disabled="disabled"
|
:errors="store.fieldErrors"
|
:fields="scalarFields"
|
:filter-input="fieldFilter"
|
:focused-tag="store.focusedTag"
|
@locate="locateField"
|
@resolve="store.resolveConflict"
|
@update="store.setDraft" />
|
<RepeatRowsPanel
|
v-if="repeatFields.length || visibleGroups.length"
|
:append-disabled="disabled || !filler?.canAppendRepeat()"
|
:conflicts="store.conflicts"
|
:disabled="disabled"
|
:errors="store.fieldErrors"
|
:fields="repeatFields"
|
:groups="visibleGroups"
|
:focused-tag="store.focusedTag"
|
@append="filler?.appendRepeatRow($event)"
|
@delete="filler?.deleteRepeatRow"
|
@locate="locateField"
|
@resolve="store.resolveConflict"
|
@restore="filler?.restoreRepeatRow"
|
@update="store.setDraft" />
|
</template>
|
</section>
|
|
<footer>
|
<a-button
|
type="primary"
|
block
|
:disabled="disabled || Boolean(filler?.conflictCount.value) || Boolean(store?.checkboxGroupError)"
|
:loading="store?.status === 'working'"
|
@click="filler?.apply()">
|
应用到文档<span v-if="filler?.pendingCount.value">({{ filler.pendingCount.value }})</span>
|
</a-button>
|
</footer>
|
|
<a-modal
|
:open="Boolean(filler?.sourceSyncPreview.value)"
|
:confirm-loading="filler?.sourceSyncLoading.value"
|
:ok-button-props="{ disabled: sourceSyncConfirmDisabled }"
|
title="重新同步来源快照"
|
width="min(520px, calc(100vw - 24px))"
|
@cancel="closeSourceSync"
|
@ok="confirmSourceSync">
|
<div v-if="visibleSourceDifferences.length" class="source-sync-list" data-test="source-sync-differences">
|
<div v-for="item in visibleSourceDifferences" :key="item.bindingId" class="source-sync-item">
|
<div class="source-sync-field">
|
<div class="source-sync-heading">
|
<strong>{{ item.fieldName || item.fieldCode }}</strong>
|
<a-tag :color="item.sourceType === 'CROSS_TEMPLATE' ? 'blue' : 'default'">{{ sourceTypeLabel(item.sourceType) }}</a-tag>
|
</div>
|
<small v-if="item.sourceProperty">{{ item.sourceProperty }}{{ item.sourcePropertyRevision ? `@${item.sourcePropertyRevision}` : '' }}</small>
|
<small v-if="item.sourceOutcome === 'MISSING'">来源缺失,保留原快照</small>
|
<small v-if="item.sourceOutcome === 'NOT_APPLICABLE'">来源不适用,保留原快照</small>
|
<small v-if="item.sourceOutcome === 'MISSING' || item.sourceOutcome === 'NOT_APPLICABLE'">
|
原获取时间:{{ item.previousFetchedTime || '-' }}
|
</small>
|
<small v-if="item.sourceType === 'CROSS_TEMPLATE' && item.sourceRecordId" class="source-sync-lineage">
|
源记录 {{ item.sourceRecordId }} · 源修订 {{ item.sourceRevisionId || '-' }}
|
</small>
|
</div>
|
<div class="source-sync-values">
|
<span>{{ item.previousValue || '-' }}</span>
|
<RightOutlined />
|
<strong>{{ item.bindingId in sourceClearReasons ? '-' : (item.currentValue || '-') }}</strong>
|
</div>
|
<template v-if="item.canClear === true && (item.sourceOutcome === 'MISSING' || item.sourceOutcome === 'NOT_APPLICABLE')">
|
<a-checkbox
|
:checked="item.bindingId in sourceClearReasons"
|
:disabled="filler?.sourceSyncLoading.value"
|
@update:checked="selectSourceClear(item.bindingId, $event)">
|
清空此字段
|
</a-checkbox>
|
<a-textarea
|
v-if="item.bindingId in sourceClearReasons"
|
v-model:value="sourceClearReasons[item.bindingId]"
|
:disabled="filler?.sourceSyncLoading.value"
|
:maxlength="500"
|
:rows="2"
|
show-count
|
aria-label="清空原因"
|
placeholder="清空原因(必填)" />
|
</template>
|
</div>
|
</div>
|
<a-empty v-else :image="false" description="来源快照没有变化" data-test="source-sync-empty" />
|
</a-modal>
|
</main>
|
</a-config-provider>
|
</template>
|