|
|
@@ -0,0 +1,315 @@
|
|
|
+<template>
|
|
|
+ <div class="formula-editor">
|
|
|
+ <div class="formula-editor__bar">
|
|
|
+ <span class="formula-editor__hint">
|
|
|
+ <el-tag size="small" type="info">@ 指标</el-tag>
|
|
|
+ <el-tag size="small" type="warning">$ 事实</el-tag>
|
|
|
+ <span class="formula-editor__hint-text">支持 + - × ÷ ( ) 数字 %</span>
|
|
|
+ </span>
|
|
|
+ <el-button link type="primary" size="small" @click="insertSymbol('@')">插入 @指标</el-button>
|
|
|
+ <el-button link type="primary" size="small" @click="insertSymbol('$')">插入 $事实</el-button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <el-input
|
|
|
+ ref="inputRef"
|
|
|
+ v-model="localExpr"
|
|
|
+ type="textarea"
|
|
|
+ :rows="2"
|
|
|
+ :placeholder="placeholder"
|
|
|
+ @input="onInput"
|
|
|
+ @blur="onBlur"
|
|
|
+ />
|
|
|
+
|
|
|
+ <div v-if="showSuggest" class="formula-editor__suggest" :style="suggestStyle">
|
|
|
+ <div class="formula-editor__suggest-header">
|
|
|
+ {{ suggestSymbol === '@' ? '指标列表' : '业务事实列表' }}
|
|
|
+ <span class="formula-editor__suggest-filter">{{ suggestFilter || '(输入后自动过滤)' }}</span>
|
|
|
+ </div>
|
|
|
+ <div class="formula-editor__suggest-list">
|
|
|
+ <div
|
|
|
+ v-for="(it, i) in filteredSuggest"
|
|
|
+ :key="it.code"
|
|
|
+ class="formula-editor__suggest-item"
|
|
|
+ :class="{ active: i === suggestIndex }"
|
|
|
+ @mousedown.prevent="selectSuggest(it)"
|
|
|
+ >
|
|
|
+ <span class="formula-editor__suggest-code">{{ it.code }}</span>
|
|
|
+ <span class="formula-editor__suggest-name">{{ it.name }}</span>
|
|
|
+ <span v-if="it.module" class="formula-editor__suggest-module">[{{ it.module }}]</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="filteredSuggest.length === 0" class="formula-editor__suggest-empty">无匹配项</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div v-if="preview" class="formula-editor__preview">
|
|
|
+ <span class="formula-editor__preview-label">预览:</span>
|
|
|
+ <span class="formula-editor__preview-text">{{ preview }}</span>
|
|
|
+ </div>
|
|
|
+ <div v-if="errors.length" class="formula-editor__error">
|
|
|
+ <div v-for="(e, i) in errors" :key="'e' + i">✗ {{ e }}</div>
|
|
|
+ </div>
|
|
|
+ <div v-if="warnings.length" class="formula-editor__warning">
|
|
|
+ <div v-for="(w, i) in warnings" :key="'w' + i">⚠ {{ w }}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { ref, computed, watch, reactive, nextTick } from 'vue';
|
|
|
+import * as kpiApi from '/@/views/aidop/api/kpiMasterApi';
|
|
|
+import * as factApi from '/@/views/aidop/api/businessFactApi';
|
|
|
+
|
|
|
+const props = defineProps<{
|
|
|
+ modelValue: string | null | undefined;
|
|
|
+ moduleCode?: string;
|
|
|
+ isEnabled?: boolean;
|
|
|
+ excludeKpiId?: number;
|
|
|
+ placeholder?: string;
|
|
|
+}>();
|
|
|
+
|
|
|
+const emit = defineEmits<{
|
|
|
+ (e: 'update:modelValue', v: string): void;
|
|
|
+ (e: 'validationChange', v: { errors: string[]; warnings: string[]; preview: string }): void;
|
|
|
+}>();
|
|
|
+
|
|
|
+const inputRef = ref<any>(null);
|
|
|
+const localExpr = ref(props.modelValue ?? '');
|
|
|
+
|
|
|
+watch(
|
|
|
+ () => props.modelValue,
|
|
|
+ (v) => {
|
|
|
+ if ((v ?? '') !== localExpr.value) localExpr.value = v ?? '';
|
|
|
+ }
|
|
|
+);
|
|
|
+
|
|
|
+// ── 候选 ──
|
|
|
+
|
|
|
+interface SuggestItem { code: string; name: string; module?: string }
|
|
|
+const metricList = ref<SuggestItem[]>([]);
|
|
|
+const factList = ref<SuggestItem[]>([]);
|
|
|
+
|
|
|
+async function loadCandidates() {
|
|
|
+ try {
|
|
|
+ const [tree, facts] = await Promise.all([
|
|
|
+ kpiApi.getTree({ moduleCode: undefined as any }),
|
|
|
+ factApi.picker(props.moduleCode),
|
|
|
+ ]);
|
|
|
+ const nodes: any[] = (tree as any).data ?? tree ?? [];
|
|
|
+ const flat: SuggestItem[] = [];
|
|
|
+ const walk = (arr: any[]) => {
|
|
|
+ for (const n of arr) {
|
|
|
+ flat.push({ code: n.metricCode, name: n.metricName, module: n.moduleCode });
|
|
|
+ if (n.children?.length) walk(n.children);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ walk(nodes);
|
|
|
+ metricList.value = flat;
|
|
|
+
|
|
|
+ const fdata: any[] = (facts as any).data ?? facts ?? [];
|
|
|
+ factList.value = fdata.map((f) => ({ code: f.factCode, name: f.factName, module: f.moduleCode }));
|
|
|
+ } catch (err) {
|
|
|
+ // 静默失败,候选为空不阻塞编辑
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+loadCandidates();
|
|
|
+watch(
|
|
|
+ () => props.moduleCode,
|
|
|
+ () => loadCandidates()
|
|
|
+);
|
|
|
+
|
|
|
+// ── 输入监听 + 候选弹出 ──
|
|
|
+
|
|
|
+const showSuggest = ref(false);
|
|
|
+const suggestSymbol = ref<'@' | '$'>('@');
|
|
|
+const suggestFilter = ref('');
|
|
|
+const suggestIndex = ref(0);
|
|
|
+const suggestStyle = reactive({ top: '0px', left: '0px' });
|
|
|
+
|
|
|
+const filteredSuggest = computed<SuggestItem[]>(() => {
|
|
|
+ const src = suggestSymbol.value === '@' ? metricList.value : factList.value;
|
|
|
+ const kw = suggestFilter.value.toLowerCase();
|
|
|
+ if (!kw) return src.slice(0, 50);
|
|
|
+ return src
|
|
|
+ .filter((x) => x.code.toLowerCase().includes(kw) || x.name.toLowerCase().includes(kw))
|
|
|
+ .slice(0, 50);
|
|
|
+});
|
|
|
+
|
|
|
+function getCursorPos(): number {
|
|
|
+ const el = inputRef.value?.textarea ?? inputRef.value?.$refs?.textarea ?? null;
|
|
|
+ if (el && 'selectionStart' in el) return el.selectionStart as number;
|
|
|
+ return localExpr.value.length;
|
|
|
+}
|
|
|
+
|
|
|
+function onInput() {
|
|
|
+ emit('update:modelValue', localExpr.value);
|
|
|
+ const pos = getCursorPos();
|
|
|
+ const before = localExpr.value.substring(0, pos);
|
|
|
+ const match = before.match(/([@$])([A-Za-z0-9_]*)$/);
|
|
|
+ if (match) {
|
|
|
+ suggestSymbol.value = match[1] as '@' | '$';
|
|
|
+ suggestFilter.value = match[2];
|
|
|
+ suggestIndex.value = 0;
|
|
|
+ showSuggest.value = true;
|
|
|
+ } else {
|
|
|
+ showSuggest.value = false;
|
|
|
+ }
|
|
|
+ schedulePreview();
|
|
|
+}
|
|
|
+
|
|
|
+function onBlur() {
|
|
|
+ setTimeout(() => {
|
|
|
+ showSuggest.value = false;
|
|
|
+ }, 150);
|
|
|
+ fetchPreview();
|
|
|
+}
|
|
|
+
|
|
|
+function insertSymbol(sym: '@' | '$') {
|
|
|
+ const pos = getCursorPos();
|
|
|
+ localExpr.value = localExpr.value.slice(0, pos) + sym + localExpr.value.slice(pos);
|
|
|
+ emit('update:modelValue', localExpr.value);
|
|
|
+ nextTick(() => {
|
|
|
+ const el = inputRef.value?.textarea;
|
|
|
+ if (el) {
|
|
|
+ el.focus();
|
|
|
+ el.setSelectionRange(pos + 1, pos + 1);
|
|
|
+ }
|
|
|
+ onInput();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function selectSuggest(it: SuggestItem) {
|
|
|
+ const pos = getCursorPos();
|
|
|
+ const before = localExpr.value.substring(0, pos);
|
|
|
+ const after = localExpr.value.substring(pos);
|
|
|
+ const newBefore = before.replace(/([@$])([A-Za-z0-9_]*)$/, `$1${it.code}`);
|
|
|
+ localExpr.value = newBefore + after;
|
|
|
+ emit('update:modelValue', localExpr.value);
|
|
|
+ showSuggest.value = false;
|
|
|
+ nextTick(() => {
|
|
|
+ const el = inputRef.value?.textarea;
|
|
|
+ if (el) {
|
|
|
+ const cp = newBefore.length;
|
|
|
+ el.focus();
|
|
|
+ el.setSelectionRange(cp, cp);
|
|
|
+ }
|
|
|
+ fetchPreview();
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+// ── 预览 ──
|
|
|
+
|
|
|
+const preview = ref('');
|
|
|
+const errors = ref<string[]>([]);
|
|
|
+const warnings = ref<string[]>([]);
|
|
|
+let previewTimer: number | null = null;
|
|
|
+
|
|
|
+function schedulePreview() {
|
|
|
+ if (previewTimer) window.clearTimeout(previewTimer);
|
|
|
+ previewTimer = window.setTimeout(() => fetchPreview(), 500);
|
|
|
+}
|
|
|
+
|
|
|
+async function fetchPreview() {
|
|
|
+ if (!localExpr.value?.trim()) {
|
|
|
+ preview.value = '';
|
|
|
+ errors.value = [];
|
|
|
+ warnings.value = [];
|
|
|
+ emit('validationChange', { errors: [], warnings: [], preview: '' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res: any = await kpiApi.previewFormula(
|
|
|
+ localExpr.value,
|
|
|
+ props.isEnabled ?? true,
|
|
|
+ props.excludeKpiId
|
|
|
+ );
|
|
|
+ const data = res.data ?? res;
|
|
|
+ preview.value = data.preview ?? '';
|
|
|
+ errors.value = data.errors ?? [];
|
|
|
+ warnings.value = data.warnings ?? [];
|
|
|
+ emit('validationChange', { errors: errors.value, warnings: warnings.value, preview: preview.value });
|
|
|
+ } catch {
|
|
|
+ // 静默失败:保留上次预览
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+defineExpose({ fetchPreview });
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped>
|
|
|
+.formula-editor { position: relative; }
|
|
|
+.formula-editor__bar {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ margin-bottom: 4px;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.formula-editor__hint { display: flex; gap: 6px; align-items: center; }
|
|
|
+.formula-editor__hint-text { color: #64748b; }
|
|
|
+.formula-editor__suggest {
|
|
|
+ position: absolute;
|
|
|
+ left: 0;
|
|
|
+ top: calc(100% + 4px);
|
|
|
+ width: 420px;
|
|
|
+ max-height: 280px;
|
|
|
+ overflow-y: auto;
|
|
|
+ background: #ffffff;
|
|
|
+ border: 1px solid #cbd5e1;
|
|
|
+ border-radius: 6px;
|
|
|
+ box-shadow: 0 8px 20px rgba(15, 23, 42, 0.15);
|
|
|
+ z-index: 10;
|
|
|
+}
|
|
|
+.formula-editor__suggest-header {
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-bottom: 1px solid #e2e8f0;
|
|
|
+ font-size: 12px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #334155;
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+}
|
|
|
+.formula-editor__suggest-filter { color: #94a3b8; font-weight: normal; }
|
|
|
+.formula-editor__suggest-item {
|
|
|
+ padding: 6px 12px;
|
|
|
+ cursor: pointer;
|
|
|
+ display: flex;
|
|
|
+ gap: 10px;
|
|
|
+ align-items: center;
|
|
|
+ font-size: 12px;
|
|
|
+}
|
|
|
+.formula-editor__suggest-item:hover,
|
|
|
+.formula-editor__suggest-item.active { background: #eef2ff; }
|
|
|
+.formula-editor__suggest-code { font-family: 'JetBrains Mono', Consolas, monospace; color: #2563eb; min-width: 120px; }
|
|
|
+.formula-editor__suggest-name { flex: 1; color: #1f2937; }
|
|
|
+.formula-editor__suggest-module { color: #64748b; font-size: 11px; }
|
|
|
+.formula-editor__suggest-empty { padding: 16px; text-align: center; color: #94a3b8; font-size: 12px; }
|
|
|
+.formula-editor__preview {
|
|
|
+ margin-top: 6px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ background: #f0f9ff;
|
|
|
+ border-left: 3px solid #3b82f6;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #1e40af;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+.formula-editor__preview-label { font-weight: 600; margin-right: 6px; }
|
|
|
+.formula-editor__error {
|
|
|
+ margin-top: 4px;
|
|
|
+ padding: 6px 10px;
|
|
|
+ background: #fef2f2;
|
|
|
+ border-left: 3px solid #ef4444;
|
|
|
+ color: #b91c1c;
|
|
|
+ font-size: 12px;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+.formula-editor__warning {
|
|
|
+ margin-top: 4px;
|
|
|
+ padding: 6px 10px;
|
|
|
+ background: #fffbeb;
|
|
|
+ border-left: 3px solid #f59e0b;
|
|
|
+ color: #92400e;
|
|
|
+ font-size: 12px;
|
|
|
+ border-radius: 4px;
|
|
|
+}
|
|
|
+</style>
|