refactor(vnt-web): 前端重构为 Vite+Vue3+Pinia 工程并重新设计 UI

- 单文件 index.html 拆分为 vnt-web/ui(pnpm+Vite+Vue3 SFC+Pinia+Tailwind v4),构建产物输出到 static/
- UI 重新设计:浅色简洁风(可切换深色模式)、顶部导航、新增总览仪表盘首页
- 自定义弹窗/toast/确认框替换全部原生 alert/confirm,交互与过渡统一
- 响应式布局:移动端汉堡导航,表格与卡片自适应
- 实例卡片移除日志按钮;启动组网默认选中第一个配置
This commit is contained in:
lbl
2026-08-22 00:53:51 +08:00
parent 292d54fbc3
commit 7513465b61
37 changed files with 5090 additions and 3227 deletions
+609
View File
@@ -0,0 +1,609 @@
<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>