feat: 完成 issue #178 [D1] 样式对齐(上)——驾驶舱+对话助手硬编码色改 vben 主题 token(54 处 hsl(var(--*))),Page 容器

This commit is contained in:
2026-08-06 14:02:00 +08:00
parent 36d8f7db2f
commit 9b44723b35
7 changed files with 110 additions and 167 deletions
@@ -1,14 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 对话助手(Epic #163 / issue #169 [B3]) * 对话助手(issue #178 [D1] 样式对齐)
* 对齐旧 web/chat/assistant.js: * 样式全部改 vben 主题 token(明暗主题自适应),Page 容器。
* - 消息列表 + 输入区(AntD)
* - infer.ts chat completions + 模型选择(/v1/models)
* - citations.json 引用来源展示(PRD 5.4 溯源)
* - 网关离线/dry-run 降级提示
*/ */
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { ask, fetchHealth, fetchModels } from '#/api/iaop/infer'; import { ask, fetchHealth, fetchModels } from '#/api/iaop/infer';
interface Msg { interface Msg {
@@ -64,7 +62,6 @@ async function send() {
const r = await ask(q, { model: model.value, maxTokens: 512 }); const r = await ask(q, { model: model.value, maxTokens: 512 });
const meta = (r.meta || {}) as Record<string, unknown>; const meta = (r.meta || {}) as Record<string, unknown>;
offline.value = Boolean(meta.dry_run); offline.value = Boolean(meta.dry_run);
// 引用溯源:按关键词在 citations 文档中检索(演示:命中即附来源)
const hits = Object.keys(citations.value).filter((k) => const hits = Object.keys(citations.value).filter((k) =>
q.includes(k) || k.split('·')[0].length > 0 && q.includes(k.split('·')[0]), q.includes(k) || k.split('·')[0].length > 0 && q.includes(k.split('·')[0]),
).slice(0, 3); ).slice(0, 3);
@@ -80,18 +77,15 @@ async function send() {
</script> </script>
<template> <template>
<div class="chat-page"> <Page title="iAOP 对话助手" description="工艺问答 · LLM 网关(PRD 5.4 溯源)" class="chat-page">
<div class="chat-head"> <template #extra>
<h1>iAOP 对话助手</h1>
<div class="chat-opts">
<a-select v-model:value="model" style="width: 220px" placeholder="模型"> <a-select v-model:value="model" style="width: 220px" placeholder="模型">
<a-select-option v-for="m in models" :key="m" :value="m">{{ m }}</a-select-option> <a-select-option v-for="m in models" :key="m" :value="m">{{ m }}</a-select-option>
</a-select> </a-select>
<a-tag :color="offline ? 'red' : 'green'"> <a-tag :color="offline ? 'red' : 'green'">
{{ offline ? '网关离线 / dry-run' : '网关在线' }} {{ offline ? '网关离线 / dry-run' : '网关在线' }}
</a-tag> </a-tag>
</div> </template>
</div>
<a-alert v-if="offline" type="warning" show-icon style="margin-bottom: 12px"> <a-alert v-if="offline" type="warning" show-icon style="margin-bottom: 12px">
<template #message>推理服务离线或 dry-run 占位:回答可能为占位内容(未连接上游推理服务)。</template> <template #message>推理服务离线或 dry-run 占位:回答可能为占位内容(未连接上游推理服务)。</template>
@@ -119,37 +113,20 @@ async function send() {
/> />
<a-button type="primary" :loading="loading" @click="send">发送</a-button> <a-button type="primary" :loading="loading" @click="send">发送</a-button>
</div> </div>
</div> </Page>
</template> </template>
<style scoped> <style scoped>
.chat-page { .chat-page {
max-width: 900px;
margin: 0 auto;
padding: 12px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 150px); height: calc(100vh - 170px);
}
.chat-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.chat-head h1 {
font-size: 18px;
margin: 0;
}
.chat-opts {
display: flex;
gap: 10px;
align-items: center;
} }
.chat-list { .chat-list {
flex: 1; flex: 1;
overflow: auto; overflow: auto;
background: rgba(0, 0, 0, 0.02); background: hsl(var(--muted) / 0.6);
border: 1px solid hsl(var(--border));
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
} }
@@ -166,16 +143,17 @@ async function send() {
border-radius: 10px; border-radius: 10px;
font-size: 14px; font-size: 14px;
line-height: 1.7; line-height: 1.7;
background: #fff; background: hsl(var(--card));
border: 1px solid rgba(0, 0, 0, 0.08); color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border));
} }
.role-user .bubble { .role-user .bubble {
background: #1677ff; background: hsl(var(--primary));
color: #fff; color: hsl(var(--primary-foreground));
border-color: transparent; border-color: transparent;
} }
.bubble-loading { .bubble-loading {
color: #999; color: hsl(var(--muted-foreground));
} }
.citations { .citations {
margin-top: 6px; margin-top: 6px;
@@ -183,8 +161,8 @@ async function send() {
.cit-tag { .cit-tag {
display: inline-block; display: inline-block;
font-size: 11px; font-size: 11px;
color: #1677ff; color: hsl(var(--primary));
background: rgba(22, 119, 255, 0.08); background: hsl(var(--primary) / 0.08);
border-radius: 10px; border-radius: 10px;
padding: 2px 8px; padding: 2px 8px;
margin-right: 6px; margin-right: 6px;
@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 告警面板(issue #167 [B1]) * 告警面板(issue #178 [D1] 样式对齐)
* 对齐旧 cockpit.js AlarmPanel:alarm_panel.ti.json 驱动 * severity 配色改 vben 主题 token:P0→destructive / P1→warning / P2→primary。
* (severity 配色 / SOP / 确认),一期演示数据 + 确认流转(本地状态)。
*/ */
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
@@ -19,12 +18,10 @@ const alerts = ref<Array<{ id: number; severity: string; title: string; device:
{ id: 3, severity: 'P2', title: '蒸馏塔液位波动增大', device: 'D-03', time: '08:12', 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 colorOf = (sev: string): string => {
const c = severityColors.value[sev]; if (sev === 'P0') return 'hsl(var(--destructive))';
return (c && (c.color || c.background)) || (sev === 'P0' ? '#ff4d4f' : sev === 'P1' ? '#faad14' : '#1677ff'); if (sev === 'P1') return 'hsl(var(--warning))';
return 'hsl(var(--primary))';
}; };
async function ack(id: number) { async function ack(id: number) {
@@ -60,16 +57,17 @@ onMounted(async () => {
.w-title { .w-title {
font-weight: 600; font-weight: 600;
margin-bottom: 10px; margin-bottom: 10px;
color: hsl(var(--card-foreground));
} }
.alarm-item { .alarm-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 6px 0; padding: 6px 0;
border-bottom: 1px dashed rgba(0, 0, 0, 0.08); border-bottom: 1px dashed hsl(var(--border));
} }
.sev { .sev {
color: #fff; color: hsl(var(--primary-foreground));
border-radius: 4px; border-radius: 4px;
font-size: 11px; font-size: 11px;
padding: 1px 6px; padding: 1px 6px;
@@ -80,7 +78,7 @@ onMounted(async () => {
font-size: 13px; font-size: 13px;
} }
.alarm-title small { .alarm-title small {
color: var(--cockpit-fg-muted, rgba(0, 0, 0, 0.45)); color: hsl(var(--muted-foreground));
margin-left: 6px; margin-left: 6px;
} }
</style> </style>
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* KPI 卡片(issue #167 [B1]) * KPI 卡片(issue #178 [D1] 样式对齐)
* 对齐旧 cockpit.js KpiCard:props.kpi {label, value, unit, thresholds}, * 阈值配色保留语义,色值取自 vben 主题 token:
* 按阈值着色(ok / warn / alarm)。 * ok→primary / warn→warning / alarm→destructive。
*/ */
import { computed } from 'vue'; import { computed } from 'vue';
@@ -47,7 +47,7 @@ const level = computed<'ok' | 'warn' | 'alarm'>(() => {
} }
.kpi-label { .kpi-label {
font-size: 13px; font-size: 13px;
color: var(--cockpit-fg-muted, rgba(0, 0, 0, 0.45)); color: hsl(var(--muted-foreground));
} }
.kpi-value { .kpi-value {
font-size: 24px; font-size: 24px;
@@ -58,25 +58,25 @@ const level = computed<'ok' | 'warn' | 'alarm'>(() => {
font-size: 13px; font-size: 13px;
font-weight: 400; font-weight: 400;
margin-left: 4px; margin-left: 4px;
color: var(--cockpit-fg-muted, rgba(0, 0, 0, 0.45)); color: hsl(var(--muted-foreground));
} }
.kpi-level { .kpi-level {
font-size: 11px; font-size: 11px;
text-transform: uppercase; text-transform: uppercase;
} }
.kpi-ok { .kpi-ok {
color: #1677ff; color: hsl(var(--primary));
border-color: rgba(22, 119, 255, 0.35); border-color: hsl(var(--primary) / 0.35);
background: rgba(22, 119, 255, 0.05); background: hsl(var(--primary) / 0.06);
} }
.kpi-warn { .kpi-warn {
color: #faad14; color: hsl(var(--warning));
border-color: rgba(250, 173, 20, 0.5); border-color: hsl(var(--warning) / 0.5);
background: rgba(250, 173, 20, 0.08); background: hsl(var(--warning) / 0.1);
} }
.kpi-alarm { .kpi-alarm {
color: #ff4d4f; color: hsl(var(--destructive));
border-color: rgba(255, 77, 79, 0.5); border-color: hsl(var(--destructive) / 0.5);
background: rgba(255, 77, 79, 0.08); background: hsl(var(--destructive) / 0.08);
} }
</style> </style>
@@ -1,8 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* NL 查询(issue #168 [B2]) * NL 查询(issue #178 [D1] 样式对齐)
* 驾驶舱内联 NL 查询:输入 → infer.ts ask()(/v1/chat/completions)→ 展示回答。 * 输入/回答区样式使用 vben 主题 token。
* 网关离线(dry-run 占位/失败)给出降级提示。
*/ */
import { ref } from 'vue'; import { ref } from 'vue';
@@ -57,6 +56,7 @@ async function submit() {
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px; font-size: 14px;
color: hsl(var(--card-foreground));
} }
.nl-row { .nl-row {
display: flex; display: flex;
@@ -64,7 +64,9 @@ async function submit() {
} }
.nl-answer { .nl-answer {
margin-top: 10px; margin-top: 10px;
background: rgba(0, 0, 0, 0.02); background: hsl(var(--muted));
color: hsl(var(--foreground));
border: 1px solid hsl(var(--border));
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
font-size: 13px; font-size: 13px;
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 四状态工艺流程视图(issue #167 [B1]) * 四状态工艺流程视图(issue #178 [D1] 样式对齐)
* 对齐旧 cockpit.js ProcessView:stages 来自布局资产 props.stages, * 状态色取自 vben 主题 token:running→primary / warning→warning /
* 状态色走 .state-* 四类(running/warning/alarm/offline),一期轮流着色。 * alarm→destructive / offline→muted,随明暗主题自适应。
*/ */
import { computed } from 'vue'; import { computed } from 'vue';
@@ -31,7 +31,6 @@ const stageState = (idx: number): string => stateList[idx % stateList.length];
<template> <template>
<div class="process-view"> <div class="process-view">
<div class="w-title">{{ props?.src ? '四状态工艺流程(' + props.src + ')' : '四状态工艺流程' }}</div> <div class="w-title">{{ props?.src ? '四状态工艺流程(' + props.src + ')' : '四状态工艺流程' }}</div>
<div class="w-body">
<div class="process-flow"> <div class="process-flow">
<template v-if="stages.length"> <template v-if="stages.length">
<template v-for="(s, i) in stages" :key="i"> <template v-for="(s, i) in stages" :key="i">
@@ -48,14 +47,13 @@ const stageState = (idx: number): string => stateList[idx % stateList.length];
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<style scoped> <style scoped>
.w-title { .w-title {
font-weight: 600; font-weight: 600;
margin-bottom: 10px; margin-bottom: 10px;
color: var(--cockpit-fg, rgba(0, 0, 0, 0.88)); color: hsl(var(--card-foreground));
} }
.process-flow { .process-flow {
display: flex; display: flex;
@@ -83,27 +81,27 @@ const stageState = (idx: number): string => stateList[idx % stateList.length];
margin-top: 4px; margin-top: 4px;
} }
.stage-arrow { .stage-arrow {
color: var(--cockpit-fg-muted, #999); color: hsl(var(--muted-foreground));
font-size: 18px; font-size: 18px;
} }
.state-running { .state-running {
color: #1677ff; color: hsl(var(--primary));
border-color: #1677ff; border-color: hsl(var(--primary) / 0.4);
background: rgba(22, 119, 255, 0.06); background: hsl(var(--primary) / 0.08);
} }
.state-warning { .state-warning {
color: #faad14; color: hsl(var(--warning));
border-color: #faad14; border-color: hsl(var(--warning) / 0.5);
background: rgba(250, 173, 20, 0.08); background: hsl(var(--warning) / 0.1);
} }
.state-alarm { .state-alarm {
color: #ff4d4f; color: hsl(var(--destructive));
border-color: #ff4d4f; border-color: hsl(var(--destructive) / 0.5);
background: rgba(255, 77, 79, 0.08); background: hsl(var(--destructive) / 0.08);
} }
.state-offline { .state-offline {
color: rgba(0, 0, 0, 0.45); color: hsl(var(--muted-foreground));
border-color: rgba(0, 0, 0, 0.25); border-color: hsl(var(--muted));
background: rgba(0, 0, 0, 0.03); background: hsl(var(--muted) / 0.5);
} }
</style> </style>
@@ -1,9 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 实时趋势图(issue #168 [B2]) * 实时趋势图(issue #178 [D1] 样式对齐)
* 原生 SVG 折线(零依赖,对齐旧 cockpit.js canvas 曲线): * 原生 SVG 折线(零依赖),线条/文字用 vben 主题 token。
* props.series 点位名(如 CLF-01.TEMP),种子数据 + 2s 滚动刷新,
* 接总线后替换为实时点位数据源。
*/ */
import { onMounted, onUnmounted, ref, watch } from 'vue'; import { onMounted, onUnmounted, ref, watch } from 'vue';
@@ -62,8 +60,8 @@ onUnmounted(() => window.clearInterval(timer));
<div class="trend-chart"> <div class="trend-chart">
<div class="w-title">{{ props?.title || `实时趋势(${series})` }}</div> <div class="w-title">{{ props?.title || `实时趋势(${series})` }}</div>
<svg :width="W" :height="H" viewBox="0 0 320 90" preserveAspectRatio="none"> <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" /> <polyline :points="polyline" fill="none" stroke="hsl(var(--primary))" stroke-width="2" />
<text x="4" y="12" font-size="11" fill="var(--cockpit-fg-muted, #999)"> <text x="4" y="12" font-size="11" fill="hsl(var(--muted-foreground))">
bind: {{ series }} · 2s 滚动 bind: {{ series }} · 2s 滚动
</text> </text>
</svg> </svg>
@@ -75,6 +73,7 @@ onUnmounted(() => window.clearInterval(timer));
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
font-size: 14px; font-size: 14px;
color: hsl(var(--card-foreground));
} }
.trend-chart svg { .trend-chart svg {
width: 100%; width: 100%;
@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 驾驶舱主页面(Epic #163 / issue #167 [B1]) * 驾驶舱主页面(issue #178 [D1] 样式对齐)
* 布局对齐旧 web/cockpit/cockpit.js:模板 JSON(RenderPlan)驱动网格渲染, * 模板 JSON(RenderPlan)驱动网格渲染,widget 类型分发到子组件。
* widget 类型分发到子组件(process_view / kpi_card / alarm_panel; * 样式全部使用 vben 主题 token(hsl(var(--*))),随明/暗主题自适应。
* trend / nl_query 由 B2 提供,此处占位)。
*/ */
import { onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { loadCockpitPlan } from '#/api/iaop/templates'; import { loadCockpitPlan } from '#/api/iaop/templates';
import AlarmPanel from './components/AlarmPanel.vue'; import AlarmPanel from './components/AlarmPanel.vue';
@@ -23,7 +24,6 @@ interface PlanWidget {
} }
const planTitle = ref('海绵钛车间驾驶舱'); const planTitle = ref('海绵钛车间驾驶舱');
const themeTokens = ref<Record<string, string>>({});
const widgets = ref<PlanWidget[]>([]); const widgets = ref<PlanWidget[]>([]);
const loading = ref(true); const loading = ref(true);
const errorMsg = ref(''); const errorMsg = ref('');
@@ -34,12 +34,12 @@ onMounted(async () => {
try { try {
const plan = await loadCockpitPlan('ti-cl4'); const plan = await loadCockpitPlan('ti-cl4');
planTitle.value = plan.title || planTitle.value; planTitle.value = plan.title || planTitle.value;
themeTokens.value = plan.themeTokens || {};
widgets.value = (plan.widgets as unknown as PlanWidget[]) || []; widgets.value = (plan.widgets as unknown as PlanWidget[]) || [];
// 主题 token 注入 CSS 变量(切模板 = 换一套变量) // 主题 token 注入 CSS 变量(切模板 = 换一套变量)
const root = document.documentElement; const root = document.documentElement;
Object.entries(themeTokens.value).forEach(([k, v]) => root.style.setProperty(k, v)); Object.entries((plan.themeTokens as Record<string, string>) || {}).forEach(([k, v]) =>
// 2s 轮询推理服务状态(对齐旧版徽标) root.style.setProperty(k, v),
);
timer = window.setInterval(refreshHealth, 2000); timer = window.setInterval(refreshHealth, 2000);
await refreshHealth(); await refreshHealth();
} catch { } catch {
@@ -64,68 +64,34 @@ async function refreshHealth() {
</script> </script>
<template> <template>
<div class="cockpit-page"> <Page :title="planTitle" description="海绵钛(Ti)行业模板 · 模板 JSON 驱动">
<div class="cockpit-header"> <template #extra>
<h1>{{ planTitle }}</h1>
<div class="cockpit-meta">
<a-tag :color="inferOnline ? 'green' : 'red'"> <a-tag :color="inferOnline ? 'green' : 'red'">
{{ inferOnline ? '● 推理服务在线' : '○ 推理服务离线' }} {{ inferOnline ? '● 推理服务在线' : '○ 推理服务离线' }}
</a-tag> </a-tag>
<a-tag>行业模板:海绵钛(Ti)</a-tag> </template>
</div>
</div>
<a-spin :spinning="loading"> <a-spin :spinning="loading">
<a-alert v-if="errorMsg" type="error" :message="errorMsg" style="margin-bottom: 12px" /> <a-alert v-if="errorMsg" type="error" :message="errorMsg" style="margin-bottom: 12px" />
<div <div class="cockpit-grid">
class="cockpit-grid"
:style="{ gridTemplateColumns: `repeat(${widgets[0]?.grid?.style ? '12' : '12'}, 1fr)` }"
>
<template v-for="(w, idx) in widgets" :key="idx"> <template v-for="(w, idx) in widgets" :key="idx">
<div <div class="cockpit-widget" :style="w.grid?.style || 'grid-column: span 12'">
class="cockpit-widget"
:style="w.grid?.style || `grid-column: span 12`"
>
<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" /> <TrendChart v-else-if="w.type === 'trend'" :props="w.props" />
<NlQuery v-else-if="w.type === 'nl_query'" :props="w.props" /> <NlQuery v-else-if="w.type === 'nl_query'" :props="w.props" />
<a-card <a-card v-else size="small" :title="w.props?.title || w.description || '未知组件'">
v-else
size="small"
:title="w.props?.title || w.description || '未知组件'"
>
<a-empty description="未知 widget 类型" /> <a-empty description="未知 widget 类型" />
</a-card> </a-card>
</div> </div>
</template> </template>
</div> </div>
</a-spin> </a-spin>
</div> </Page>
</template> </template>
<style scoped> <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 { .cockpit-grid {
display: grid; display: grid;
grid-auto-rows: minmax(60px, auto); grid-auto-rows: minmax(60px, auto);
@@ -133,9 +99,11 @@ async function refreshHealth() {
} }
.cockpit-widget { .cockpit-widget {
min-width: 0; min-width: 0;
background: var(--cockpit-surface, #fff); background: hsl(var(--card));
color: hsl(var(--card-foreground));
border: 1px solid hsl(var(--border));
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); box-shadow: 0 1px 2px hsl(var(--foreground) / 0.08);
} }
</style> </style>