重构 Diagnosis 页面并引入测试与 CI

1. 页面结构:拆分 ResizableBar、SplitPane、LogViewer、ClusterNodeSelector、DiagnosisChatPanel

2. 逻辑抽离:新增 useCollapsiblePercent/useIsMobile/useScrollBottomHint/useClusterTree/useDiagnosisChat

3. 错误处理:新增 lib/errors.ts, Diagnosis/ClusterList 复用

4. 测试体系:引入 Vitest@vue/test-utils、happy-dom,新增 Diagnosis 关键链路单测

5. 工程化:新增 GitHub Actions 工作流,自动跑 typecheck/test/build;新增前端 scripts
develop
hnu202326010131 3 months ago
parent 2fbe40edac
commit 917b0aa9e4

@ -0,0 +1,36 @@
name: frontend-vue-ci
on:
push:
paths:
- "frontend-vue/**"
- ".github/workflows/frontend-vue-ci.yml"
pull_request:
paths:
- "frontend-vue/**"
- ".github/workflows/frontend-vue-ci.yml"
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend-vue
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: frontend-vue/pnpm-lock.yaml
- name: Enable corepack
run: corepack enable
- name: Install
run: pnpm install --frozen-lockfile
- name: Typecheck
run: pnpm run typecheck
- name: Test
run: pnpm run test
- name: Build
run: pnpm run build

