feat: 完成 issue #168 [B2] 驾驶舱重构(下)——实时趋势(SVG 折线 2s 滚动)+ NL 查询(infer.ts)

This commit is contained in:
2026-08-06 12:56:46 +08:00
parent febdd5b917
commit df6776caba
3 changed files with 164 additions and 2 deletions
@@ -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>
@@ -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 KpiCard from './components/KpiCard.vue';
import NlQuery from './components/NlQuery.vue';
import ProcessFlow from './components/ProcessFlow.vue';
import TrendChart from './components/TrendChart.vue';
interface PlanWidget {
type: string;
@@ -87,12 +89,14 @@ async function refreshHealth() {
<ProcessFlow v-if="w.type === 'process_view'" :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" />
<TrendChart v-else-if="w.type === 'trend'" :props="w.props" />
<NlQuery v-else-if="w.type === 'nl_query'" :props="w.props" />
<a-card
v-else
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>
</div>
</template>