Feat/config leave confirm (#5249)

* feat: 配置文件增加未保存提示弹窗

* fix: 移除unsavedChangesDialog插件使用组件方式实现弹窗
This commit is contained in:
SnowNightt
2026-02-20 12:55:21 +08:00
committed by GitHub
parent 0a517980b7
commit e469178a6b
4 changed files with 284 additions and 10 deletions
@@ -0,0 +1,98 @@
<template>
<v-dialog v-model="isOpen" max-width="480" persistent>
<v-card>
<v-card-title class="dialog-title d-flex align-center justify-space-between">
<span>{{ title }}</span>
<v-btn icon="mdi-close" variant="text" @click="handleClose"></v-btn>
</v-card-title>
<v-card-text>
<div class="message-text">{{ message }}</div>
<div class="action-hints">
<span class="hint-item">{{ confirmHint }}</span>
<span class="hint-item">{{ cancelHint }}</span>
<span class="hint-item">{{ closeHint }}</span>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="gray" @click="handleCancel">{{ t('core.common.dialog.cancelButton') }}</v-btn>
<v-btn color="red" @click="handleConfirm" class="confirm-button">{{ t('core.common.dialog.confirmButton') }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref } from "vue";
import { useI18n } from '@/i18n/composables';
const { t } = useI18n();
const isOpen = ref(false);
const title = ref("");
const message = ref("");
const confirmHint = ref("");
const cancelHint = ref("");
const closeHint = ref("");
let resolvePromise = null;
const open = (options) => {
title.value = options.title || t('core.common.dialog.confirmTitle');
message.value = options.message || t('core.common.dialog.confirmMessage');
confirmHint.value = options.confirmHint || "";
cancelHint.value = options.cancelHint || "";
closeHint.value = options.closeHint || "";
isOpen.value = true;
return new Promise((resolve) => {
resolvePromise = resolve;
});
};
const handleConfirm = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(true);
};
const handleCancel = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise(false);
};
const handleClose = () => {
isOpen.value = false;
if (resolvePromise) resolvePromise('close');
};
defineExpose({ open });
</script>
<style scoped>
.message-text {
margin-bottom: 8px;
line-height: 1.5;
font-size: 16px;
font-weight: 600;
}
.action-hints {
display: flex;
gap: 15px;
}
.hint-item {
color: var(--v-theme-secondaryText, #666);
font-size: 12px;
opacity: 0.7;
}
.dialog-title {
font-size: 20px;
font-weight: 500;
}
.confirm-button {
color: rgb(239, 68, 68);
}
</style>
@@ -112,5 +112,18 @@
"addToConfig": "Added to config",
"fileCount": "Files: {count}",
"done": "Done"
},
"unsavedChangesWarning": {
"dialogTitle": "Unsaved changes",
"leavePage": "You have unsaved changes. Do you want to save before leaving?",
"switchConfig": "Switching config will discard unsaved changes. Do you want to save first?",
"options": {
"save": "Save",
"saveAndSwitch": "Save and switch",
"discardAndSwitch": "Discard changes and switch",
"closeCard": "Close the pop-up window",
"confirm": "confirm",
"cancel": "cancel"
}
}
}
@@ -112,5 +112,18 @@
"addToConfig": "已加入配置",
"fileCount": "文件:{count}",
"done": "完成"
},
"unsavedChangesWarning": {
"dialogTitle": "未保存的更改",
"leavePage": "当前配置有未保存的更改,切换前是否保存?",
"switchConfig": "切换配置文件会丢失当前未保存的更改,是否先保存?",
"options": {
"save": "保存",
"saveAndSwitch": "保存并切换",
"discardAndSwitch": "放弃更改并切换",
"closeCard": "关闭弹窗",
"confirm": "确定",
"cancel": "取消"
}
}
}
+160 -10
View File
@@ -7,7 +7,7 @@
<div class="config-toolbar d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; width: 100%; justify-content: space-between;">
<div class="config-toolbar-controls d-flex flex-row align-center" style="gap: 12px;">
<v-select class="config-select" style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
<v-select class="config-select" style="min-width: 130px;" :model-value="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" :label="tm('configSelection.selectConfig')" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
@@ -191,6 +191,10 @@
</div>
</v-card>
</v-overlay>
<!-- 未保存更改确认弹窗 -->
<UnsavedChangesConfirmDialog ref="unsavedChangesDialog" />
</template>
@@ -206,6 +210,7 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import UnsavedChangesConfirmDialog from '@/components/config/UnsavedChangesConfirmDialog.vue';
export default {
name: 'ConfigPage',
@@ -213,7 +218,8 @@ export default {
AstrBotCoreConfigWrapper,
VueMonacoEditor,
WaitingForRestart,
StandaloneChat
StandaloneChat,
UnsavedChangesConfirmDialog
},
props: {
initialConfigId: {
@@ -233,6 +239,40 @@ export default {
};
},
// 检查未保存的更改
async beforeRouteLeave(to, from, next) {
if (this.hasUnsavedChanges) {
const confirmed = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: this.tm('unsavedChangesWarning.leavePage'),
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
// 关闭弹窗不跳转
if (confirmed === 'close') {
next(false);
} else if (confirmed) {
const result = await this.updateConfig();
if (this.isSystemConfig) {
next(false);
} else {
if (result?.success) {
await new Promise(resolve => setTimeout(resolve, 800));
next();
} else {
next(false);
}
}
} else {
this.hasUnsavedChanges = false;
next();
}
} else {
next();
}
},
computed: {
messages() {
return {
@@ -243,6 +283,11 @@ export default {
configApplyError: this.tm('messages.configApplyError')
};
},
// 检查配置是否变化
configHasChanges() {
if (!this.originalConfigData || !this.config_data) return false;
return JSON.stringify(this.originalConfigData) !== JSON.stringify(this.config_data);
},
configInfoNameList() {
return this.configInfoList.map(info => info.name);
},
@@ -269,8 +314,16 @@ export default {
config_data_str(val) {
this.config_data_has_changed = true;
},
'$route.fullPath'(newVal) {
this.syncConfigTypeFromHash(newVal);
config_data: {
deep: true,
handler() {
if (this.fetched) {
this.hasUnsavedChanges = this.configHasChanges;
}
}
},
async '$route.fullPath'(newVal) {
await this.syncConfigTypeFromHash(newVal);
},
initialConfigId(newVal) {
if (!newVal) {
@@ -309,6 +362,7 @@ export default {
// 多配置文件管理
selectedConfigID: null, // 用于存储当前选中的配置项信息
currentConfigId: null, // 跟踪当前正在编辑的配置id
configInfoList: [],
configFormData: {
name: '',
@@ -318,6 +372,11 @@ export default {
// 测试聊天
testChatDrawer: false,
testConfigId: null,
// 未保存的更改状态
hasUnsavedChanges: false,
// 存储原始配置
originalConfigData: null,
}
},
mounted() {
@@ -334,6 +393,13 @@ export default {
// 监听语言切换事件,重新加载配置以获取插件的 i18n 数据
window.addEventListener('astrbot-locale-changed', this.handleLocaleChange);
// 保存初始配置
this.$watch('config_data', (newVal) => {
if (!this.originalConfigData && newVal) {
this.originalConfigData = JSON.parse(JSON.stringify(newVal));
}
}, { immediate: false, deep: true });
},
beforeUnmount() {
@@ -362,14 +428,14 @@ export default {
const cleanHash = rawHash.slice(lastHashIndex + 1);
return cleanHash === 'system' || cleanHash === 'normal' ? cleanHash : null;
},
syncConfigTypeFromHash(hash) {
async syncConfigTypeFromHash(hash) {
const configType = this.extractConfigTypeFromHash(hash);
if (!configType || configType === this.configType) {
return false;
}
this.configType = configType;
this.onConfigTypeToggle();
await this.onConfigTypeToggle();
return true;
},
getConfigInfoList(abconf_id) {
@@ -382,6 +448,7 @@ export default {
for (let i = 0; i < this.configInfoList.length; i++) {
if (this.configInfoList[i].id === abconf_id) {
this.selectedConfigID = this.configInfoList[i].id;
this.currentConfigId = this.configInfoList[i].id;
this.getConfig(abconf_id);
matched = true;
break;
@@ -391,6 +458,7 @@ export default {
if (!matched && this.configInfoList.length) {
// 当找不到目标配置时,默认展示列表中的第一个配置
this.selectedConfigID = this.configInfoList[0].id;
this.currentConfigId = this.configInfoList[0].id;
this.getConfig(this.selectedConfigID);
}
}
@@ -418,6 +486,14 @@ export default {
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
// 获取配置后更新
this.$nextTick(() => {
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
this.hasUnsavedChanges = false;
if (!this.isSystemConfig) {
this.currentConfigId = abconf_id || this.selectedConfigID;
}
});
}).catch((err) => {
this.save_message = this.messages.loadError;
this.save_message_snack = true;
@@ -437,27 +513,37 @@ export default {
postData.conf_id = this.selectedConfigID;
}
axios.post('/api/config/astrbot/update', postData).then((res) => {
return axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
this.onConfigSaved();
if (this.isSystemConfig) {
restartAstrBotRuntime(this.$refs.wfr).catch(() => {})
}
return { success: true };
} else {
this.save_message = res.data.message || this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
return { success: false };
}
}).catch((err) => {
this.save_message = this.messages.saveError;
this.save_message_snack = true;
this.save_message_success = "error";
return { success: false };
});
},
// 重置未保存状态
onConfigSaved() {
this.hasUnsavedChanges = false;
this.originalConfigData = JSON.parse(JSON.stringify(this.config_data));
},
configToString() {
this.config_data_str = JSON.stringify(this.config_data, null, 2);
this.config_data_has_changed = false;
@@ -497,7 +583,7 @@ export default {
this.save_message_success = "error";
});
},
onConfigSelect(value) {
async onConfigSelect(value) {
if (value === '_%manage%_') {
this.configManageDialog = true;
// 重置选择到之前的值
@@ -506,7 +592,44 @@ export default {
this.getConfig(this.selectedConfigID);
});
} else {
this.getConfig(value);
// 检查是否有未保存的更改
if (this.hasUnsavedChanges) {
// 获取之前正在编辑的配置id
const prevConfigId = this.isSystemConfig ? 'default' : (this.currentConfigId || this.selectedConfigID || 'default');
const message = this.tm('unsavedChangesWarning.switchConfig');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: message,
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
// 关闭弹窗不切换
if (saveAndSwitch === 'close') {
return;
}
if (saveAndSwitch) {
// 设置临时变量保存切换后的id
const currentSelectedId = this.selectedConfigID;
// 把id设置回切换前的用于保存上一次的配置,保存完后恢复id为切换后的
this.selectedConfigID = prevConfigId;
const result = await this.updateConfig();
this.selectedConfigID = currentSelectedId;
if (result?.success) {
this.selectedConfigID = value;
this.getConfig(value);
}
return;
} else {
// 取消保存并切换配置
this.selectedConfigID = value;
this.getConfig(value);
}
} else {
// 无未保存更改直接切换
this.selectedConfigID = value;
this.getConfig(value);
}
}
},
startCreateConfig() {
@@ -600,7 +723,34 @@ export default {
this.save_message_success = "error";
});
},
onConfigTypeToggle() {
async onConfigTypeToggle() {
// 检查是否有未保存的更改
if (this.hasUnsavedChanges) {
const message = this.tm('unsavedChangesWarning.leavePage');
const saveAndSwitch = await this.$refs.unsavedChangesDialog?.open({
title: this.tm('unsavedChangesWarning.dialogTitle'),
message: message,
confirmHint: `${this.tm('unsavedChangesWarning.options.saveAndSwitch')}:${this.tm('unsavedChangesWarning.options.confirm')}`,
cancelHint: `${this.tm('unsavedChangesWarning.options.discardAndSwitch')}:${this.tm('unsavedChangesWarning.options.cancel')}`,
closeHint: `${this.tm('unsavedChangesWarning.options.closeCard')}:"x"`
});
// 关闭弹窗
if (saveAndSwitch === 'close') {
// 恢复路由
const originalHash = this.isSystemConfig ? '#system' : '#normal';
this.$router.replace('/config' + originalHash);
this.configType = this.isSystemConfig ? 'system' : 'normal';
return;
}
if (saveAndSwitch) {
await this.updateConfig();
// 系统配置保存后不跳转
if (this.isSystemConfig) {
this.$router.replace('/config#system');
return;
}
}
}
this.isSystemConfig = this.configType === 'system';
this.fetched = false; // 重置加载状态