@ -5,6 +5,9 @@
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"build": "vite build --config vite.config.ts",
"preview": "vite preview --config vite.config.ts"
},
@ -22,11 +25,14 @@
},
"devDependencies": {
"@types/marked": "^5.0.2",
"@vue/test-utils": "^2.4.6",
"@vitejs/plugin-vue": "^5.1.2",
"amfe-flexible": "^2.2.1",
"happy-dom": "^16.3.1",
"postcss-pxtorem": "^6.1.0",
"sass": "^1.97.2",
"typescript": "^5.6.2",
"vite": "^5.4.10"
"vite": "^5.4.10",
"vitest": "^2.1.9"
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,151 @@
<template>
<el-card
class="selection-card-vertical"
shadow="never"
:style="isMobile ? { height: percent + '%' } : { width: percent + '%' }"
>
<template #header>
<div class="card-header">
<span class="card-title">集群与节点</span>
</div>
</template>
<el-scrollbar>
<div class="cluster-groups-vertical">
<div v-for="g in filteredGroups" :key="g.id" class="cluster-group-v">
<div class="group-header" @click="toggleGroup(g)">
<el-icon class="icon-mr">
<ArrowDown v-if="g.open" />
<ArrowRight v-else />
</el-icon>
<span class="group-name">{{ g.name }}</span>
</div>
<div v-if="g.open" class="node-list-v">
<div
v-for="n in nodesForGroup(g)"
:key="n.name"
class="node-item-v"
:class="{ 'is-active': selectedNode === n.name }"
@click="selectNode(n.name)"
>
<span class="status-dot icon-mr" :class="statusDot(n)"></span>
<span class="node-name">{{ n.name }}</span>
</div>
</div>
</div>
</div>
</el-scrollbar>
</el-card>
</template>
<script setup lang="ts">
import { ArrowDown, ArrowRight } from "@element-plus/icons-vue";
type NodeItem = { name: string; status: string };
type Group = { id: string; uuid: string; name: string; open: boolean; nodes: NodeItem[]; count?: number };
defineProps<{
isMobile: boolean;
percent: number;
filteredGroups: Group[];
selectedNode: string;
nodesForGroup: (g: Group) => NodeItem[];
toggleGroup: (g: Group) => void | Promise<void>;
selectNode: (n: string) => void;
statusDot: (n: NodeItem) => string;
}>();
</script>
<style scoped>
.selection-card-vertical {
flex-shrink: 0;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
}
.cluster-groups-vertical {
padding: 8px;
}
.cluster-group-v {
margin-bottom: 8px;
}
.group-header {
padding: 8px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
font-size: 13px;
font-weight: 500;
color: var(--app-text-primary);
transition: background 0.2s;
}
.group-header:hover {
background: var(--el-color-primary-light-9);
}
.node-list-v {
margin-top: 4px;
padding-left: 12px;
}
.node-item-v {
padding: 6px 10px;
margin: 2px 0;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
transition: all 0.2s;
}
.node-item-v:hover {
background: var(--app-content-bg);
}
.node-item-v.is-active {
background: var(--el-color-primary-light-8);
color: var(--el-color-primary);
font-weight: 600;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
display: inline-block;
}
.status-dot-running {
background-color: var(--el-color-success);
}
.status-dot-warning {
background-color: var(--el-color-warning);
}
.status-dot-error {
background-color: var(--el-color-danger);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-weight: 600;
font-size: 14px;
}
.icon-mr {
margin-right: 6px;
}
</style>

@ -0,0 +1,227 @@
<template>
<el-card class="chat-card-full" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">诊断助手</span>
<div class="agent-selectors">
<el-select
:model-value="model"
size="small"
style="width: 160px"
@update:model-value="(v) => emit('update:model', String(v || ''))"
>
<el-option label="DeepSeek-V3" value="deepseek-ai/DeepSeek-V3" />
<el-option label="DeepSeek-R1" value="Pro/deepseek-ai/DeepSeek-R1" />
</el-select>
</div>
</div>
</template>
<div class="chat-container">
<div class="chat-history" :ref="setChatHistoryEl">
<div
v-for="(m, i) in visibleMessages"
:key="'msg-' + i"
class="chat-item"
:class="m.role === 'user' ? 'chat-item-user' : 'chat-item-assistant'"
>
<div class="chat-role">{{ roleLabel(m.role) }}</div>
<div class="chat-content">
<div v-if="m.reasoning" class="reasoning-container">
<el-collapse>
<el-collapse-item title="推理过程">
<pre class="reasoning-text">{{ m.reasoning }}</pre>
</el-collapse-item>
</el-collapse>
</div>
<div class="message-text">{{ m.content }}</div>
</div>
</div>
</div>
<transition name="el-fade-in">
<el-button
v-if="showScrollBottom"
class="scroll-bottom-btn"
type="primary"
circle
@click="emit('scrollBottom')"
>
<el-icon><ArrowDown /></el-icon>
</el-button>
</transition>
<div class="chat-input-area">
<el-input
:model-value="inputMsg"
type="textarea"
:rows="3"
placeholder="支持Markdown输入... Enter 发送"
:disabled="sending"
@keydown.enter.exact.prevent="emit('send')"
@update:model-value="(v) => emit('update:inputMsg', String(v || ''))"
/>
<div class="input-actions">
<div class="option-checks">
<el-checkbox
:model-value="useWebSearch"
label="搜索"
size="small"
@update:model-value="(v) => emit('update:useWebSearch', !!v)"
/>
<el-checkbox
:model-value="useClusterOps"
label="操作"
size="small"
@update:model-value="(v) => emit('update:useClusterOps', !!v)"
/>
</div>
<div class="button-group">
<el-button v-if="!sending" type="primary" :disabled="!inputMsg.trim()" @click="emit('send')">
发送
</el-button>
<el-button v-else type="danger" plain @click="emit('stop')"></el-button>
<el-button type="warning" plain :disabled="sending" @click="emit('diagnose')"></el-button>
</div>
</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import { ArrowDown } from "@element-plus/icons-vue";
type Message = { role: "user" | "assistant" | "system"; content: string; reasoning?: string };
defineProps<{
model: string;
visibleMessages: Message[];
showScrollBottom: boolean;
inputMsg: string;
sending: boolean;
useWebSearch: boolean;
useClusterOps: boolean;
roleLabel: (r: string) => string;
setChatHistoryEl: (el: Element | null) => void;
}>();
const emit = defineEmits<{
(e: "update:model", v: string): void;
(e: "update:inputMsg", v: string): void;
(e: "update:useWebSearch", v: boolean): void;
(e: "update:useClusterOps", v: boolean): void;
(e: "scrollBottom"): void;
(e: "send"): void;
(e: "stop"): void;
(e: "diagnose"): void;
}>();
</script>
<style scoped>
.chat-card-full {
height: 100%;
display: flex;
flex-direction: column;
border-radius: 0;
border: none;
border-left: 1px solid var(--app-border-color);
}
:deep(.el-card__body) {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 16px;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-history {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding-right: 8px;
margin-bottom: 16px;
word-break: break-word;
}
.scroll-bottom-btn {
position: absolute;
right: 20px;
bottom: 160px;
z-index: 100;
}
.chat-item {
margin-bottom: 20px;
}
.chat-role {
font-size: 12px;
color: var(--app-text-secondary);
margin-bottom: 4px;
}
.chat-item-user .chat-role {
text-align: right;
}
.chat-content {
padding: 12px;
border-radius: 8px;
background: var(--app-card-bg);
border: 1px solid var(--app-border-color);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
.message-text {
white-space: pre-wrap;
}
.chat-item-user .chat-content {
background: var(--el-color-primary-light-9);
border-color: var(--el-color-primary-light-8);
}
.chat-input-area {
flex-shrink: 0;
border-top: 1px solid var(--app-border-color);
padding-top: 16px;
}
.input-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.button-group {
display: flex;
gap: 8px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-weight: 600;
font-size: 14px;
}
</style>

@ -0,0 +1,304 @@
<template>
<div class="log-viewer">
<div v-if="!node" class="empty-preview"></div>
<div v-else class="log-preview-container">
<div class="filter-bar">
<el-select
:model-value="level"
placeholder="级别"
size="small"
clearable
style="width: 80px"
@update:model-value="(v) => emit('update:level', String(v || ''))"
>
<el-option label="INFO" value="info" />
<el-option label="WARN" value="warning" />
<el-option label="ERROR" value="error" />
</el-select>
<el-select
:model-value="timeRange"
placeholder="时间"
size="small"
clearable
style="width: 90px"
@update:model-value="(v) => emit('update:timeRange', String(v || ''))"
>
<el-option label="1h" value="1h" />
<el-option label="6h" value="6h" />
<el-option label="24h" value="24h" />
</el-select>
</div>
<div class="log-list-wrapper">
<div class="log-list-head">
<div class="cell time">时间</div>
<div class="cell level">级别</div>
<div class="cell msg">消息</div>
</div>
<div ref="viewportEl" class="log-viewport" @scroll="onScroll">
<div class="log-spacer" :style="{ height: totalHeight + 'px' }"></div>
<div
class="log-items"
:style="{ transform: `translateY(${offsetY}px)` }"
>
<div v-for="row in visibleLogs" :key="row.id" class="log-row">
<div class="cell time">
{{ row.time.split("T")[1]?.slice(0, 8) || row.time }}
</div>
<div class="cell level">
<el-tag
:type="getLevelType(row.level)"
size="small"
effect="dark"
>
{{ row.level.charAt(0).toUpperCase() }}
</el-tag>
</div>
<div class="cell msg" :title="row.message">{{ row.message }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { LogService } from "../api/log.service";
type LogItem = {
id: number | string;
time: string;
level: string;
source: string;
message: string;
};
const props = withDefaults(
defineProps<{
node: string;
level: string;
timeRange: string;
paused?: boolean;
size?: number;
pollMs?: number;
}>(),
{ paused: false, size: 50, pollMs: 5000 }
);
const emit = defineEmits<{
(e: "update:level", v: string): void;
(e: "update:timeRange", v: string): void;
}>();
const logs = ref<LogItem[]>([]);
const viewportEl = ref<HTMLDivElement | null>(null);
const scrollTop = ref(0);
const viewportHeight = ref(0);
const rowHeight = 34;
const overscan = 8;
let timer: any = null;
let ro: ResizeObserver | null = null;
function onScroll() {
if (!viewportEl.value) return;
scrollTop.value = viewportEl.value.scrollTop;
}
const totalHeight = computed(() => logs.value.length * rowHeight);
const startIndex = computed(() => {
const base = Math.floor(scrollTop.value / rowHeight);
return Math.max(0, base - overscan);
});
const endIndex = computed(() => {
const count = Math.ceil(viewportHeight.value / rowHeight) + overscan * 2;
return Math.min(logs.value.length, startIndex.value + count);
});
const offsetY = computed(() => startIndex.value * rowHeight);
const visibleLogs = computed(() =>
logs.value.slice(startIndex.value, endIndex.value)
);
function getLevelType(level: string) {
switch (level.toLowerCase()) {
case "error":
return "danger";
case "warning":
case "warn":
return "warning";
case "info":
return "info";
case "success":
return "success";
default:
return "info";
}
}
async function loadLogs() {
if (!props.node) {
logs.value = [];
return;
}
try {
const params: any = { node: props.node, size: props.size };
if (props.level) params.level = props.level;
if (props.timeRange) {
const now = Date.now();
const r = props.timeRange;
const span =
r === "1h"
? 60 * 60 * 1000
: r === "6h"
? 6 * 60 * 60 * 1000
: r === "24h"
? 24 * 60 * 60 * 1000
: r === "7d"
? 7 * 24 * 60 * 60 * 1000
: 0;
if (span) params.time_from = new Date(now - span).toISOString();
}
const { items } = await LogService.list(params);
logs.value = (items || []).map((d: any, i: number) => ({
id: d.log_id || d.id || i,
time: d.log_time || d.time || d.timestamp || new Date().toISOString(),
level: String(d.level || "info").toLowerCase(),
source: String(
d.title || d.source || d.node_host || d.node || d.host || ""
),
message: d.info || d.message || "",
}));
} catch {
logs.value = [];
}
}
function startPoll() {
stopPoll();
timer = setInterval(() => {
if (props.node && !props.paused) loadLogs();
}, props.pollMs);
}
function stopPoll() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
watch(
() => [props.node, props.level, props.timeRange],
() => {
loadLogs();
if (viewportEl.value) viewportEl.value.scrollTop = 0;
},
{ immediate: true }
);
onMounted(() => {
if (viewportEl.value) viewportHeight.value = viewportEl.value.clientHeight;
ro = new ResizeObserver(() => {
if (viewportEl.value) viewportHeight.value = viewportEl.value.clientHeight;
});
if (viewportEl.value) ro.observe(viewportEl.value);
startPoll();
});
onBeforeUnmount(() => {
stopPoll();
if (ro && viewportEl.value) ro.unobserve(viewportEl.value);
ro = null;
});
</script>
<style scoped>
.log-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.empty-preview {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--app-text-secondary);
font-size: 14px;
}
.log-preview-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.log-list-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--app-border-color);
border-radius: 6px;
}
.log-list-head {
display: grid;
grid-template-columns: 90px 80px 1fr;
gap: 8px;
padding: 8px 10px;
font-size: 12px;
color: var(--app-text-secondary);
border-bottom: 1px solid var(--app-border-color);
background: var(--app-card-bg);
}
.log-viewport {
position: relative;
flex: 1;
overflow: auto;
}
.log-spacer {
width: 1px;
opacity: 0;
}
.log-items {
position: absolute;
top: 0;
left: 0;
right: 0;
}
.log-row {
height: 34px;
display: grid;
grid-template-columns: 90px 80px 1fr;
gap: 8px;
padding: 0 10px;
align-items: center;
font-size: 12px;
border-bottom: 1px solid var(--app-border-color);
}
.cell.msg {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

@ -0,0 +1,118 @@
<template>
<div class="resizer-bar" :class="barClass" @mousedown="start">
<div class="resizer-handle"></div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount } from "vue";
const props = withDefaults(
defineProps<{
container: HTMLElement | null;
isMobile: boolean;
modelValue: number;
maxPercent: number;
collapseThreshold?: number;
barClass?: string;
}>(),
{ collapseThreshold: 5, barClass: "" }
);
const emit = defineEmits<{
(e: "update:modelValue", v: number): void;
(e: "dragging", v: boolean): void;
}>();
let isDragging = false;
function computePercent(e: MouseEvent) {
const containerRect = props.container?.getBoundingClientRect();
if (!containerRect) return null;
if (props.isMobile) {
return ((e.clientY - containerRect.top) / containerRect.height) * 100;
}
return ((e.clientX - containerRect.left) / containerRect.width) * 100;
}
function onMove(e: MouseEvent) {
if (!isDragging) return;
const next = computePercent(e);
if (next == null) return;
if (next < props.collapseThreshold) {
emit("update:modelValue", 0);
return;
}
if (next < props.maxPercent) {
emit("update:modelValue", next);
}
}
function stop() {
if (!isDragging) return;
isDragging = false;
emit("dragging", false);
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", stop);
document.body.style.cursor = "";
document.body.style.userSelect = "";
}
function start(e: MouseEvent) {
if (!props.container) return;
e.preventDefault();
isDragging = true;
emit("dragging", true);
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", stop);
document.body.style.cursor = props.isMobile ? "row-resize" : "col-resize";
document.body.style.userSelect = "none";
}
onBeforeUnmount(() => stop());
</script>
<style scoped>
.resizer-bar {
width: 8px;
cursor: col-resize;
background: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s;
z-index: 10;
flex-shrink: 0;
}
.resizer-bar:hover {
background: var(--el-color-primary-light-8);
}
.resizer-handle {
width: 2px;
height: 40px;
background: var(--app-border-color);
border-radius: 1px;
}
.inner-resizer {
margin: 0 4px;
}
@media (max-width: 1024px) {
.resizer-bar {
width: 100%;
height: 8px;
cursor: row-resize;
}
.resizer-handle {
width: 40px;
height: 2px;
}
.inner-resizer {
margin: 4px 0;
}
}
</style>

@ -0,0 +1,36 @@
<template>
<slot name="first"></slot>
<ResizableBar
v-if="resizable"
:container="container"
:is-mobile="isMobile"
:max-percent="maxPercent"
:bar-class="barClass"
:model-value="modelValue"
@update:model-value="(v) => emit('update:modelValue', v)"
@dragging="(v) => emit('dragging', v)"
/>
<slot name="second"></slot>
</template>
<script setup lang="ts">
import ResizableBar from "./ResizableBar.vue";
withDefaults(
defineProps<{
container: HTMLElement | null;
isMobile: boolean;
modelValue: number;
maxPercent: number;
resizable?: boolean;
barClass?: string;
}>(),
{ resizable: true, barClass: "" }
);
const emit = defineEmits<{
(e: "update:modelValue", v: number): void;
(e: "dragging", v: boolean): void;
}>();
</script>

@ -0,0 +1,135 @@
import { computed, reactive, ref, type Ref } from "vue";
import { ClusterService } from "../api/cluster.service";
import { NodeService } from "../api/node.service";
type NodeItem = { name: string; status: string };
export type ClusterGroup = {
id: string;
uuid: string;
name: string;
open: boolean;
nodes: NodeItem[];
count?: number;
};
export function useClusterTree(options: {
kw: Ref<string>;
filters: { cluster: string; node: string };
selectedNode: Ref<string>;
setError: (e: any, def: string) => void;
}) {
const groups = reactive<ClusterGroup[]>([]);
const loadingSidebar = ref(false);
function pad3(n: number) {
return String(n).padStart(3, "0");
}
async function loadClusters() {
loadingSidebar.value = true;
try {
const list = await ClusterService.list();
const mapped: ClusterGroup[] = list
.map((x: any) => ({
id: String(x.uuid || x.id || x.host || x.name || ""),
uuid: String(x.uuid || x.id || ""),
name: String(x.host || x.name || x.uuid || ""),
open: false,
nodes: [],
count: Number(x.count) || 0,
}))
.filter((g: any) => g.id && g.name);
groups.splice(0, groups.length, ...mapped);
} catch (e: any) {
options.setError(e, "集群列表加载失败");
} finally {
loadingSidebar.value = false;
}
}
async function loadNodesFor(clusterUuid: string) {
const g = groups.find((x) => x.uuid === clusterUuid);
if (!g) return;
const clusterName = g.name;
try {
const nodes = await NodeService.listByCluster(clusterUuid);
const mappedNodes = nodes
.map((x: any) => ({
name: String(x?.name || x),
status: x?.status || "running",
}))
.filter((x: any) => x.name);
if (mappedNodes.length) g.nodes = mappedNodes;
else if ((g.count || 0) > 0)
g.nodes = Array.from({ length: g.count as number }, (_, i) => ({
name: `${clusterName}-${pad3(i + 1)}`,
status: "running",
}));
} catch (e: any) {
if ((g.count || 0) > 0 && g.nodes.length === 0)
g.nodes = Array.from({ length: g.count as number }, (_, i) => ({
name: `${clusterName}-${pad3(i + 1)}`,
status: "running",
}));
options.setError(e, "节点列表加载失败");
}
}
async function toggleGroup(g: ClusterGroup) {
g.open = !g.open;
if (g.open && g.nodes.length === 0) await loadNodesFor(g.uuid);
}
const filteredGroups = computed(() => {
const kraw = options.kw.value.trim().toLowerCase();
let base = groups.filter((g) => !options.filters.cluster || g.name === options.filters.cluster);
if (kraw) {
base = base.filter(
(g) =>
g.name.toLowerCase().includes(kraw) ||
g.nodes.some((n) => n.name.toLowerCase().includes(kraw))
);
}
if (options.filters.node) {
base = base.filter((g) => g.nodes.some((n) => n.name === options.filters.node));
}
return base;
});
function nodesForGroup(g: ClusterGroup) {
const k = options.kw.value.trim().toLowerCase();
let nodes = g.nodes;
if (k) nodes = nodes.filter((n) => n.name.toLowerCase().includes(k) || g.name.toLowerCase().includes(k));
if (options.filters.node) nodes = nodes.filter((n) => n.name === options.filters.node);
return nodes;
}
function statusDot(n: { name: string; status: string }) {
if (n.status === "running") return "status-dot-running";
if (n.status === "warning") return "status-dot-warning";
if (n.status === "error") return "status-dot-error";
return n.name.includes("003")
? "status-dot-error"
: n.name.includes("002")
? "status-dot-warning"
: "status-dot-running";
}
const selectedClusterUuid = computed(() => {
if (!options.selectedNode.value) return "";
const group = groups.find((g) => g.nodes.some((n) => n.name === options.selectedNode.value));
return group ? group.uuid : "";
});
return {
groups,
loadingSidebar,
filteredGroups,
nodesForGroup,
toggleGroup,
statusDot,
selectedClusterUuid,
loadClusters,
};
}

@ -0,0 +1,38 @@
import { ref, watch } from "vue";
export function useCollapsiblePercent(options: {
initialPercent: number;
defaultExpandedPercent: number;
minRememberPercent?: number;
}) {
const minRememberPercent = options.minRememberPercent ?? 0;
const percent = ref(options.initialPercent);
const lastPercent = ref(options.initialPercent);
const collapsed = ref(options.initialPercent <= 0);
function toggle() {
if (collapsed.value) {
percent.value =
lastPercent.value > minRememberPercent
? lastPercent.value
: options.defaultExpandedPercent;
collapsed.value = false;
} else {
lastPercent.value = percent.value;
percent.value = 0;
collapsed.value = true;
}
}
watch(percent, (v) => {
if (v > 0) {
collapsed.value = false;
lastPercent.value = v;
} else {
collapsed.value = true;
}
});
return { percent, lastPercent, collapsed, toggle };
}

@ -0,0 +1,211 @@
import { computed, nextTick, reactive, ref, type Ref } from "vue";
import type { ClusterGroup } from "./useClusterTree";
import { DiagnosisService } from "../api/diagnosis.service";
import { ElMessage } from "element-plus";
type Message = { role: "user" | "assistant" | "system"; content: string; reasoning?: string };
export function useDiagnosisChat(options: {
authToken: Ref<string | null>;
agent: Ref<string>;
model: Ref<string>;
selectedNode: Ref<string>;
selectedClusterUuid: Ref<string>;
groups: ClusterGroup[];
useWebSearch: Ref<boolean>;
useClusterOps: Ref<boolean>;
setError: (e: any, def: string) => void;
clearError: () => void;
scrollToLatest: () => void;
}) {
const messages = ref<Message[]>([
{ role: "system", content: "欢迎使用多智能体诊断面板" },
{ role: "assistant", content: "请在左侧选择节点并开始诊断" },
]);
const visibleMessages = computed(() => messages.value.filter((m) => m.role !== "system"));
const inputMsg = ref("");
const sending = ref(false);
let abortController: AbortController | null = null;
function stopGeneration() {
if (abortController) {
abortController.abort();
abortController = null;
sending.value = false;
}
}
function sessionIdOf() {
return options.selectedNode.value ? `diagnosis-${options.selectedNode.value}` : "diagnosis-global";
}
async function loadHistory() {
options.clearError();
try {
const r = await DiagnosisService.getHistory(sessionIdOf());
const list = Array.isArray(r?.messages) ? r.messages : [];
messages.value = list.map((m: any) => ({
role: m.role || "assistant",
content: String(m.content || ""),
reasoning: m.reasoning || m.reasoning_content,
}));
await nextTick();
options.scrollToLatest();
} catch (e: any) {
options.setError(e, "历史记录加载失败");
}
}
async function send() {
const msg = inputMsg.value.trim();
if (!msg) return;
sending.value = true;
options.clearError();
abortController = new AbortController();
const userMsg = { role: "user" as const, content: msg };
messages.value.push(userMsg);
const assistantMsg = reactive({ role: "assistant" as const, content: "", reasoning: "" });
messages.value.push(assistantMsg);
try {
const response = await fetch("/api/v1/ai/chat", {
method: "POST",
signal: abortController.signal,
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
"Cache-Control": "no-cache",
...(options.authToken.value ? { Authorization: `Bearer ${options.authToken.value}` } : {}),
},
body: JSON.stringify({
sessionId: sessionIdOf(),
message: msg,
stream: true,
context: {
webSearch: options.useWebSearch.value,
clusterOps: options.useClusterOps.value,
agent: options.agent.value,
node: options.selectedNode.value || "",
cluster: options.selectedClusterUuid.value || "",
model: options.model.value,
},
}),
});
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("无法读取响应流");
inputMsg.value = "";
let buffer = "";
let hasReceivedContent = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith("data: ")) continue;
const jsonStr = trimmed.slice(6);
if (jsonStr === "[DONE]") break;
try {
const data = JSON.parse(jsonStr);
const text = data.content || data.reply || data.message || "";
if (text) {
assistantMsg.content += text;
hasReceivedContent = true;
}
if (data.reasoning || data.reasoning_content) {
assistantMsg.reasoning += data.reasoning || data.reasoning_content;
hasReceivedContent = true;
}
await nextTick();
options.scrollToLatest();
} catch (e) {
console.error("解析流数据失败", e, jsonStr);
}
}
}
if (!hasReceivedContent) {
options.setError({ friendlyMessage: "后端已响应但未返回任何有效诊断内容。" }, "消息发送失败");
messages.value.pop();
}
} catch (e: any) {
if (e.name === "AbortError") return;
options.setError(e, "消息发送失败");
messages.value.pop();
} finally {
sending.value = false;
}
}
async function diagnose() {
if (!options.selectedNode.value) {
ElMessage.warning("请先在左侧选择一个节点进行诊断");
return;
}
const group = options.groups.find((g) => g.nodes.some((n) => n.name === options.selectedNode.value));
if (!group) {
ElMessage.error("无法确定节点所属集群");
return;
}
sending.value = true;
options.clearError();
try {
const res = await DiagnosisService.diagnoseRepair({
cluster: group.uuid,
model: options.model.value,
auto: true,
maxSteps: 3,
});
messages.value.push({
role: "assistant",
content: `深度诊断已完成:\n${res.summary || res.message || "诊断完成,请查看报告。"}`,
});
await nextTick();
options.scrollToLatest();
} catch (e: any) {
options.setError(e, "深度诊断请求失败");
} finally {
sending.value = false;
}
}
async function generateReport() {
inputMsg.value =
inputMsg.value ||
`请根据当前节点${options.selectedNode.value || "(未选定)"}最近关键日志生成一份状态报告(包含症状、影响范围、根因假设与建议)。`;
await send();
}
return {
messages,
visibleMessages,
inputMsg,
sending,
stopGeneration,
loadHistory,
send,
diagnose,
generateReport,
};
}

