<script lang="ts" setup>
|
import type { Diagnostic as CodeMirrorDiagnostic } from '@codemirror/lint';
|
|
import type { FormulaResourceKind } from './formulaLanguage';
|
|
import type { FormulaFunction, FormulaRule, FormulaValidation, ParameterSlot } from '#/api/x/eln/formulas';
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import { autocompletion, completionStatus, startCompletion } from '@codemirror/autocomplete';
|
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
import { linter, setDiagnostics } from '@codemirror/lint';
|
import { EditorState } from '@codemirror/state';
|
import { placeholder as editorPlaceholder, EditorView, keymap } from '@codemirror/view';
|
|
import { formulaCompletionSource, formulaLanguage } from './formulaLanguage';
|
import { toEditorDiagnostics } from './formulaRules';
|
import { useFormulaValidation } from './useFormulaValidation';
|
|
defineOptions({ name: 'ElnFormulaEditor' });
|
|
const props = withDefaults(
|
defineProps<{
|
functions?: FormulaFunction[];
|
modelValue: string;
|
parameters?: ParameterSlot[];
|
readonly?: boolean;
|
rule: FormulaRule;
|
}>(),
|
{ functions: () => [], parameters: () => [], readonly: false },
|
);
|
const emit = defineEmits<{
|
'update:modelValue': [value: string];
|
validation: [value: FormulaValidation];
|
}>();
|
|
const editorElement = ref<HTMLElement>();
|
const { error, loading, result, retry, schedule, validateNow } = useFormulaValidation();
|
const latestValidation = computed(() => result.value);
|
const validationStatusText = computed(() => {
|
if (loading.value) return '校验中';
|
if (error.value) return '校验失败';
|
if (latestValidation.value?.diagnostics.some((diagnostic) => diagnostic.severity === 'ERROR')) return '存在错误';
|
if (latestValidation.value) return '语法通过';
|
return '';
|
});
|
let editorView: EditorView | undefined;
|
let syncingModelValue = false;
|
let activeResourceKind: FormulaResourceKind = 'all';
|
|
function clearValidation() {
|
if (editorView) editorView.dispatch(setDiagnostics(editorView.state, []));
|
emit('validation', { diagnostics: [], latex: null });
|
}
|
|
function scheduleValidation(rule: FormulaRule) {
|
clearValidation();
|
schedule(rule);
|
}
|
|
onMounted(() => {
|
if (!editorElement.value) return;
|
editorView = new EditorView({
|
parent: editorElement.value,
|
state: EditorState.create({
|
doc: props.modelValue,
|
extensions: [
|
history(),
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
bracketMatching(),
|
formulaLanguage,
|
syntaxHighlighting(defaultHighlightStyle),
|
editorPlaceholder('请输入公式表达式'),
|
autocompletion({ override: [(context) => formulaCompletionSource(props.parameters, props.functions, () => activeResourceKind)(context)] }),
|
linter(() => []),
|
EditorView.lineWrapping,
|
EditorView.editable.of(!props.readonly),
|
EditorView.updateListener.of((update) => {
|
if (completionStatus(update.startState) && !completionStatus(update.state)) activeResourceKind = 'all';
|
if (!update.docChanged || syncingModelValue) return;
|
const value = update.state.doc.toString();
|
emit('update:modelValue', value);
|
scheduleValidation({ ...props.rule, biaodashi: value });
|
}),
|
],
|
}),
|
});
|
});
|
|
watch(
|
() => props.modelValue,
|
(value) => {
|
if (!editorView || value === editorView.state.doc.toString()) return;
|
syncingModelValue = true;
|
editorView.dispatch({ changes: { from: 0, insert: value, to: editorView.state.doc.length } });
|
syncingModelValue = false;
|
},
|
);
|
|
const nonExpressionRuleSignature = computed(() =>
|
JSON.stringify({
|
jieguoLeixing: props.rule.jieguoLeixing,
|
parameters: props.rule.parameters.map((slot) => ({
|
canshuBianma: slot.canshuBianma,
|
paixu: slot.paixu,
|
shifouBitian: slot.shifouBitian,
|
shujuLeixing: slot.shujuLeixing,
|
})),
|
xiuyueMoshi: props.rule.xiuyueMoshi,
|
xiuyueXiaoshuWei: props.rule.xiuyueXiaoshuWei,
|
}),
|
);
|
|
watch(nonExpressionRuleSignature, () => {
|
const expression = editorView?.state.doc.toString() || props.modelValue;
|
scheduleValidation({ ...props.rule, biaodashi: expression });
|
});
|
|
watch(latestValidation, (validationResult) => {
|
if (!validationResult) return;
|
const mappedDiagnostics = toEditorDiagnostics(validationResult.diagnostics, editorView?.state.doc.length);
|
const diagnostics: CodeMirrorDiagnostic[] = mappedDiagnostics.map((diagnostic) => ({
|
from: diagnostic.from,
|
message: diagnostic.message || diagnostic.code || '公式校验失败',
|
severity: diagnostic.severity === 'ERROR' ? 'error' : 'warning',
|
to: diagnostic.to,
|
}));
|
if (editorView) editorView.dispatch(setDiagnostics(editorView.state, diagnostics));
|
const hasSyntaxError = validationResult.diagnostics.some((diagnostic) => diagnostic.severity === 'ERROR');
|
emit('validation', { ...validationResult, latex: hasSyntaxError ? null : validationResult.latex });
|
});
|
|
function insert(text: string) {
|
if (!editorView || props.readonly) return;
|
const range = editorView.state.selection.main;
|
editorView.dispatch({
|
changes: { from: range.from, insert: text, to: range.to },
|
selection: { anchor: range.from + text.length },
|
});
|
editorView.focus();
|
}
|
|
function openResourceMenu(kind: Exclude<FormulaResourceKind, 'all'>) {
|
if (!editorView || props.readonly) return;
|
activeResourceKind = kind;
|
editorView.focus();
|
startCompletion(editorView);
|
}
|
|
async function validate() {
|
const expression = editorView?.state.doc.toString() || props.modelValue;
|
await validateNow({ ...props.rule, biaodashi: expression });
|
}
|
|
onBeforeUnmount(() => {
|
editorView?.destroy();
|
editorView = undefined;
|
});
|
|
defineExpose({ insert, validate });
|
</script>
|
|
<template>
|
<div class="formula-editor" :class="{ 'is-readonly': readonly }">
|
<div v-if="!readonly" class="formula-editor__toolbar">
|
<a-button size="small" @click="openResourceMenu('parameter')">插入参数</a-button>
|
<a-button size="small" @click="openResourceMenu('function')">插入函数</a-button>
|
<span v-if="validationStatusText" class="formula-editor__validation-status" :class="{ 'is-error': error || validationStatusText === '存在错误' }">
|
{{ validationStatusText }}
|
</span>
|
</div>
|
<div ref="editorElement" class="formula-editor__surface"></div>
|
<div v-if="error" class="formula-editor__error">
|
<a-alert :message="error" show-icon type="error" />
|
<a-button size="small" type="link" @click="retry">重试</a-button>
|
</div>
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.formula-editor__surface {
|
min-height: 180px;
|
max-height: 360px;
|
overflow: auto;
|
border: 1px solid var(--border-color-base);
|
border-top: 0;
|
}
|
|
.formula-editor.is-readonly .formula-editor__surface {
|
border-top: 1px solid var(--border-color-base);
|
}
|
|
.formula-editor__toolbar {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
min-height: 40px;
|
padding: 4px 8px;
|
background: var(--app-content-background);
|
border: 1px solid var(--border-color-base);
|
}
|
|
.formula-editor__validation-status {
|
margin-left: auto;
|
color: var(--success-color);
|
font-size: 12px;
|
|
&.is-error {
|
color: var(--error-color);
|
}
|
}
|
|
.formula-editor__error {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
padding-top: 8px;
|
}
|
</style>
|