feat: 完成 issue #167 [B1] 驾驶舱重构(上)——布局+四状态工艺+KPI 卡片+告警面板(模板 JSON 驱动)
This commit is contained in:
+86
@@ -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>
|
||||
+82
@@ -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>
|
||||
+109
@@ -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>
|
||||
Reference in New Issue
Block a user