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
+1 -1
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
pub(crate) mod internal_nat;
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NetInput {
pub net: Ipv4Net,
pub target_ip: Ipv4Addr,
+20 -1
View File
@@ -242,7 +242,7 @@ impl<T> ApiResponse<T> {
}
}
#[derive(Serialize, Deserialize, Debug, Clone)]
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct StartConfig {
pub config_name: Option<String>,
pub server: Vec<String>,
@@ -322,6 +322,8 @@ struct HttpAppInfo {
compress: Option<bool>,
encrypt: Option<bool>,
rtx: Option<bool>,
/// 启动后配置文件是否发生过变化(与启动时的配置快照对比)
config_changed: bool,
}
#[derive(Serialize)]
@@ -1023,12 +1025,27 @@ async fn get_info(
State(state): State<HttpAppState>,
Query(req): Query<FileReq>,
) -> Json<ApiResponse<HttpAppInfo>> {
// 先读当前配置文件(异步),避免持锁跨 await
let current_config: Option<StartConfig> =
match fs::read_to_string(Path::new(CONFIG_DIR).join(&req.file_name)).await {
Ok(content) => toml::from_str(&content).ok(),
Err(_) => None,
};
let lock = state.inner.lock();
let Some(inst) = lock.instances.get(&req.file_name) else {
return Json(ApiResponse::error("实例不存在"));
};
let status = inst.status;
// 与启动时的配置快照对比:文件缺失或解析失败也视为已变化
let config_changed = status != VntStatus::Stopped
&& match (&inst.start_config, &current_config) {
(Some(base), Some(current)) => base != current,
(Some(_), None) => true,
(None, _) => false,
};
let info = if let Some(handler) = inst.vnt.as_ref() {
let api = &handler.api;
let config = api.get_config();
@@ -1077,11 +1094,13 @@ async fn get_info(
compress: config.as_ref().map(|v| v.compress),
encrypt: config.as_ref().map(|v| v.password.is_some()),
rtx: config.as_ref().map(|v| v.rtx),
config_changed,
}
} else {
HttpAppInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
status,
config_changed,
..Default::default()
}
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -19,8 +19,8 @@
if (dark) document.documentElement.classList.add("dark");
})();
</script>
<script type="module" crossorigin src="/assets/index-DHCLVx7I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DVj6b74x.css">
<script type="module" crossorigin src="/assets/index-CvlopnF8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-xUGHQxai.css">
</head>
<body>
<div id="app"></div>
+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">