@ -0,0 +1,20 @@
import { onBeforeUnmount, onMounted, ref } from "vue";
export function useIsMobile(breakpoint = 1024) {
const isMobile = ref(window.innerWidth <= breakpoint);
function update() {
isMobile.value = window.innerWidth <= breakpoint;
}
onMounted(() => {
window.addEventListener("resize", update);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", update);
});
return { isMobile, update };
}

@ -0,0 +1,39 @@
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
export function useScrollBottomHint(options?: { threshold?: number }) {
const threshold = options?.threshold ?? 200;
const el = ref<HTMLElement | null>(null);
const showScrollBottom = ref(false);
function handleScroll() {
if (!el.value) return;
const { scrollTop, scrollHeight, clientHeight } = el.value;
showScrollBottom.value = scrollHeight - scrollTop - clientHeight > threshold;
}
function setEl(v: Element | null) {
el.value = (v as HTMLElement | null) || null;
}
function scrollToBottom(smooth = true) {
nextTick(() => {
if (!el.value) return;
el.value.scrollTo({
top: el.value.scrollHeight,
behavior: smooth ? "smooth" : "auto",
});
});
}
watch(el, (next, prev) => {
if (prev) prev.removeEventListener("scroll", handleScroll);
if (next) next.addEventListener("scroll", handleScroll);
});
onBeforeUnmount(() => {
if (el.value) el.value.removeEventListener("scroll", handleScroll);
});
return { el, setEl, showScrollBottom, scrollToBottom };
}

