feat(vnt-web): 配置变化提示与停止交互优化

- /api/info 新增 config_changed:与启动时的配置快照对比,启动后配置被修改(或文件缺失/解析失败)时实例卡片显示「配置发生变化」徽章,重启后自动消失
- 停止操作增加「停止中」中间态:徽章脉冲提示、按钮区禁用,实例真正停止后卡片渐隐消失,失败时自动恢复
- 停止/重启/移除确认弹窗优先显示配置名称,兜底文件名
This commit is contained in:
lbl
2026-08-22 01:27:39 +08:00
parent 6029fd113d
commit 39a4e60d39
11 changed files with 292 additions and 211 deletions
+69 -34
View File
@@ -14,11 +14,27 @@ const ui = useUiStore();
const info = computed(() => app.infoOf(props.inst.file_name));
const loading = computed(() => !!app.loadingMap[props.inst.file_name]);
// 停止中:点击停止后直到实例真正消失/停止
const stopping = computed(() => !!app.stoppingMap[props.inst.file_name]);
// 展示名优先用配置名称,兜底文件名
const displayName = computed(() => props.inst.config_name || props.inst.file_name);
const statusBadgeClass = (status) =>
status === "running" ? "badge-green" : status === "starting" ? "badge-blue" : "badge-gray";
stopping.value
? "badge-yellow"
: status === "running"
? "badge-green"
: status === "starting"
? "badge-blue"
: "badge-gray";
const statusText = (status) =>
status === "running" ? "运行中" : status === "starting" ? "启动中" : "已停止";
stopping.value
? "停止中"
: status === "running"
? "运行中"
: status === "starting"
? "启动中"
: "已停止";
const select = () => {
if (props.selectable) app.selectedInstance = props.inst.file_name;
@@ -27,7 +43,7 @@ const select = () => {
const confirmStop = async () => {
const ok = await ui.confirm({
title: "停止组网",
message: `确定要停止 ${props.inst.file_name} 吗?`,
message: `确定要停止 ${displayName.value} 吗?`,
danger: true,
confirmText: "停止",
});
@@ -37,7 +53,7 @@ const confirmStop = async () => {
const confirmRestart = async () => {
const ok = await ui.confirm({
title: "重启组网",
message: `确定要重启 ${props.inst.file_name} 吗?`,
message: `确定要重启 ${displayName.value} 吗?`,
});
if (ok) app.restartVnt(props.inst.file_name);
};
@@ -45,7 +61,7 @@ const confirmRestart = async () => {
const confirmDismiss = async () => {
const ok = await ui.confirm({
title: "移除实例",
message: `确定要移除已停止的实例 ${props.inst.file_name} 吗?`,
message: `确定要移除已停止的实例 ${displayName.value} 吗?`,
danger: true,
confirmText: "移除",
});
@@ -66,9 +82,20 @@ const confirmDismiss = async () => {
>
<div class="flex items-center justify-between gap-2">
<h3 class="truncate text-base font-bold text-slate-900 dark:text-white" :title="inst.file_name">
{{ inst.config_name || inst.file_name }}
{{ displayName }}
</h3>
<span class="shrink-0" :class="statusBadgeClass(inst.status)">{{ statusText(inst.status) }}</span>
<div class="flex shrink-0 items-center gap-1.5">
<span
v-if="info.config_changed"
class="badge-yellow"
title="配置文件在启动后被修改,重启实例后生效"
>
配置发生变化
</span>
<span :class="[statusBadgeClass(inst.status), stopping ? 'animate-pulse' : '']">{{
statusText(inst.status)
}}</span>
</div>
</div>
<div class="mt-2 text-sm">
@@ -98,33 +125,41 @@ const confirmDismiss = async () => {
</div>
<div class="mt-4 flex flex-wrap justify-end gap-2">
<button
v-if="inst.status === 'running' || inst.status === 'stopped'"
class="btn-primary btn-sm"
:disabled="loading"
@click.stop="confirmRestart"
>
<span v-if="loading" class="animate-spin"></span>
{{ inst.status === "stopped" ? "重新启动" : "重启" }}
</button>
<button
v-if="inst.status !== 'stopped'"
class="btn-danger btn-sm"
:disabled="loading"
@click.stop="confirmStop"
>
<span v-if="loading" class="animate-spin"></span>
停止
</button>
<button
v-if="inst.status === 'stopped'"
class="btn-ghost btn-sm"
:disabled="loading"
@click.stop="confirmDismiss"
>
<span v-if="loading" class="animate-spin"></span>
移除
</button>
<template v-if="stopping">
<span class="flex items-center gap-1.5 text-xs muted">
<span class="inline-block animate-spin"></span>
正在停止请稍候...
</span>
</template>
<template v-else>
<button
v-if="inst.status === 'running' || inst.status === 'stopped'"
class="btn-primary btn-sm"
:disabled="loading"
@click.stop="confirmRestart"
>
<span v-if="loading" class="animate-spin"></span>
{{ inst.status === "stopped" ? "重新启动" : "重启" }}
</button>
<button
v-if="inst.status !== 'stopped'"
class="btn-danger btn-sm"
:disabled="loading"
@click.stop="confirmStop"
>
<span v-if="loading" class="animate-spin"></span>
停止
</button>
<button
v-if="inst.status === 'stopped'"
class="btn-ghost btn-sm"
:disabled="loading"
@click.stop="confirmDismiss"
>
<span v-if="loading" class="animate-spin"></span>
移除
</button>
</template>
</div>
</div>
</template>
+13
View File
@@ -22,6 +22,8 @@ export const useAppStore = defineStore("app", () => {
const selectedInstance = ref(null);
const configList = ref([]);
const loadingMap = ref({});
// 停止中的实例:key=file_name;点击停止后置位,实例从列表消失或变为 stopped 时清除
const stoppingMap = ref({});
// 页面可见性
const isPageVisible = ref(!document.hidden);
@@ -93,6 +95,13 @@ export const useAppStore = defineStore("app", () => {
delete instances.value[key];
}
}
// 停止已生效(实例消失或进入 stopped)时清除停止中标记
for (const key of Object.keys(stoppingMap.value)) {
const inst = list.find((i) => i.file_name === key);
if (!inst || inst.status === "stopped") {
delete stoppingMap.value[key];
}
}
// 默认选中逻辑:当前选中失效时优先第一个 running,否则第一个,否则 null
if (
!selectedInstance.value ||
@@ -145,6 +154,7 @@ export const useAppStore = defineStore("app", () => {
const stopVnt = async (fileName) => {
if (!fileName || loadingMap.value[fileName]) return;
loadingMap.value[fileName] = true;
stoppingMap.value[fileName] = true;
try {
await stopVntApi(fileName);
ui.toast.success("已停止");
@@ -156,6 +166,8 @@ export const useAppStore = defineStore("app", () => {
} catch (e) {
ui.toast.error("停止失败: " + e.message);
console.error(e);
// 停止失败,恢复可操作状态
delete stoppingMap.value[fileName];
} finally {
loadingMap.value[fileName] = false;
}
@@ -231,6 +243,7 @@ export const useAppStore = defineStore("app", () => {
selectedInstance,
configList,
loadingMap,
stoppingMap,
isPageVisible,
runningCount,
startingCount,
+14
View File
@@ -59,6 +59,20 @@ body {
opacity: 0;
}
/* 实例卡片列表:停止/移除后渐隐收缩消失 */
.card-list-enter-active,
.card-list-leave-active {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.card-list-enter-from,
.card-list-leave-to {
opacity: 0;
transform: scale(0.96);
}
/* 弹窗动画 */
.modal-enter-active,
.modal-leave-active {
+2 -2
View File
@@ -95,9 +95,9 @@ const statusSummary = computed(() => {
v-if="app.instanceList.length === 0"
:text="app.configList.length === 0 ? '暂无配置,请先新建配置' : '暂无运行中的组网,请在上方选择配置启动'"
/>
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
<TransitionGroup v-else name="card-list" tag="div" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InstanceCard v-for="inst in app.instanceList" :key="inst.file_name" :inst="inst" />
</div>
</TransitionGroup>
</div>
</div>
</template>
+2 -2
View File
@@ -20,9 +20,9 @@ const app = useAppStore();
<!-- 组网实例 -->
<EmptyState v-if="app.instanceList.length === 0" text="暂无运行中的组网,请在上方选择配置启动" />
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2">
<TransitionGroup v-else name="card-list" tag="div" class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InstanceCard v-for="inst in app.instanceList" :key="inst.file_name" :inst="inst" selectable />
</div>
</TransitionGroup>
<!-- 选中实例详情 -->
<template v-if="app.selectedInstance && app.selectedInfo">