feat(vnt-web): 配置变化提示与停止交互优化
- /api/info 新增 config_changed:与启动时的配置快照对比,启动后配置被修改(或文件缺失/解析失败)时实例卡片显示「配置发生变化」徽章,重启后自动消失 - 停止操作增加「停止中」中间态:徽章脉冲提示、按钮区禁用,实例真正停止后卡片渐隐消失,失败时自动恢复 - 停止/重启/移除确认弹窗优先显示配置名称,兜底文件名
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user