@ -0,0 +1,42 @@
export function formatError(
e: any,
defaultMsg: string,
options?: { mode?: "diagnosis" | "clusterList" }
): string {
const mode = options?.mode || "diagnosis";
if (mode === "clusterList") {
if (e?.response) {
const s = e.response.status;
const d = e.response.data;
let detail = "";
if (d?.detail) {
if (typeof d.detail === "string") detail = d.detail;
else if (Array.isArray(d.detail?.errors)) {
detail = d.detail.errors
.map((x: any) => {
let msg = x?.message || "未知错误";
if (x?.field) msg = `[${x.field}] ${msg}`;
return msg;
})
.join(", ");
}
}
return detail || `请求异常 (${s})`;
}
return e?.message || defaultMsg;
}
if (e instanceof Error && !(e as any).response) {
return e.message || "网络请求异常";
}
const r = e?.response;
const s = r?.status;
const d = r?.data;
const detail = typeof d?.detail === "string" ? d.detail : "";
const msgs: string[] = [];
if (s) msgs.push(`HTTP ${s}`);
if (detail) msgs.push(detail);
if (!msgs.length) msgs.push(r ? defaultMsg : "网络连接异常");
return msgs.join(" | ");
}

@ -230,6 +230,7 @@ import { LogService } from '../api/log.service'
import { useClusterStore } from '../stores/cluster'
import { Plus, More } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { formatError } from '../lib/errors'
const router = useRouter()
const clusterStore = useClusterStore()
@ -306,26 +307,6 @@ function getHealthTag(h: string) {
return map[h] || 'info'
}
function formatError(e: any, defaultMsg: string = '操作失败'): string {
if (e?.response) {
const s = e.response.status
const d = e.response.data
let detail = ''
if (d?.detail) {
if (typeof d.detail === 'string') detail = d.detail
else if (Array.isArray(d.detail?.errors)) {
detail = d.detail.errors.map((x: any) => {
let msg = x?.message || '未知错误'
if (x?.field) msg = `[${x.field}] ${msg}`
return msg
}).join(', ')
}
}
return detail || `请求异常 (${s})`
}
return e?.message || defaultMsg
}
async function load() {
loading.value = true
try {
@ -341,7 +322,7 @@ async function load() {
//
await syncCollectionStatus()
} catch (e: any) {
ElMessage.error(e.friendlyMessage || formatError(e, '加载列表失败'))
ElMessage.error(e.friendlyMessage || formatError(e, '加载列表失败', { mode: 'clusterList' }))
} finally {
loading.value = false
}
@ -391,7 +372,7 @@ async function onRegister() {
cancelRegister()
await load()
} catch (e: any) {
err.value = e.friendlyMessage || formatError(e, '提交失败')
err.value = e.friendlyMessage || formatError(e, '提交失败', { mode: 'clusterList' })
} finally {
registering.value = false
}
@ -403,7 +384,7 @@ async function unregister(id: string) {
ElMessage.success('集群已注销')
await load()
} catch (e: any) {
ElMessage.error(e.friendlyMessage || formatError(e, '注销失败'))
ElMessage.error(e.friendlyMessage || formatError(e, '注销失败', { mode: 'clusterList' }))
}
}
@ -423,7 +404,7 @@ async function startCluster(row: any) {
await load()
} catch (e: any) {
executionLogs.value = e.response?.data?.logs || [e.message || '启动失败']
ElMessage.error(e.friendlyMessage || formatError(e, '启动失败'))
ElMessage.error(e.friendlyMessage || formatError(e, '启动失败', { mode: 'clusterList' }))
} finally {
rowLoading.value[id] = false
}
@ -445,7 +426,7 @@ async function stopCluster(row: any) {
await load()
} catch (e: any) {
executionLogs.value = e.response?.data?.logs || [e.message || '停止失败']
ElMessage.error(e.friendlyMessage || formatError(e, '关闭失败'))
ElMessage.error(e.friendlyMessage || formatError(e, '关闭失败', { mode: 'clusterList' }))
} finally {
rowLoading.value[id] = false
}
@ -467,7 +448,7 @@ async function collectLogs(row: any) {
clusterStore.setCollectionState(id, true)
} catch (e: any) {
executionLogs.value = e.response?.data?.logs || [e.message || '启动采集失败']
ElMessage.error(formatError(e, '启动日志采集失败'))
ElMessage.error(formatError(e, '启动日志采集失败', { mode: 'clusterList' }))
clusterStore.setCollectionState(id, false) //
} finally {
rowLoading.value[id] = false
@ -491,7 +472,7 @@ async function stopLogs(row: any) {
clusterStore.syncStates(newStates)
} catch (e: any) {
executionLogs.value = e.response?.data?.logs || [e.message || '停止失败']
ElMessage.error(formatError(e, '停止采集失败'))
ElMessage.error(formatError(e, '停止采集失败', { mode: 'clusterList' }))
clusterStore.setCollectionState(id, true) //
} finally {
rowLoading.value[id] = false

File diff suppressed because it is too large Load Diff

@ -0,0 +1,240 @@
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
import Diagnosis from "../Diagnosis.vue";
const mocks = vi.hoisted(() => ({
elMessageWarning: vi.fn(),
elMessageError: vi.fn(),
mockClusterList: vi.fn(),
mockNodesByCluster: vi.fn(),
mockGetHistory: vi.fn(),
mockDiagnoseRepair: vi.fn(),
}));
vi.mock("element-plus", () => ({
ElMessage: {
warning: mocks.elMessageWarning,
error: mocks.elMessageError,
},
}));
vi.mock("../../api/cluster.service", () => ({
ClusterService: { list: () => mocks.mockClusterList() },
}));
vi.mock("../../api/node.service", () => ({
NodeService: { listByCluster: (uuid: string) => mocks.mockNodesByCluster(uuid) },
}));
vi.mock("../../api/diagnosis.service", () => ({
DiagnosisService: {
getHistory: (sid: string) => mocks.mockGetHistory(sid),
diagnoseRepair: (p: any) => mocks.mockDiagnoseRepair(p),
},
}));
vi.mock("../../stores/auth", () => ({
useAuthStore: () => ({ token: "test-token" }),
}));
function flush() {
return new Promise((r) => setTimeout(r, 0));
}
const stubs = {
SplitPane: {
template: "<div><slot name='first' /><slot name='second' /></div>",
},
LogViewer: {
template: "<div></div>",
},
transition: { template: "<div><slot /></div>" },
"el-icon": { template: "<i><slot /></i>" },
"el-tag": { props: ["type", "size"], template: "<span><slot /></span>" },
"el-scrollbar": { template: "<div><slot /></div>" },
"el-card": {
template:
'<section class="el-card"><header class="el-card__header"><slot name="header"/></header><div class="el-card__body"><slot /></div></section>',
},
"el-select": {
props: ["modelValue"],
emits: ["update:modelValue"],
template:
'<select :value="modelValue" @change="$emit(\'update:modelValue\', $event.target && $event.target.value)"><slot /></select>',
},
"el-option": { template: "<option></option>" },
"el-input": {
props: ["modelValue", "type", "rows", "disabled", "placeholder"],
emits: ["update:modelValue"],
template:
'<textarea :value="modelValue" :disabled="disabled" @input="$emit(\'update:modelValue\', $event.target && $event.target.value)" />',
},
"el-button": {
props: ["disabled", "type", "plain", "circle", "loading", "link"],
emits: ["click"],
template:
'<button v-bind="$attrs" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
},
"el-checkbox": {
props: ["modelValue", "label", "disabled"],
emits: ["update:modelValue"],
template:
'<label><input type="checkbox" :checked="modelValue" :disabled="disabled" @change="$emit(\'update:modelValue\', $event.target && $event.target.checked)" /><span><slot /></span></label>',
},
"el-collapse": { template: "<div><slot /></div>" },
"el-collapse-item": { template: "<div><slot /></div>" },
};
describe("Diagnosis关键链路", () => {
beforeEach(() => {
mocks.elMessageWarning.mockReset();
mocks.elMessageError.mockReset();
mocks.mockClusterList.mockReset();
mocks.mockNodesByCluster.mockReset();
mocks.mockGetHistory.mockReset();
mocks.mockDiagnoseRepair.mockReset();
});
it("页面能打开、选择节点会拉取历史", async () => {
mocks.mockClusterList.mockResolvedValue([
{ uuid: "c1", host: "cluster-1", count: 1 },
]);
mocks.mockNodesByCluster.mockResolvedValue([{ name: "node-1", status: "running" }]);
mocks.mockGetHistory
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: "global-history" }],
})
.mockResolvedValueOnce({
messages: [{ role: "assistant", content: "node-history" }],
});
const wrapper = mount(Diagnosis, {
global: { stubs },
attachTo: document.body,
});
await flush();
await flush();
expect(mocks.mockGetHistory).toHaveBeenCalledWith("diagnosis-global");
const groupHeader = wrapper.find(".group-header");
expect(groupHeader.exists()).toBe(true);
await groupHeader.trigger("click");
await flush();
const nodeItem = wrapper.find(".node-item-v");
expect(nodeItem.exists()).toBe(true);
await nodeItem.trigger("click");
await flush();
expect(mocks.mockGetHistory).toHaveBeenCalledWith("diagnosis-node-1");
});
it("发送消息、停止、深度诊断按钮校验、滚动到底部提示", async () => {
mocks.mockClusterList.mockResolvedValue([{ uuid: "c1", host: "cluster-1", count: 1 }]);
mocks.mockNodesByCluster.mockResolvedValue([{ name: "node-1", status: "running" }]);
mocks.mockGetHistory.mockResolvedValue({ messages: [{ role: "assistant", content: "history" }] });
mocks.mockDiagnoseRepair.mockResolvedValue({ summary: "ok" });
const wrapper = mount(Diagnosis, {
global: { stubs },
attachTo: document.body,
});
await flush();
await flush();
await wrapper.find(".group-header").trigger("click");
await flush();
await wrapper.find(".node-item-v").trigger("click");
await flush();
mocks.elMessageWarning.mockReset();
const deepBtn = wrapper.findAll("button").find((b) => b.text().includes("深度诊断"));
expect(deepBtn).toBeTruthy();
const wrapperNoNode = mount(Diagnosis, { global: { stubs } });
await flush();
const deepBtn2 = wrapperNoNode.findAll("button").find((b) => b.text().includes("深度诊断"));
expect(deepBtn2).toBeTruthy();
await deepBtn2!.trigger("click");
expect(mocks.elMessageWarning).toHaveBeenCalled();
const originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn((_: any, init: any) => {
return new Promise((_resolve, reject) => {
const signal = init?.signal as AbortSignal | undefined;
if (!signal) return reject({ name: "AbortError" });
const onAbort = () => reject({ name: "AbortError" });
if (signal.aborted) return reject({ name: "AbortError" });
signal.addEventListener("abort", onAbort, { once: true });
}) as any;
}) as any;
const textarea = wrapper.find("textarea");
await textarea.setValue("hello");
const sendBtn = wrapper.findAll("button").find((b) => b.text().trim() === "发送");
expect(sendBtn).toBeTruthy();
await sendBtn!.trigger("click");
await flush();
const stopBtn = wrapper.findAll("button").find((b) => b.text().includes("停止"));
expect(stopBtn).toBeTruthy();
await stopBtn!.trigger("click");
await flush();
globalThis.fetch = originalFetch as any;
globalThis.fetch = vi.fn(async () => {
const text = [
'data: {"content":"hi"}\n',
'data: {"content":"!"}\n',
"data: [DONE]\n",
].join("");
let used = false;
return {
ok: true,
body: {
getReader() {
return {
read: async () => {
if (used) return { done: true, value: undefined };
used = true;
return { done: false, value: new TextEncoder().encode(text) };
},
};
},
},
} as any;
}) as any;
await textarea.setValue("ping");
const sendBtnAgain = wrapper.findAll("button").find((b) => b.text().trim() === "发送");
expect(sendBtnAgain).toBeTruthy();
await sendBtnAgain!.trigger("click");
await flush();
await flush();
expect(wrapper.text()).toContain("hi");
expect(wrapper.text()).toContain("!");
globalThis.fetch = originalFetch as any;
const historyEl = wrapper.find(".chat-history").element as HTMLElement;
const scrollToSpy = vi.fn();
(historyEl as any).scrollTo = scrollToSpy;
Object.defineProperty(historyEl, "scrollHeight", { value: 1000, configurable: true });
Object.defineProperty(historyEl, "clientHeight", { value: 100, configurable: true });
Object.defineProperty(historyEl, "scrollTop", { value: 0, configurable: true });
historyEl.dispatchEvent(new Event("scroll"));
await flush();
const scrollBottomBtn = wrapper.findAll("button").find((b) => b.element.classList.contains("scroll-bottom-btn"));
expect(scrollBottomBtn).toBeTruthy();
await scrollBottomBtn!.trigger("click");
await flush();
await flush();
expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, behavior: "smooth" });
});
});

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
test: {
environment: "happy-dom",
globals: true,
},
});
Loading…
Cancel
Save