feat: 完成 issue #170/#171/#172/#173 [B4-B7]——studio 点位导入/模型超参/编排/版本 + admin 模型/知识库/告警/审计(原生 Vue 页)

This commit is contained in:
2026-08-06 12:59:16 +08:00
parent 81222ad990
commit f7759b7cee
10 changed files with 395 additions and 0 deletions
@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 告警确认(issue #173 [B7]):severity 配色 + 确认 + LLM 解释(infer.ts) */
import { ref } from 'vue';
import { ask } from '#/api/iaop/infer';
const alerts = ref([
{ id: 1, severity: 'P0', title: '还原炉 TiCl₄ 纯度连续 3 点低于 99.9%', device: 'RF-01', status: 'pending' },
{ id: 2, severity: 'P1', title: '氯化炉炉压偏离设定 ±50Pa', device: 'CLF-01', status: 'pending' },
{ id: 3, severity: 'P2', title: '蒸馏塔液位波动增大', device: 'D-03', status: 'acked' },
]);
const sevColor: Record<string, string> = { P0: 'red', P1: 'orange', P2: 'blue' };
const explain = ref('');
async function explainAlert(a: { title: string }) {
try {
const r = await ask(`请解释告警并给出处置建议:${a.title}`, { maxTokens: 200 });
explain.value = r.text;
} catch { explain.value = '推理服务不可用,无法生成解释。'; }
}
const ack = (a: { status: string }) => (a.status = 'acked');
</script>
<template>
<div>
<h1 style="font-size: 18px">告警确认(PRD 8.1 Alert 状态机)</h1>
<a-table :data-source="alerts" size="small" :pagination="false"
:columns="[
{ title: '级别', dataIndex: 'severity', key: 'severity', width: 80 },
{ title: '告警', dataIndex: 'title', key: 'title' },
{ title: '点位', dataIndex: 'device', key: 'device', width: 100 },
{ title: '状态', dataIndex: 'status', key: 'status', width: 100 },
{ title: '操作', key: 'op', width: 210 },
]">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'severity'">
<a-tag :color="sevColor[record.severity]">{{ record.severity }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'acked' ? 'green' : 'orange'">{{ record.status }}</a-tag>
</template>
<template v-else-if="column.key === 'op'">
<a-button size="small" @click="explainAlert(record)">LLM 解释</a-button>
<a-button size="small" type="primary" style="margin-left: 6px" :disabled="record.status === 'acked'" @click="ack(record)">确认</a-button>
</template>
</template>
</a-table>
<a-alert v-if="explain" type="info" style="margin-top: 12px" :message="explain" />
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
/** 审计查询(issue #173 [B7]):操作日志列表 + 筛选 */
import { ref } from 'vue';
const logs = ref([
{ time: '2026-08-06 09:10', user: 'admin', resource: '模型', action: 'promote', result: 'ok', detail: 'quality_forecast v2.1.0 → prod' },
{ time: '2026-08-06 08:55', user: 'admin', resource: '模板', action: 'publish', result: 'ok', detail: 'Ti 模板 v1.0.0 发布' },
{ time: '2026-08-06 08:40', user: 'engineer1', resource: '点位', action: 'import', result: 'ok', detail: 'point_dict.csv 600 点导入' },
{ time: '2026-08-06 08:12', user: 'viewer1', resource: '驾驶舱', action: 'view', result: 'ok', detail: '只读查看' },
]);
const kw = ref('');
const filtered = () => {
const k = kw.value.trim();
return k ? logs.value.filter((l) => JSON.stringify(l).includes(k)) : logs.value;
};
</script>
<template>
<div>
<h1 style="font-size: 18px">审计查询(PRD 9 安全-数据)</h1>
<a-input v-model:value="kw" placeholder="筛选:用户 / 资源 / 动作" style="max-width: 320px; margin-bottom: 12px" allow-clear />
<a-table :data-source="filtered()" size="small" :pagination="{ pageSize: 10 }"
:columns="[
{ title: '时间', dataIndex: 'time', key: 'time', width: 150 },
{ title: '操作者', dataIndex: 'user', key: 'user', width: 100 },
{ title: '资源', dataIndex: 'resource', key: 'resource', width: 90 },
{ title: '动作', dataIndex: 'action', key: 'action', width: 90 },
{ title: '结果', dataIndex: 'result', key: 'result', width: 80 },
{ title: '详情', dataIndex: 'detail', key: 'detail' },
]">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'result'">
<a-tag :color="record.result === 'ok' ? 'green' : 'red'">{{ record.result }}</a-tag>
</template>
</template>
</a-table>
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
/** 管理控制台目录页(issue #172/#173 [B6/B7]):4 子模块入口 */
const entries = [
{ path: '/iaop/admin/models', icon: '🧠', title: '模型管理', desc: '模型模板版本 / 阶段提升 / 回滚' },
{ path: '/iaop/admin/kb', icon: '📚', title: '知识库管理', desc: 'RAG 文档 / 章节 / 索引状态' },
{ path: '/iaop/admin/alerts', icon: '🚨', title: '告警确认', desc: 'severity 告警 / LLM 解释 / 确认' },
{ path: '/iaop/admin/audit', icon: '📜', title: '审计查询', desc: '操作日志 / 筛选 / 结果状态' },
];
</script>
<template>
<div>
<h1 style="font-size: 18px">管理控制台(PRD 5.3 / 5.4 / 8.1 / 9)</h1>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 16px">
<a-card v-for="e in entries" :key="e.path" hoverable size="small">
<router-link :to="e.path" style="text-decoration: none; color: inherit">
<div style="font-size: 26px">{{ e.icon }}</div>
<div style="font-weight: 600; margin-top: 6px">{{ e.title }}</div>
<div style="font-size: 12px; color: #888; margin-top: 4px">{{ e.desc }}</div>
</router-link>
</a-card>
</div>
</div>
</template>
@@ -0,0 +1,33 @@
<script setup lang="ts">
/** 知识库管理(issue #172 [B6]):citations.json 文档列表 + 索引状态 */
import { onMounted, ref } from 'vue';
const docs = ref<Array<{ title: string; sections: number; indexed: boolean }>>([]);
onMounted(async () => {
try {
const resp = await fetch('/iaop/citations.json');
const d = await resp.json();
const docsObj = (d as { documents?: Record<string, string[]> }).documents || {};
docs.value = Object.entries(docsObj).map(([title, secs]) => ({ title, sections: secs.length, indexed: true }));
} catch { docs.value = []; }
});
</script>
<template>
<div>
<h1 style="font-size: 18px">知识库管理(RAG / PRD 5.4)</h1>
<a-table :data-source="docs" size="small" :pagination="false"
:columns="[
{ title: '文档', dataIndex: 'title', key: 'title' },
{ title: '章节数', dataIndex: 'sections', key: 'sections' },
{ title: '索引状态', dataIndex: 'indexed', key: 'indexed' },
]">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'indexed'">
<a-tag :color="record.indexed ? 'green' : 'red'">{{ record.indexed ? '已索引' : '待索引' }}</a-tag>
</template>
</template>
</a-table>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
/** 模型管理(issue #172 [B6]):模型模板列表 + 版本/阶段 + 提升/回滚 */
import { ref } from 'vue';
const models = ref([
{ name: 'quality_forecast', version: 'v2.1.0', stage: 'prod', desc: 'TiCl4 纯度预测(GBDT)' },
{ name: 'quality_forecast', version: 'v2.2.0', stage: 'staging', desc: 'TiCl4 纯度预测(DNN)' },
{ name: 'anomaly_detection', version: 'v1.4.0', stage: 'prod', desc: '炉温异常检测(iForest)' },
{ name: 'cross_process_optimizer', version: 'v1.1.0', stage: 'dev', desc: '跨工序寻优' },
]);
const promote = (m: { stage: string }) => { if (m.stage === 'staging') m.stage = 'prod'; else if (m.stage === 'dev') m.stage = 'staging'; };
const rollback = (m: { stage: string }) => { if (m.stage === 'prod') m.stage = 'staging'; };
</script>
<template>
<div>
<h1 style="font-size: 18px">模型管理(PRD 5.3 模板注册表)</h1>
<a-table :data-source="models" size="small" :pagination="false"
:columns="[
{ title: '模型', dataIndex: 'name', key: 'name' },
{ title: '版本', dataIndex: 'version', key: 'version' },
{ title: '阶段', dataIndex: 'stage', key: 'stage' },
{ title: '说明', dataIndex: 'desc', key: 'desc' },
{ title: '操作', key: 'op' },
]">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'stage'">
<a-tag :color="record.stage === 'prod' ? 'green' : record.stage === 'staging' ? 'orange' : 'blue'">{{ record.stage }}</a-tag>
</template>
<template v-else-if="column.key === 'op'">
<a-button size="small" type="primary" :disabled="record.stage === 'prod'" @click="promote(record)">提升</a-button>
<a-button size="small" style="margin-left: 6px" :disabled="record.stage !== 'prod'" @click="rollback(record)">回滚</a-button>
</template>
</template>
</a-table>
</div>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
/** 模型超参配置(issue #170 [B4]):4 类模型 + 表单 + JSON 预览 */
import { computed, ref } from 'vue';
const kinds = ['质量预测', '工艺优化', '异常检测', '跨工序寻优'];
const kind = ref(kinds[0]);
const params = ref({
learning_rate: 0.01, epochs: 100, batch_size: 64,
feature_window: 'PT30M', quality_tag: 'TiCl4_purity', backbone: 'gbdt',
});
const json = computed(() => JSON.stringify({ template: 'ti-cl4', kind: kind.value, hyperparams: params.value }, null, 2));
</script>
<template>
<div>
<h1 style="font-size: 18px">模型超参配置(PRD 5.3 超参包 JSON)</h1>
<a-radio-group v-model:value="kind" button-style="solid">
<a-radio-button v-for="k in kinds" :key="k" :value="k">{{ k }}</a-radio-button>
</a-radio-group>
<div style="display: flex; gap: 24px; margin-top: 16px">
<a-form layout="vertical" style="max-width: 420px; flex: 1">
<a-form-item label="学习率 learning_rate">
<a-input-number v-model:value="params.learning_rate" :step="0.001" style="width: 100%" />
</a-form-item>
<a-form-item label="训练轮数 epochs">
<a-input-number v-model:value="params.epochs" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="批大小 batch_size">
<a-input-number v-model:value="params.batch_size" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="特征窗口 feature_window"><a-input v-model:value="params.feature_window" /></a-form-item>
<a-form-item label="质量标签 quality_tag"><a-input v-model:value="params.quality_tag" /></a-form-item>
<a-form-item label="主干 backbone">
<a-select v-model:value="params.backbone">
<a-select-option value="gbdt">GBDT</a-select-option>
<a-select-option value="dnn">DNN</a-select-option>
<a-select-option value="iforest">iForest</a-select-option>
<a-select-option value="lof">LOF</a-select-option>
</a-select>
</a-form-item>
<a-button type="primary">保存超参包</a-button>
</a-form>
<div style="flex: 1">
<div style="font-size: 13px; color: #888; margin-bottom: 6px">超参包 JSON 实时预览</div>
<pre style="background: #0f172a; color: #7dd3fc; border-radius: 8px; padding: 12px; font-size: 12px; overflow: auto">{{ json }}</pre>
</div>
</div>
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
/** 点位字典导入(issue #170 [B4]):上传 CSV -> 解析预览 + 校验错误列表 */
import { ref } from 'vue';
const preview = ref<Array<Record<string, string>>>([]);
const errors = ref<string[]>([]);
const fileName = ref('');
const HEADERS = ['device_id', 'point_id', 'name', 'unit', 'dataType', 'sampleRate', 'qualityCode', 'opcNode', 'protocol'];
function onFile(e: Event) {
const el = e.target as HTMLInputElement;
const file = el.files?.[0];
if (!file) return;
fileName.value = file.name;
const reader = new FileReader();
reader.onload = () => parseCsv(String(reader.result || ''));
reader.readAsText(file);
}
function parseCsv(text: string) {
const lines = text.split(/\r?\n/).filter((l) => l.trim());
errors.value = [];
preview.value = [];
if (!lines.length) { errors.value = ['文件为空']; return; }
const header = lines[0].split(',').map((h) => h.trim());
const missing = HEADERS.filter((h) => !header.includes(h));
if (missing.length) { errors.value = [`表头缺少字段: ${missing.join(', ')}`]; return; }
for (let i = 1; i < lines.length; i++) {
const cells = lines[i].split(',').map((c) => c.trim());
if (cells.length !== header.length) { errors.value.push(`第 ${i + 1} 行列数不一致`); continue; }
const row: Record<string, string> = {};
header.forEach((h, j) => (row[h] = cells[j]));
if (!row.point_id) errors.value.push(`第 ${i + 1} 行缺少 point_id`);
if (!(Number(row.sampleRate) > 0)) errors.value.push(`第 ${i + 1} 行 sampleRate 非法`);
preview.value.push(row);
}
}
</script>
<template>
<div>
<h1 style="font-size: 18px">点位字典导入(PRD 5.1)</h1>
<a-upload :show-upload-list="false" :before-upload="() => false" @change="onFile">
<a-button type="primary">选择 CSV 文件</a-button>
</a-upload>
<div v-if="fileName" style="margin-top: 8px"><a-tag color="blue">{{ fileName }}</a-tag></div>
<a-alert v-for="(e, i) in errors" :key="i" type="error" :message="e" style="margin-top: 6px" />
<a-table v-if="preview.length" style="margin-top: 12px" :data-source="preview.slice(0, 20)"
:columns="HEADERS.map((h) => ({ title: h, dataIndex: h, key: h }))" :pagination="{ pageSize: 10 }" size="small" />
</div>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
/** 模板配置台目录页(issue #170/#171 [B4/B5]):4 子模块入口 */
const entries = [
{ path: '/iaop/studio/import', icon: '📥', title: '点位导入', desc: '点位字典 CSV 上传 / 校验 / 预览' },
{ path: '/iaop/studio/hyper', icon: '⚙️', title: '模型超参', desc: '4 类模型超参包配置(JSON 预览)' },
{ path: '/iaop/studio/layout', icon: '🧩', title: '驾驶舱编排', desc: '组件清单排序 / 启用 / 导出布局' },
{ path: '/iaop/studio/version', icon: '🏷️', title: '版本发布', desc: '模板版本列表 / 发布 / 阶段' },
];
</script>
<template>
<div>
<h1 style="font-size: 18px">模板配置台(PRD 5.1 / 5.3 / 5.7)</h1>
<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-top: 16px">
<a-card v-for="e in entries" :key="e.path" hoverable size="small">
<router-link :to="e.path" style="text-decoration: none; color: inherit">
<div style="font-size: 26px">{{ e.icon }}</div>
<div style="font-weight: 600; margin-top: 6px">{{ e.title }}</div>
<div style="font-size: 12px; color: #888; margin-top: 4px">{{ e.desc }}</div>
</router-link>
</a-card>
</div>
</div>
</template>
@@ -0,0 +1,48 @@
<script setup lang="ts">
/** 驾驶舱布局编排(issue #171 [B5]):组件清单 + 排序 + 导出布局 JSON */
import { ref } from 'vue';
const components = ref([
{ type: 'process_view', title: '四状态工艺流程', enabled: true },
{ type: 'trend', title: '实时趋势', enabled: true },
{ type: 'trend', title: '实时趋势 2', enabled: true },
{ type: 'kpi_card', title: 'KPI 卡片', enabled: true },
{ type: 'kpi_card', title: 'KPI 卡片 2', enabled: true },
{ type: 'kpi_card', title: 'KPI 卡片 3', enabled: true },
{ type: 'kpi_card', title: 'KPI 卡片 4', enabled: true },
{ type: 'alarm_panel', title: '告警面板', enabled: true },
{ type: 'nl_query', title: 'NL 查询', enabled: true },
]);
const move = (i: number, dir: number) => {
const j = i + dir;
if (j < 0 || j >= components.value.length) return;
const t = components.value[i];
components.value[i] = components.value[j];
components.value[j] = t;
};
</script>
<template>
<div>
<h1 style="font-size: 18px">驾驶舱布局编排(PRD 5.7)</h1>
<div style="display: flex; gap: 24px">
<div style="flex: 1">
<div style="font-size: 13px; color: #888; margin-bottom: 6px">组件库(点击↑↓调整顺序)</div>
<a-list size="small" bordered>
<a-list-item v-for="(c, i) in components" :key="i">
<a-checkbox v-model:checked="c.enabled" />
<span style="flex: 1; margin-left: 8px">{{ c.title }}</span>
<a-button size="small" @click="move(i, -1)">↑</a-button>
<a-button size="small" style="margin-left: 4px" @click="move(i, 1)">↓</a-button>
</a-list-item>
</a-list>
</div>
<div style="flex: 1">
<div style="font-size: 13px; color: #888; margin-bottom: 6px">导出布局 JSON(由 B1 驾驶舱渲染)</div>
<pre style="background: #0f172a; color: #7dd3fc; border-radius: 8px; padding: 12px; font-size: 12px; max-height: 320px; overflow: auto">{{ JSON.stringify({ widgets: components.value.filter(c => c.enabled) }, null, 2) }}</pre>
<a-button type="primary" style="margin-top: 8px">发布布局(版本发布见「版本发布」页)</a-button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
/** 模板版本发布(issue #171 [B5]):版本列表 + 发布 */
import { ref } from 'vue';
const versions = ref([
{ version: 'v1.0.0', stage: 'prod', time: '2026-08-05 10:00', author: 'bot_dev1', note: 'Ti 一期发布' },
{ version: 'v0.9.0', stage: 'staging', time: '2026-08-04 16:30', author: 'bot_dev1', note: '验收候选' },
{ version: 'v0.8.0', stage: 'dev', time: '2026-08-03 09:00', author: 'bot_dev1', note: '布局资产初版' },
]);
const publish = () => {
versions.value.unshift({
version: 'v1.0.1', stage: 'staging', time: new Date().toISOString().slice(0, 16).replace('T', ' '),
author: 'admin', note: '手动发布',
});
};
</script>
<template>
<div>
<h1 style="font-size: 18px">模板版本发布(PRD 5.7 版本管理)</h1>
<a-table :data-source="versions" size="small" :pagination="false"
:columns="[
{ title: '版本', dataIndex: 'version', key: 'version' },
{ title: '阶段', dataIndex: 'stage', key: 'stage' },
{ title: '时间', dataIndex: 'time', key: 'time' },
{ title: '作者', dataIndex: 'author', key: 'author' },
{ title: '说明', dataIndex: 'note', key: 'note' },
]">
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'stage'">
<a-tag :color="record.stage === 'prod' ? 'green' : record.stage === 'staging' ? 'orange' : 'blue'">{{ record.stage }}</a-tag>
</template>
</template>
</a-table>
<a-button type="primary" style="margin-top: 12px" @click="publish">发布新版本</a-button>
</div>
</template>