feat: 完成 issue #167 [B1] 驾驶舱重构(上)——布局+四状态工艺+KPI 卡片+告警面板(模板 JSON 驱动)

This commit is contained in:
2026-08-06 12:56:01 +08:00
parent 7279c9f71f
commit febdd5b917
4 changed files with 414 additions and 0 deletions
@@ -0,0 +1,86 @@
<script setup lang="ts">
/**
* 告警面板(issue #167 [B1])
* 对齐旧 cockpit.js AlarmPanel:alarm_panel.ti.json 驱动
* (severity 配色 / SOP / 确认),一期演示数据 + 确认流转(本地状态)。
*/
import { computed, onMounted, ref } from 'vue';
import { loadAlarmPanel, type AlarmPanelConfig } from '#/api/iaop/templates';
const props = defineProps<{
props?: Record<string, unknown>;
}>();
const config = ref<AlarmPanelConfig>({});
const alerts = ref<Array<{ id: number; severity: string; title: string; device: string; time: string; acked: boolean }>>([
{ id: 1, severity: 'P0', title: '还原炉 TiCl₄ 纯度连续 3 点低于 99.9%', device: 'RF-01', time: '08:42', acked: false },
{ id: 2, severity: 'P1', title: '氯化炉炉压偏离设定 ±50Pa', device: 'CLF-01', time: '08:35', acked: false },
{ id: 3, severity: 'P2', title: '蒸馏塔液位波动增大', device: 'D-03', time: '08:12', acked: false },
]);
const severityColors = computed<Record<string, string>>(
() => (config.value.severityStyles as Record<string, Record<string, string>>) || {},
);
const colorOf = (sev: string): string => {
const c = severityColors.value[sev];
return (c && (c.color || c.background)) || (sev === 'P0' ? '#ff4d4f' : sev === 'P1' ? '#faad14' : '#1677ff');
};
async function ack(id: number) {
const target = alerts.value.find((a) => a.id === id);
if (target) target.acked = true;
}
onMounted(async () => {
try {
config.value = await loadAlarmPanel();
} catch {
config.value = {};
}
});
</script>
<template>
<div class="alarm-panel">
<div class="w-title">告警面板{{ props?.subscribe ? '(' + props.subscribe + ')' : '' }}</div>
<div class="alarm-list">
<div v-for="a in alerts" :key="a.id" class="alarm-item">
<span class="sev" :style="{ background: colorOf(a.severity) }">{{ a.severity }}</span>
<span class="alarm-title">{{ a.title }} <small>{{ a.device }} · {{ a.time }}</small></span>
<a-button size="small" :disabled="a.acked" @click="ack(a.id)">
{{ a.acked ? '已确认' : '确认' }}
</a-button>
</div>
</div>
</div>
</template>
<style scoped>
.w-title {
font-weight: 600;
margin-bottom: 10px;
}
.alarm-item {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08);
}
.sev {
color: #fff;
border-radius: 4px;
font-size: 11px;
padding: 1px 6px;
font-weight: 600;
}
.alarm-title {
flex: 1;
font-size: 13px;
}
.alarm-title small {
color: var(--cockpit-fg-muted, rgba(0, 0, 0, 0.45));
margin-left: 6px;
}
</style>
@@ -0,0 +1,82 @@
<script setup lang="ts">
/**
* KPI 卡片(issue #167 [B1])
* 对齐旧 cockpit.js KpiCard:props.kpi {label, value, unit, thresholds},
* 按阈值着色(ok / warn / alarm)。
*/
import { computed } from 'vue';
const props = defineProps<{
props?: Record<string, unknown>;
}>();
interface KpiSpec {
label?: string;
value?: number | string;
unit?: string;
thresholds?: { ok?: number; warn?: number; alarm?: number };
}
const kpi = computed<KpiSpec>(() => (props.props?.kpi as KpiSpec) || {});
const level = computed<'ok' | 'warn' | 'alarm'>(() => {
const t = kpi.value.thresholds || {};
const v = Number(kpi.value.value);
if (!t || Number.isNaN(v)) return 'ok';
if (t.alarm !== undefined && v >= t.alarm) return 'alarm';
if (t.warn !== undefined && v >= t.warn) return 'warn';
return 'ok';
});
</script>
<template>
<div class="kpi-card" :class="'kpi-' + level">
<div class="kpi-label">{{ kpi.label || '指标' }}</div>
<div class="kpi-value">
{{ kpi.value ?? '--' }}<span v-if="kpi.unit" class="kpi-unit">{{ kpi.unit }}</span>
</div>
<div class="kpi-level">{{ level }}</div>
</div>
</template>
<style scoped>
.kpi-card {
border-radius: 8px;
padding: 12px 14px;
border: 1px solid;
}
.kpi-label {
font-size: 13px;
color: var(--cockpit-fg-muted, rgba(0, 0, 0, 0.45));
}
.kpi-value {
font-size: 24px;
font-weight: 700;
margin: 6px 0 2px;
}
.kpi-unit {
font-size: 13px;
font-weight: 400;
margin-left: 4px;
color: var(--cockpit-fg-muted, rgba(0, 0, 0, 0.45));
}
.kpi-level {
font-size: 11px;
text-transform: uppercase;
}
.kpi-ok {
color: #1677ff;
border-color: rgba(22, 119, 255, 0.35);
background: rgba(22, 119, 255, 0.05);
}
.kpi-warn {
color: #faad14;
border-color: rgba(250, 173, 20, 0.5);
background: rgba(250, 173, 20, 0.08);
}
.kpi-alarm {
color: #ff4d4f;
border-color: rgba(255, 77, 79, 0.5);
background: rgba(255, 77, 79, 0.08);
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
/**
* 四状态工艺流程视图(issue #167 [B1])
* 对齐旧 cockpit.js ProcessView:stages 来自布局资产 props.stages,
* 状态色走 .state-* 四类(running/warning/alarm/offline),一期轮流着色。
*/
import { computed } from 'vue';
const props = defineProps<{
props?: Record<string, unknown>;
}>();
interface Stage {
id?: string;
name?: string;
device?: string;
order?: number;
}
const stages = computed<Stage[]>(() => {
const list = ((props.props?.stages as Stage[]) || []).slice().sort(
(a, b) => (a.order || 0) - (b.order || 0),
);
return list;
});
const stateList = ['running', 'warning', 'alarm', 'offline'];
const stageState = (idx: number): string => stateList[idx % stateList.length];
</script>
<template>
<div class="process-view">
<div class="w-title">{{ props?.src ? '四状态工艺流程(' + props.src + ')' : '四状态工艺流程' }}</div>
<div class="w-body">
<div class="process-flow">
<template v-if="stages.length">
<template v-for="(s, i) in stages" :key="i">
<div class="stage" :class="'state-' + stageState(i)">
<div class="stage-name">{{ s.name || s.id }}</div>
<div v-if="s.device" class="stage-device">{{ s.device }}</div>
<div class="stage-state">{{ stageState(i) }}</div>
</div>
<div v-if="i < stages.length - 1" class="stage-arrow">→</div>
</template>
</template>
<div v-else class="stage state-running">
{{ (props?.src as string) || '流程图资源' }}(流程节点由模板 stages 声明)
</div>
</div>
</div>
</div>
</template>
<style scoped>
.w-title {
font-weight: 600;
margin-bottom: 10px;
color: var(--cockpit-fg, rgba(0, 0, 0, 0.88));
}
.process-flow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.stage {
border-radius: 8px;
padding: 10px 16px;
min-width: 110px;
text-align: center;
border: 1px solid;
}
.stage-name {
font-weight: 600;
}
.stage-device {
font-size: 12px;
opacity: 0.75;
}
.stage-state {
font-size: 11px;
text-transform: uppercase;
margin-top: 4px;
}
.stage-arrow {
color: var(--cockpit-fg-muted, #999);
font-size: 18px;
}
.state-running {
color: #1677ff;
border-color: #1677ff;
background: rgba(22, 119, 255, 0.06);
}
.state-warning {
color: #faad14;
border-color: #faad14;
background: rgba(250, 173, 20, 0.08);
}
.state-alarm {
color: #ff4d4f;
border-color: #ff4d4f;
background: rgba(255, 77, 79, 0.08);
}
.state-offline {
color: rgba(0, 0, 0, 0.45);
border-color: rgba(0, 0, 0, 0.25);
background: rgba(0, 0, 0, 0.03);
}
</style>
@@ -0,0 +1,137 @@
<script setup lang="ts">
/**
* 驾驶舱主页面(Epic #163 / issue #167 [B1])
* 布局对齐旧 web/cockpit/cockpit.js:模板 JSON(RenderPlan)驱动网格渲染,
* widget 类型分发到子组件(process_view / kpi_card / alarm_panel;
* trend / nl_query 由 B2 提供,此处占位)。
*/
import { onMounted, onUnmounted, ref } from 'vue';
import { loadCockpitPlan } from '#/api/iaop/templates';
import AlarmPanel from './components/AlarmPanel.vue';
import KpiCard from './components/KpiCard.vue';
import ProcessFlow from './components/ProcessFlow.vue';
interface PlanWidget {
type: string;
grid?: { style?: string };
props?: Record<string, unknown>;
description?: string;
}
const planTitle = ref('海绵钛车间驾驶舱');
const themeTokens = ref<Record<string, string>>({});
const widgets = ref<PlanWidget[]>([]);
const loading = ref(true);
const errorMsg = ref('');
let timer = 0;
onMounted(async () => {
try {
const plan = await loadCockpitPlan('ti-cl4');
planTitle.value = plan.title || planTitle.value;
themeTokens.value = plan.themeTokens || {};
widgets.value = (plan.widgets as unknown as PlanWidget[]) || [];
// 主题 token 注入 CSS 变量(切模板 = 换一套变量)
const root = document.documentElement;
Object.entries(themeTokens.value).forEach(([k, v]) => root.style.setProperty(k, v));
// 2s 轮询推理服务状态(对齐旧版徽标)
timer = window.setInterval(refreshHealth, 2000);
await refreshHealth();
} catch {
errorMsg.value = '驾驶舱模板加载失败,请检查 FBA UI 部署(/iaop/plans)';
} finally {
loading.value = false;
}
});
onUnmounted(() => window.clearInterval(timer));
const inferOnline = ref(false);
async function refreshHealth() {
try {
const resp = await fetch('/v1/health', { signal: AbortSignal.timeout(3000) });
const d = await resp.json();
inferOnline.value = d.status === 'ok' || d.status === 'dry-run';
} catch {
inferOnline.value = false;
}
}
</script>
<template>
<div class="cockpit-page">
<div class="cockpit-header">
<h1>{{ planTitle }}</h1>
<div class="cockpit-meta">
<a-tag :color="inferOnline ? 'green' : 'red'">
{{ inferOnline ? '● 推理服务在线' : '○ 推理服务离线' }}
</a-tag>
<a-tag>行业模板:海绵钛(Ti)</a-tag>
</div>
</div>
<a-spin :spinning="loading">
<a-alert v-if="errorMsg" type="error" :message="errorMsg" style="margin-bottom: 12px" />
<div
class="cockpit-grid"
:style="{ gridTemplateColumns: `repeat(${widgets[0]?.grid?.style ? '12' : '12'}, 1fr)` }"
>
<template v-for="(w, idx) in widgets" :key="idx">
<div
class="cockpit-widget"
:style="w.grid?.style || `grid-column: span 12`"
>
<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" />
<a-card
v-else
size="small"
:title="w.props?.title || w.description || '趋势 / NL 查询(B2 交付)'"
>
<a-empty description="待 B2(实时趋势 / NL 查询)" />
</a-card>
</div>
</template>
</div>
</a-spin>
</div>
</template>
<style scoped>
.cockpit-page {
padding: 8px;
background: var(--cockpit-bg, #f0f2f5);
min-height: calc(100vh - 140px);
}
.cockpit-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.cockpit-header h1 {
font-size: 20px;
margin: 0;
color: var(--cockpit-fg, rgba(0, 0, 0, 0.88));
}
.cockpit-meta {
display: flex;
gap: 8px;
}
.cockpit-grid {
display: grid;
grid-auto-rows: minmax(60px, auto);
gap: 12px;
}
.cockpit-widget {
min-width: 0;
background: var(--cockpit-surface, #fff);
border-radius: 8px;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
</style>