feat: 完成 issue #168 [B2] 驾驶舱重构(下)——实时趋势(SVG 折线 2s 滚动)+ NL 查询(infer.ts)
This commit is contained in:
+75
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* NL 查询(issue #168 [B2])
|
||||||
|
* 驾驶舱内联 NL 查询:输入 → infer.ts ask()(/v1/chat/completions)→ 展示回答。
|
||||||
|
* 网关离线(dry-run 占位/失败)给出降级提示。
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { ask } from '#/api/iaop/infer';
|
||||||
|
|
||||||
|
const question = ref('');
|
||||||
|
const answer = ref('');
|
||||||
|
const loading = ref(false);
|
||||||
|
const offline = ref(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
const q = question.value.trim();
|
||||||
|
if (!q || loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
answer.value = '';
|
||||||
|
offline.value = false;
|
||||||
|
try {
|
||||||
|
const r = await ask(q, { maxTokens: 200 });
|
||||||
|
answer.value = r.text;
|
||||||
|
if (r.meta && (r.meta as Record<string, unknown>).dry_run) {
|
||||||
|
offline.value = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
answer.value = '';
|
||||||
|
offline.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="nl-query">
|
||||||
|
<div class="w-title">工艺问答(NL 查询)</div>
|
||||||
|
<div class="nl-row">
|
||||||
|
<a-input
|
||||||
|
v-model:value="question"
|
||||||
|
placeholder="如:炉温异常如何处理?"
|
||||||
|
@press-enter="submit"
|
||||||
|
/>
|
||||||
|
<a-button type="primary" :loading="loading" @click="submit">查询</a-button>
|
||||||
|
</div>
|
||||||
|
<a-alert v-if="offline" type="warning" show-icon style="margin-top: 8px">
|
||||||
|
<template #message>推理服务离线 / dry-run 占位:请检查后端推理服务(/v1/health)</template>
|
||||||
|
</a-alert>
|
||||||
|
<div v-if="answer" class="nl-answer">{{ answer }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.w-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.nl-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.nl-answer {
|
||||||
|
margin-top: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
max-height: 180px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 实时趋势图(issue #168 [B2])
|
||||||
|
* 原生 SVG 折线(零依赖,对齐旧 cockpit.js canvas 曲线):
|
||||||
|
* props.series 点位名(如 CLF-01.TEMP),种子数据 + 2s 滚动刷新,
|
||||||
|
* 接总线后替换为实时点位数据源。
|
||||||
|
*/
|
||||||
|
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const series = String(props.props?.series || 'unknown.series');
|
||||||
|
const points = ref<number[]>([]);
|
||||||
|
const maxPoints = 40;
|
||||||
|
|
||||||
|
let timer = 0;
|
||||||
|
let seed = 100;
|
||||||
|
|
||||||
|
function next(): number {
|
||||||
|
seed = Math.max(20, Math.min(200, seed + (Math.random() - 0.5) * 10));
|
||||||
|
return Math.round(seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
points.value = [...points.value.slice(-(maxPoints - 1)), next()];
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 320;
|
||||||
|
const H = 90;
|
||||||
|
|
||||||
|
const polyline = ref('');
|
||||||
|
|
||||||
|
function buildPath(vals: number[]): string {
|
||||||
|
if (vals.length < 2) return '';
|
||||||
|
const min = Math.min(...vals) - 5;
|
||||||
|
const max = Math.max(...vals) + 5;
|
||||||
|
const range = max - min || 1;
|
||||||
|
return vals
|
||||||
|
.map((v, i) => {
|
||||||
|
const x = (i / (maxPoints - 1)) * W;
|
||||||
|
const y = H - ((v - min) / range) * H;
|
||||||
|
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(points, (v) => {
|
||||||
|
polyline.value = buildPath(v);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
for (let i = 0; i < 10; i++) tick();
|
||||||
|
timer = window.setInterval(tick, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => window.clearInterval(timer));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="trend-chart">
|
||||||
|
<div class="w-title">{{ props?.title || `实时趋势(${series})` }}</div>
|
||||||
|
<svg :width="W" :height="H" viewBox="0 0 320 90" preserveAspectRatio="none">
|
||||||
|
<polyline :points="polyline" fill="none" stroke="var(--cockpit-accent, #1677ff)" stroke-width="2" />
|
||||||
|
<text x="4" y="12" font-size="11" fill="var(--cockpit-fg-muted, #999)">
|
||||||
|
bind: {{ series }} · 2s 滚动
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.w-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.trend-chart svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 90px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,7 +11,9 @@ import { loadCockpitPlan } from '#/api/iaop/templates';
|
|||||||
|
|
||||||
import AlarmPanel from './components/AlarmPanel.vue';
|
import AlarmPanel from './components/AlarmPanel.vue';
|
||||||
import KpiCard from './components/KpiCard.vue';
|
import KpiCard from './components/KpiCard.vue';
|
||||||
|
import NlQuery from './components/NlQuery.vue';
|
||||||
import ProcessFlow from './components/ProcessFlow.vue';
|
import ProcessFlow from './components/ProcessFlow.vue';
|
||||||
|
import TrendChart from './components/TrendChart.vue';
|
||||||
|
|
||||||
interface PlanWidget {
|
interface PlanWidget {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -87,12 +89,14 @@ async function refreshHealth() {
|
|||||||
<ProcessFlow v-if="w.type === 'process_view'" :props="w.props" />
|
<ProcessFlow v-if="w.type === 'process_view'" :props="w.props" />
|
||||||
<KpiCard v-else-if="w.type === 'kpi_card'" :props="w.props" />
|
<KpiCard v-else-if="w.type === 'kpi_card'" :props="w.props" />
|
||||||
<AlarmPanel v-else-if="w.type === 'alarm_panel'" :props="w.props" />
|
<AlarmPanel v-else-if="w.type === 'alarm_panel'" :props="w.props" />
|
||||||
|
<TrendChart v-else-if="w.type === 'trend'" :props="w.props" />
|
||||||
|
<NlQuery v-else-if="w.type === 'nl_query'" :props="w.props" />
|
||||||
<a-card
|
<a-card
|
||||||
v-else
|
v-else
|
||||||
size="small"
|
size="small"
|
||||||
:title="w.props?.title || w.description || '趋势 / NL 查询(B2 交付)'"
|
:title="w.props?.title || w.description || '未知组件'"
|
||||||
>
|
>
|
||||||
<a-empty description="待 B2(实时趋势 / NL 查询)" />
|
<a-empty description="未知 widget 类型" />
|
||||||
</a-card>
|
</a-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user