Files
vnt/vnt-web/ui/src/views/ConfigEditor.vue
T
lbl 7513465b61 refactor(vnt-web): 前端重构为 Vite+Vue3+Pinia 工程并重新设计 UI
- 单文件 index.html 拆分为 vnt-web/ui(pnpm+Vite+Vue3 SFC+Pinia+Tailwind v4),构建产物输出到 static/
- UI 重新设计:浅色简洁风(可切换深色模式)、顶部导航、新增总览仪表盘首页
- 自定义弹窗/toast/确认框替换全部原生 alert/confirm,交互与过渡统一
- 响应式布局:移动端汉堡导航,表格与卡片自适应
- 实例卡片移除日志按钮;启动组网默认选中第一个配置
2026-08-22 00:53:51 +08:00

610 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { ref, watch, nextTick } from "vue";
import AppModal from "../components/AppModal.vue";
import { useUiStore } from "../stores/ui";
import { getConfig, saveConfig } from "../api";
import { emptyFormData, parseTomlToForm, formToToml, NEW_CONFIG_TEMPLATE } from "../utils/toml";
const props = defineProps({
show: { type: Boolean, default: false },
// null 表示新建
fileName: { type: String, default: null },
});
const emit = defineEmits(["close", "saved"]);
const ui = useUiStore();
const editorContent = ref("");
const editorFileName = ref("");
const editorMode = ref("new"); // 'new' 或 'edit'
const editMode = ref("form"); // 'form' 或 'toml'
const originalToml = ref(""); // 保存原始TOML内容(包含用户注释)
const hasTomlChanges = ref(false);
const hasFormChanges = ref(false);
const isParsingToml = ref(false);
const formData = ref(emptyFormData());
// 打开时加载内容
watch(
() => props.show,
async (val) => {
if (!val) return;
editorFileName.value = props.fileName || "";
editorMode.value = props.fileName ? "edit" : "new";
editMode.value = "form"; // 默认表单模式
hasTomlChanges.value = false;
hasFormChanges.value = false;
if (props.fileName) {
try {
const data = await getConfig(props.fileName);
editorContent.value = data;
originalToml.value = data; // 保存原始TOML
isParsingToml.value = true;
formData.value = parseTomlToForm(data);
nextTick(() => {
isParsingToml.value = false;
});
} catch (e) {
ui.toast.error("获取配置失败: " + e.message);
emit("close");
}
} else {
// 新建配置,初始化表单
originalToml.value = "";
formData.value = emptyFormData();
editorContent.value = NEW_CONFIG_TEMPLATE;
}
},
);
// 切换到表单模式
const switchToFormMode = () => {
if (editMode.value === "toml") {
editMode.value = "form";
// 从TOML解析到表单
isParsingToml.value = true;
formData.value = parseTomlToForm(editorContent.value);
nextTick(() => {
isParsingToml.value = false;
});
} else {
editMode.value = "form";
}
};
// 切换到TOML模式
const switchToTomlMode = () => {
if (editMode.value === "form") {
// 如果表单被修改过,生成新的TOML
if (hasFormChanges.value) {
editorContent.value = formToToml(formData.value);
hasFormChanges.value = false;
} else if (originalToml.value && !hasTomlChanges.value) {
// 表单未修改且TOML未修改,使用原始TOML(保留用户注释)
editorContent.value = originalToml.value;
} else {
editorContent.value = formToToml(formData.value);
}
}
editMode.value = "toml";
};
// 监听TOML内容变化(只在TOML模式下)
watch(editorContent, (newVal, oldVal) => {
if (editMode.value === "toml" && oldVal !== undefined) {
hasTomlChanges.value = true;
}
});
// 监听表单数据变化
watch(
formData,
() => {
if (editMode.value === "form" && props.show && !isParsingToml.value) {
hasFormChanges.value = true;
}
},
{ deep: true },
);
const handleSave = async () => {
try {
// 表单模式先转换为TOML
let configContent = editorContent.value;
if (editMode.value === "form") {
// 验证必填项
if (!formData.value.network_code.trim()) {
ui.toast.error("请填写网络编号");
return;
}
const servers = formData.value.server.filter((s) => s.trim());
if (servers.length === 0) {
ui.toast.error("请至少填写一个服务器地址");
return;
}
configContent = formToToml(formData.value);
}
await saveConfig(editorFileName.value || null, configContent);
ui.toast.success("配置已保存");
emit("close");
emit("saved");
} catch (e) {
ui.toast.error("保存失败: " + e.message);
}
};
// 表单分组内的小型重复控件
const checkboxClass =
"h-5 w-5 rounded border-slate-300 bg-white text-indigo-600 focus:ring-indigo-500 dark:border-slate-600 dark:bg-slate-700";
const toggleLabelClass =
"flex cursor-pointer items-center justify-between gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800/50 dark:hover:bg-slate-800";
const removeBtnClass =
"shrink-0 rounded-lg bg-red-50 px-3 py-2 text-red-500 transition-colors hover:bg-red-100 dark:bg-red-900/20 dark:text-red-500 dark:hover:bg-red-900/40";
const addBtnClass =
"flex w-full items-center justify-center gap-1 rounded-lg bg-slate-100 px-3 py-2 text-sm text-slate-600 transition-colors hover:bg-slate-200 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700";
const sectionTitleClass = "text-md mb-4 flex items-center font-bold text-slate-900 dark:text-white";
</script>
<template>
<AppModal
:show="show"
:mask-closable="false"
panel-class="w-full max-w-6xl h-[85vh]"
@close="emit('close')"
>
<template #header>
<h3 class="text-lg font-bold text-slate-900 dark:text-white">
{{ editorMode === "new" ? "新建配置" : "编辑配置" }}
</h3>
<div class="flex items-center gap-4">
<!-- 模式切换按钮 -->
<div class="flex rounded-lg border border-slate-300 bg-slate-100 p-1 dark:border-slate-600 dark:bg-slate-800">
<button
@click="switchToFormMode"
:class="editMode === 'form' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
表单模式
</button>
<button
@click="switchToTomlMode"
:class="editMode === 'toml' ? 'bg-white text-slate-900 shadow-sm dark:bg-indigo-600 dark:text-white' : 'text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white'"
class="px-4 py-1.5 rounded text-sm font-medium transition-colors flex items-center"
>
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"
/>
</svg>
TOML模式
</button>
</div>
<div class="text-sm text-slate-500 font-mono hidden md:block" v-if="editorFileName">
{{ editorFileName }}
</div>
</div>
</template>
<template #body>
<!-- 表单模式 -->
<div v-show="editMode === 'form'" class="h-full overflow-y-auto scrollbar-hide p-6">
<div class="max-w-4xl mx-auto space-y-6">
<!-- 基础配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
基础配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">配置名称</label>
<input v-model="formData.config_name" type="text" placeholder="例如: 我的VPN配置" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
网络编号 <span class="text-red-500">*</span>
</label>
<input v-model="formData.network_code" type="text" placeholder="例如: my_network" required class="input" />
</div>
</div>
<div class="mt-4">
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
服务器地址 <span class="text-red-500">*</span>
<span class="text-xs text-slate-500 ml-2">支持 quic:// tcp:// wss:// dynamic://</span>
</label>
<div class="space-y-2">
<div v-for="(server, idx) in formData.server" :key="idx" class="flex gap-2">
<input v-model="formData.server[idx]" type="text" placeholder="例如: quic://1.2.3.4:29872" class="input flex-1" />
<button @click="formData.server.splice(idx, 1)" v-if="formData.server.length > 1" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
<button
@click="formData.server.push('')"
:class="addBtnClass"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
添加服务器
</button>
</div>
</div>
</div>
<!-- 网络设置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
网络设置
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
自定义虚拟IP
<span class="text-xs text-slate-500 ml-1">(可选)</span>
</label>
<input v-model="formData.ip" type="text" placeholder="例如: 10.26.0.2" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">MTU</label>
<input v-model.number="formData.mtu" type="number" placeholder="1380" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">隧道端口</label>
<input v-model.number="formData.tunnel_port" type="number" placeholder="0 (自动分配)" class="input" />
</div>
</div>
</div>
<!-- 传输优化 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
传输优化
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">QUIC传输优化</div>
<div class="text-xs text-slate-400 mt-0.5">重传丢包</div>
</div>
<input v-model="formData.rtx" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">FEC前向纠错</div>
<div class="text-xs text-slate-400 mt-0.5">损失部分带宽提升稳定性</div>
</div>
<input v-model="formData.fec" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">LZ4压缩</div>
<div class="text-xs text-slate-400 mt-0.5">减少传输数据量</div>
</div>
<input v-model="formData.compress" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭P2P打洞</div>
<div class="text-xs text-slate-400 mt-0.5">仅通过服务器中转</div>
</div>
<input v-model="formData.no_punch" type="checkbox" :class="checkboxClass" />
</label>
</div>
</div>
<!-- 安全配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
安全配置
</h4>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300"
>组网加密密码(连接公共服务器时建议填写同一组网密码需要相同)</label
>
<input v-model="formData.password" type="password" placeholder="留空则不加密" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">服务端证书校验模式</label>
<select v-model="formData.cert_mode" class="input">
<option value="skip">跳过验证 (默认)</option>
<option value="standard">系统证书验证</option>
<option value="finger">证书指纹验证</option>
</select>
</div>
</div>
<div v-if="formData.cert_mode === 'finger'">
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
证书指纹
<span class="text-xs text-slate-500 ml-1">(服务端启动时日志会输出指纹)</span>
</label>
<input
v-model="formData.fingerprint"
type="text"
placeholder="例如: 3bdd8675606837cdf95d5e13445606315762315a78555f9da652940a25feaec1"
class="input font-mono text-sm"
/>
</div>
</div>
</div>
<!-- NAT与路由 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 01-.806-.98L15 9m0 0V7m0 2v6"
/>
</svg>
NAT与路由 (点对网)
</h4>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
入栈网段
<span class="text-xs text-slate-500 ml-1">格式: CIDR,目标IP</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.input" :key="idx" class="flex gap-2">
<input v-model="formData.input[idx]" type="text" placeholder="例如: 192.168.0.0/24,10.26.0.2" class="input flex-1" />
<button @click="formData.input.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.input.push('')"
:class="addBtnClass"
>
+ 添加入栈网段
</button>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
出栈网段
<span class="text-xs text-slate-500 ml-1">格式: CIDR (允许转发的网段)</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.output" :key="idx" class="flex gap-2">
<input v-model="formData.output[idx]" type="text" placeholder="例如: 0.0.0.0/0" class="input flex-1" />
<button @click="formData.output.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.output.push('')"
:class="addBtnClass"
>
+ 添加出栈网段
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭内置NAT</div>
<div class="text-xs text-slate-400 mt-0.5">使用系统网卡转发</div>
</div>
<input v-model="formData.no_nat" type="checkbox" :class="checkboxClass" />
</label>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">关闭TUN网卡</div>
<div class="text-xs text-slate-400 mt-0.5">仅作流量出口或端口映射</div>
</div>
<input v-model="formData.no_tun" type="checkbox" :class="checkboxClass" />
</label>
</div>
</div>
</div>
<!-- 端口映射 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
端口映射
</h4>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">
映射规则
<span class="text-xs text-slate-500 ml-1">格式: 协议://监听地址-虚拟IP-目标地址</span>
</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.port_mapping" :key="idx" class="flex gap-2">
<input
v-model="formData.port_mapping[idx]"
type="text"
placeholder="例如: tcp://0.0.0.0:81-10.0.0.2-10.0.0.2:80"
class="input flex-1 font-mono"
/>
<button @click="formData.port_mapping.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.port_mapping.push('')"
:class="addBtnClass"
>
+ 添加映射规则
</button>
</div>
</div>
<label :class="toggleLabelClass">
<div class="flex-1">
<div class="text-sm font-medium text-slate-800 dark:text-white">允许作为映射出口</div>
<div class="text-xs text-slate-400 mt-0.5">允许其他设备使用本机作跳板</div>
</div>
<input v-model="formData.allow_mapping" type="checkbox" :class="checkboxClass" />
</label>
</div>
</div>
<!-- 设备配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
设备配置
</h4>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备名称</label>
<input v-model="formData.device_name" type="text" placeholder="默认为主机名" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">设备ID</label>
<input v-model="formData.device_id" type="text" placeholder="自动生成" class="input" />
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">虚拟网卡名</label>
<input v-model="formData.tun_name" type="text" placeholder="默认为vnt-tun" class="input" />
</div>
</div>
</div>
<!-- STUN配置 -->
<div class="card">
<h4 :class="sectionTitleClass">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
STUN配置 (高级)
</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">UDP STUN服务器</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.udp_stun" :key="idx" class="flex gap-2">
<input v-model="formData.udp_stun[idx]" type="text" placeholder="例如: stun.l.google.com:19302" class="input flex-1" />
<button @click="formData.udp_stun.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.udp_stun.push('')"
:class="addBtnClass"
>
+ 添加UDP STUN
</button>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-600 dark:text-slate-300">TCP STUN服务器</label>
<div class="space-y-2">
<div v-for="(item, idx) in formData.tcp_stun" :key="idx" class="flex gap-2">
<input v-model="formData.tcp_stun[idx]" type="text" placeholder="例如: stun.nextcloud.com:443" class="input flex-1" />
<button @click="formData.tcp_stun.splice(idx, 1)" :class="removeBtnClass">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="formData.tcp_stun.push('')"
:class="addBtnClass"
>
+ 添加TCP STUN
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- TOML模式 -->
<div v-show="editMode === 'toml'" class="h-full">
<textarea
v-model="editorContent"
class="h-full w-full resize-none bg-slate-50 p-4 font-mono text-sm text-slate-800 focus:outline-none dark:bg-slate-950 dark:text-slate-200"
spellcheck="false"
placeholder="# 在此处输入 TOML 配置..."
></textarea>
</div>
</template>
<template #footer>
<div class="flex-1 text-left text-xs text-slate-500">
<span v-if="editMode === 'form'">填写完成后保存即可生成配置文件</span>
<span v-else>* 请使用标准 TOML 格式</span>
</div>
<button class="btn-ghost" @click="emit('close')">取消</button>
<button class="btn-primary" @click="handleSave">保存配置</button>
</template>
</AppModal>
</template>