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