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