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 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>
|
||||
|
||||
Reference in New Issue
Block a user