<script setup lang="ts">
|
import type { TemplateAnnotationClient } from './api/template-annotation';
|
import type { FormulaBindingCandidate } from './domain/formula-mapping';
|
import type {
|
CrossTemplateScalarCandidate,
|
TemplateCheckboxGroupDraft,
|
TemplateFieldOption,
|
TemplatePrivateFieldDraft,
|
TemplateRepeatGroup,
|
} from './domain/types';
|
import type { OnlyOfficeBridge } from './onlyoffice/bridge';
|
|
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
|
|
import { message } from 'ant-design-vue';
|
import zhCN from 'ant-design-vue/es/locale/zh_CN';
|
|
import { templateAnnotationClient } from './api/template-annotation';
|
import DocumentFieldList from './components/DocumentFieldList.vue';
|
import FieldEditor from './components/FieldEditor.vue';
|
import FormulaBindingPanel from './components/FormulaBindingPanel.vue';
|
import OperationStatus from './components/OperationStatus.vue';
|
import { useTemplateAnnotator } from './composables/useTemplateAnnotator';
|
import { useAnnotationStore } from './stores/annotation';
|
import { usePluginContextStore } from './stores/plugin-context';
|
|
const bridge = inject<null | OnlyOfficeBridge>('onlyOfficeBridge', null);
|
const client = inject<TemplateAnnotationClient>('templateAnnotationClient', templateAnnotationClient);
|
const annotator = bridge ? useTemplateAnnotator(bridge, client) : null;
|
const store = annotator?.store ?? useAnnotationStore();
|
const contextStore = annotator?.contextStore ?? usePluginContextStore();
|
const runtimeStatus = computed(() => (bridge ? store.status : 'error'));
|
const repairMode = computed(() => Boolean(contextStore.context?.repairBindingId));
|
const locateMode = computed(() => Boolean(contextStore.context?.locateControlTag));
|
const editorDisabled = computed(() => !bridge || store.status !== 'ready' || !contextStore.context?.canAnnotate);
|
const stepConfiguration = computed(() => annotator?.stepConfiguration ?? null);
|
const showStepSelector = computed(() => Boolean(stepConfiguration.value && !stepConfiguration.value.singleStep && !repairMode.value && !locateMode.value));
|
const annotationDisabled = computed(() => editorDisabled.value || Boolean(showStepSelector.value && !annotator?.currentStepId));
|
const documentFieldsDisabled = computed(() => !bridge || contextStore.status !== 'ready');
|
const documentFieldsReadOnly = computed(() => !contextStore.context?.canAnnotate);
|
const formulaCandidates = computed(() =>
|
(annotator?.bindings ?? []).flatMap((binding) => {
|
const field = annotator?.fields.find((item) => item.fieldVersionId === binding.fieldVersionId);
|
if (!field) return [];
|
const fieldDataType = field.type === 'DECIMAL' ? 'DECIMAL' : field.type === 'CHECKBOX' ? 'BOOLEAN' : 'TEXT';
|
return [
|
{
|
id: binding.id,
|
label: binding.controlAlias || field.label,
|
controlRole: binding.controlRole,
|
status: binding.status,
|
fieldDataType,
|
readSource: field.source,
|
writeTarget: field.writeTarget ?? '',
|
batchMode: field.batchMode ?? '',
|
} satisfies FormulaBindingCandidate & { label: string },
|
];
|
}),
|
);
|
const usedFormulaResults = computed(() =>
|
(annotator?.formulaBindings ?? []).filter((binding) => binding.status === 'ACTIVE' || binding.status === 'PENDING').map((binding) => binding.resultBindingId),
|
);
|
const activeTask = ref<'annotate' | 'document' | 'formula'>('annotate');
|
const annotationFormResetToken = ref(0);
|
const documentCount = computed(() => store.controls.length);
|
const formulaCount = computed(() => (annotator?.formulaBindings ?? []).filter((binding) => binding.status === 'ACTIVE' || binding.status === 'PENDING').length);
|
const taskBadgeStyle = { backgroundColor: '#f0f0f0', boxShadow: 'none', color: '#595959' };
|
const stepOptions = computed(() => {
|
const configuration = stepConfiguration.value;
|
if (!configuration) return [];
|
return [...configuration.steps]
|
.sort((left, right) => left.order - right.order || left.id.localeCompare(right.id))
|
.map((step) => ({ label: step.name, value: step.id }));
|
});
|
const showTaskError = computed(() => store.operation.kind === 'error' && runtimeStatus.value !== 'error');
|
const loadingMessageContent = computed(() => {
|
if (store.operation.kind === 'working' && store.operation.message) return store.operation.message;
|
return runtimeStatus.value === 'initializing' ? '正在连接 ONLYOFFICE...' : '';
|
});
|
const silentSuccessMessages = new Set(['文档字段已刷新', '模板标注已刷新', '模板标注已加载']);
|
const loadingMessageKey = 'eln-template-operation';
|
let loadingMessageTimer: ReturnType<typeof setTimeout> | undefined;
|
let loadingMessageVisible = false;
|
|
message.config({ getContainer: () => document.getElementById('app') ?? document.body });
|
|
watch(
|
loadingMessageContent,
|
(content) => {
|
if (loadingMessageTimer) clearTimeout(loadingMessageTimer);
|
loadingMessageTimer = undefined;
|
if (!content) {
|
if (loadingMessageVisible) message.destroy(loadingMessageKey);
|
loadingMessageVisible = false;
|
return;
|
}
|
if (loadingMessageVisible) {
|
message.loading({ content, duration: 0, key: loadingMessageKey });
|
return;
|
}
|
loadingMessageTimer = setTimeout(() => {
|
loadingMessageVisible = true;
|
message.loading({ content, duration: 0, key: loadingMessageKey });
|
}, 400);
|
},
|
{ immediate: true },
|
);
|
|
watch(
|
() => store.operation,
|
(operation) => {
|
if (operation.kind === 'success' && !silentSuccessMessages.has(operation.message)) {
|
message.success({
|
content: operation.message,
|
duration: 2,
|
});
|
}
|
},
|
{ deep: true },
|
);
|
|
async function resetAnnotationFormAfterSuccess(action: () => Promise<void>): Promise<void> {
|
try {
|
await action();
|
annotationFormResetToken.value++;
|
} catch {
|
// 保留表单内容,便于用户修正失败原因后重试。
|
}
|
}
|
|
function insert(field: TemplateFieldOption, checkboxGroup?: TemplateCheckboxGroupDraft): void {
|
if (annotator) void resetAnnotationFormAfterSuccess(() => annotator.insertSelectedField(field, checkboxGroup));
|
}
|
|
function setCurrentStep(stepId: string): void {
|
annotator?.setCurrentStepId(stepId);
|
}
|
|
function insertRepeat(field: TemplateFieldOption, group: TemplateRepeatGroup, checkboxGroup?: TemplateCheckboxGroupDraft): void {
|
if (annotator) void resetAnnotationFormAfterSuccess(() => annotator.insertRepeatField(field, group, checkboxGroup));
|
}
|
|
function loadCrossTemplateSourceTemplates(): void {
|
void annotator?.loadCrossTemplateSourceTemplates().catch(() => undefined);
|
}
|
|
function loadCrossTemplateScalarCandidates(sourceTemplateId: string): void {
|
void annotator?.loadCrossTemplateScalarCandidates(sourceTemplateId).catch(() => undefined);
|
}
|
|
function insertCrossTemplateScalar(candidate: CrossTemplateScalarCandidate): void {
|
if (annotator) void resetAnnotationFormAfterSuccess(() => annotator.insertCrossTemplateScalar(candidate));
|
}
|
|
function createPrivate(draft: TemplatePrivateFieldDraft): void {
|
if (annotator) void resetAnnotationFormAfterSuccess(() => annotator.createPrivateField(draft));
|
}
|
|
function refresh(): void {
|
void annotator?.refresh().catch(() => undefined);
|
}
|
|
function select(internalId: string): void {
|
void annotator?.select(internalId).catch(() => undefined);
|
}
|
|
function insertReference(bindingId: string): void {
|
void annotator?.insertReference(bindingId).catch(() => undefined);
|
}
|
|
function removeBinding(bindingId: string): void {
|
void annotator?.removeBinding(bindingId).catch(() => undefined);
|
}
|
|
function removeOrphanControl(internalId: string): void {
|
void annotator?.removeOrphanControl(internalId).catch(() => undefined);
|
}
|
|
function repair(bindingId: string): void {
|
void annotator?.repairBindingId(bindingId).catch(() => undefined);
|
}
|
|
function promoteReference(bindingId: string): void {
|
void annotator?.promoteReference(bindingId).catch(() => undefined);
|
}
|
|
function upgradeBinding(bindingId: string, targetFieldVersionId: string): void {
|
void annotator?.upgradeBinding(bindingId, targetFieldVersionId).catch(() => undefined);
|
}
|
|
function changeCheckboxGroup(bindingId: string, checkboxGroup: null | TemplateCheckboxGroupDraft, expectedVersion: number): void {
|
void annotator?.changeCheckboxGroup(bindingId, checkboxGroup, expectedVersion).catch(() => undefined);
|
}
|
|
function changeCheckboxGroupRule(groupCode: string, minSelected: number, maxSelected: null | number, expectedVersion: number): void {
|
void annotator?.changeCheckboxGroupRule(groupCode, minSelected, maxSelected, expectedVersion).catch(() => undefined);
|
}
|
|
function reserveFormula(request: { formulaVersionId: string; parameterBindings: Record<string, string>; resultBindingId: string }): void {
|
void annotator?.reserveFormulaBinding(request).catch(() => undefined);
|
}
|
|
function removeFormula(bindingId: string): void {
|
void annotator?.removeFormulaBinding(bindingId).catch(() => undefined);
|
}
|
|
function upgradeFormula(bindingId: string, request: { formulaVersionId: string; parameterBindings: Record<string, string>; resultBindingId: string }): void {
|
void annotator?.upgradeFormulaBinding(bindingId, request).catch(() => undefined);
|
}
|
|
function getPopupContainer(trigger?: HTMLElement): HTMLElement {
|
return (trigger?.closest('.plugin-shell') as HTMLElement) || document.getElementById('app') || document.body;
|
}
|
|
onBeforeUnmount(() => {
|
if (loadingMessageTimer) clearTimeout(loadingMessageTimer);
|
if (loadingMessageVisible) message.destroy(loadingMessageKey);
|
annotator?.dispose();
|
});
|
</script>
|
|
<template>
|
<a-config-provider :get-popup-container="getPopupContainer" :locale="zhCN">
|
<main class="plugin-shell">
|
<header class="plugin-header">
|
<OperationStatus
|
:operation="store.operation"
|
:retryable="Boolean(bridge && contextStore.status === 'ready')"
|
:sdk-available="Boolean(bridge)"
|
:status="runtimeStatus"
|
@retry="refresh" />
|
</header>
|
|
<section v-if="showStepSelector" class="current-step-bar" aria-label="当前步骤">
|
<label class="current-step-label" for="eln-current-step">当前步骤</label>
|
<a-select
|
id="eln-current-step"
|
data-test="current-step"
|
:disabled="editorDisabled"
|
:options="stepOptions"
|
option-filter-prop="label"
|
placeholder="搜索并选择步骤"
|
show-search
|
:value="annotator?.currentStepId ?? undefined"
|
@update:value="setCurrentStep" />
|
</section>
|
|
<a-tabs
|
v-if="!repairMode && !locateMode"
|
v-model:active-key="activeTask"
|
class="primary-task-tabs"
|
data-test="primary-task-tabs"
|
:destroy-inactive-tab-pane="true">
|
<a-tab-pane key="annotate" tab="标注">
|
<a-alert v-if="showTaskError" class="task-feedback" data-test="task-error" :message="store.operation.message" show-icon type="error" />
|
<FieldEditor
|
:bindings="annotator?.bindings ?? []"
|
:checkbox-groups="annotator?.checkboxGroups ?? []"
|
:cross-template-candidates="annotator?.crossTemplateScalarCandidates ?? []"
|
:cross-template-source-templates="annotator?.crossTemplateSourceTemplates ?? []"
|
:current-step-id="annotator?.currentStepId ?? null"
|
:disabled="annotationDisabled"
|
:fields="annotator?.fields ?? []"
|
:read-selected-text="bridge?.readSelectedText"
|
:repeat-groups="annotator?.repeatGroups ?? []"
|
:reset-token="annotationFormResetToken"
|
:suggest-code="annotator?.suggestCode"
|
@create-private="createPrivate"
|
@load-cross-template-fields="loadCrossTemplateScalarCandidates"
|
@load-cross-template-templates="loadCrossTemplateSourceTemplates"
|
@submit="insert"
|
@submit-cross-template="insertCrossTemplateScalar"
|
@submit-repeat="insertRepeat" />
|
</a-tab-pane>
|
<a-tab-pane key="document">
|
<template #tab>
|
<span class="task-tab-label">
|
<span>文档</span>
|
<a-badge v-if="documentCount > 0" data-test="document-tab-count" :count="documentCount" :number-style="taskBadgeStyle" />
|
</span>
|
</template>
|
<a-alert v-if="showTaskError" class="task-feedback" data-test="task-error" :message="store.operation.message" show-icon type="error" />
|
<DocumentFieldList
|
:bindings="annotator?.bindings ?? []"
|
:checkbox-groups="annotator?.checkboxGroups ?? []"
|
:controls="store.controls"
|
:disabled="documentFieldsDisabled"
|
:fields="annotator?.fields ?? []"
|
:read-only="documentFieldsReadOnly"
|
:repair-binding-id="contextStore.context?.repairBindingId"
|
:status="runtimeStatus"
|
:steps="stepConfiguration?.steps ?? []"
|
@change-checkbox-group="changeCheckboxGroup"
|
@change-checkbox-group-rule="changeCheckboxGroupRule"
|
@insert-reference="insertReference"
|
@promote-reference="promoteReference"
|
@remove-binding="removeBinding"
|
@remove-orphan-control="removeOrphanControl"
|
@repair="repair"
|
@refresh="refresh"
|
@select="select"
|
@upgrade-binding="upgradeBinding" />
|
</a-tab-pane>
|
<a-tab-pane key="formula">
|
<template #tab>
|
<span class="task-tab-label">
|
<span>公式</span>
|
<a-badge v-if="formulaCount > 0" data-test="formula-tab-count" :count="formulaCount" :number-style="taskBadgeStyle" />
|
</span>
|
</template>
|
<a-alert v-if="showTaskError" class="task-feedback" data-test="task-error" :message="store.operation.message" show-icon type="error" />
|
<FormulaBindingPanel
|
:candidates="formulaCandidates"
|
:disabled="editorDisabled"
|
:mappings="annotator?.formulaBindings ?? []"
|
:used-result-binding-ids="usedFormulaResults"
|
:versions="annotator?.formulaVersions ?? []"
|
@reserve="reserveFormula"
|
@remove="removeFormula"
|
@upgrade="upgradeFormula" />
|
</a-tab-pane>
|
</a-tabs>
|
|
<div v-else class="repair-task">
|
<a-alert v-if="showTaskError" class="task-feedback" data-test="task-error" :message="store.operation.message" show-icon type="error" />
|
<DocumentFieldList
|
:bindings="annotator?.bindings ?? []"
|
:checkbox-groups="annotator?.checkboxGroups ?? []"
|
:controls="store.controls"
|
:disabled="documentFieldsDisabled"
|
:fields="annotator?.fields ?? []"
|
:read-only="documentFieldsReadOnly"
|
:repair-binding-id="contextStore.context?.repairBindingId"
|
:status="runtimeStatus"
|
:steps="stepConfiguration?.steps ?? []"
|
@change-checkbox-group="changeCheckboxGroup"
|
@change-checkbox-group-rule="changeCheckboxGroupRule"
|
@insert-reference="insertReference"
|
@promote-reference="promoteReference"
|
@remove-binding="removeBinding"
|
@remove-orphan-control="removeOrphanControl"
|
@repair="repair"
|
@refresh="refresh"
|
@select="select"
|
@upgrade-binding="upgradeBinding" />
|
</div>
|
</main>
|
</a-config-provider>
|
</template>
|
|
<style scoped>
|
.task-tab-label {
|
display: inline-flex;
|
gap: 6px;
|
align-items: center;
|
}
|
|
.task-tab-label :deep(.ant-badge-count) {
|
position: static;
|
min-width: 20px;
|
height: 20px;
|
padding: 0 6px;
|
font-size: 12px;
|
line-height: 20px;
|
transform: none;
|
}
|
</style>
|