228 lines
8.5 KiB
Vue
228 lines
8.5 KiB
Vue
<script setup>
|
||
import { ref, onMounted } from "vue";
|
||
import QRCode from "qrcode";
|
||
import { useAppStore } from "../stores/app";
|
||
import { useUiStore } from "../stores/ui";
|
||
import { deleteConfig, getConfig } from "../api";
|
||
import EmptyState from "../components/EmptyState.vue";
|
||
import AppModal from "../components/AppModal.vue";
|
||
import ConfigEditor from "./ConfigEditor.vue";
|
||
import { parseTomlToForm } from "../utils/toml";
|
||
import { buildNetworkQrPayload } from "../utils/networkQr";
|
||
|
||
const app = useAppStore();
|
||
const ui = useUiStore();
|
||
|
||
const showEditor = ref(false);
|
||
const editorFileName = ref(null);
|
||
const showQr = ref(false);
|
||
const qrLoading = ref(false);
|
||
const qrImage = ref("");
|
||
const qrConfig = ref(null);
|
||
const qrError = ref("");
|
||
|
||
// 配置对应的实例运行状态(无实例返回 null)
|
||
const instStatus = (fileName) => {
|
||
const inst = app.instanceList.find((i) => i.file_name === fileName);
|
||
return inst ? inst.status : null;
|
||
};
|
||
|
||
const cardClass = (fileName) => {
|
||
const status = instStatus(fileName);
|
||
if (status === "running") return "ring-2 ring-green-500/60";
|
||
if (status === "starting") return "ring-2 ring-indigo-500/60";
|
||
return "";
|
||
};
|
||
|
||
const openEditor = (fileName) => {
|
||
editorFileName.value = fileName;
|
||
showEditor.value = true;
|
||
};
|
||
|
||
const onSaved = () => {
|
||
app.fetchConfigList();
|
||
};
|
||
|
||
const openQr = async (fileName) => {
|
||
showQr.value = true;
|
||
qrLoading.value = true;
|
||
qrImage.value = "";
|
||
qrConfig.value = null;
|
||
qrError.value = "";
|
||
try {
|
||
const form = parseTomlToForm(await getConfig(fileName));
|
||
const payload = buildNetworkQrPayload(form);
|
||
qrConfig.value = payload;
|
||
qrImage.value = await QRCode.toDataURL(JSON.stringify(payload), {
|
||
errorCorrectionLevel: "M",
|
||
width: 360,
|
||
margin: 2,
|
||
color: { dark: "#020617", light: "#ffffff" },
|
||
});
|
||
} catch (e) {
|
||
qrError.value = e.message || "二维码生成失败";
|
||
} finally {
|
||
qrLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (fileName) => {
|
||
const ok = await ui.confirm({
|
||
title: "删除配置",
|
||
message: `确定要删除配置 ${fileName} 吗?`,
|
||
danger: true,
|
||
confirmText: "删除",
|
||
});
|
||
if (!ok) return;
|
||
try {
|
||
await deleteConfig(fileName);
|
||
ui.toast.success("配置已删除");
|
||
app.fetchConfigList();
|
||
} catch (e) {
|
||
ui.toast.error(e.message);
|
||
}
|
||
};
|
||
|
||
onMounted(() => app.fetchConfigList());
|
||
</script>
|
||
|
||
<template>
|
||
<div class="space-y-6">
|
||
<div class="flex items-end justify-between gap-4">
|
||
<div>
|
||
<h1 class="page-title">配置</h1>
|
||
<p class="page-subtitle">管理组网配置文件</p>
|
||
</div>
|
||
<button class="btn-primary" @click="openEditor(null)">
|
||
<svg class="h-4 w-4" 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>
|
||
|
||
<EmptyState v-if="app.configList.length === 0" text="暂无配置,点击右上角新建" />
|
||
|
||
<div v-else class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||
<div
|
||
v-for="cfg in app.configList"
|
||
:key="cfg.file_name"
|
||
:class="cardClass(cfg.file_name)"
|
||
class="card group relative flex min-h-[140px] cursor-pointer flex-col justify-between overflow-hidden"
|
||
@click="openEditor(cfg.file_name)"
|
||
>
|
||
<button
|
||
class="absolute right-3 top-3 z-10 rounded-lg p-1.5 text-slate-400 transition hover:bg-indigo-50 hover:text-indigo-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:hover:bg-indigo-500/10 dark:hover:text-indigo-400"
|
||
type="button"
|
||
title="显示加入网络二维码"
|
||
aria-label="显示加入网络二维码"
|
||
@click.stop="openQr(cfg.file_name)"
|
||
>
|
||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h7v7H3V3zm11 0h7v7h-7V3zM3 14h7v7H3v-7zm12 0h2m4 0v2m-6 2h2v3m4-3v3h-2" />
|
||
</svg>
|
||
</button>
|
||
<div
|
||
v-if="instStatus(cfg.file_name) === 'running'"
|
||
class="absolute right-12 top-3 rounded bg-green-500 px-2 py-1 text-xs text-white"
|
||
>
|
||
运行中
|
||
</div>
|
||
<div
|
||
v-else-if="instStatus(cfg.file_name) === 'starting'"
|
||
class="absolute right-12 top-3 rounded bg-indigo-500 px-2 py-1 text-xs text-white"
|
||
>
|
||
启动中
|
||
</div>
|
||
<div class="flex items-start pr-28">
|
||
<div class="mr-3 mt-1 rounded-lg bg-indigo-50 p-2 text-indigo-600 dark:bg-indigo-500/10 dark:text-indigo-400">
|
||
<svg class="h-6 w-6" 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>
|
||
</div>
|
||
<div class="overflow-hidden">
|
||
<h3 class="truncate text-lg font-bold text-slate-900 dark:text-white" :title="cfg.config_name">
|
||
{{ cfg.config_name || "Unnamed" }}
|
||
</h3>
|
||
<p class="truncate font-mono text-xs text-slate-400" :title="cfg.file_name">
|
||
{{ cfg.file_name }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="config-card-actions mt-4 flex justify-end transition-opacity">
|
||
<button
|
||
class="mr-4 text-sm text-indigo-600 hover:text-indigo-500 dark:text-indigo-400"
|
||
@click.stop="openEditor(cfg.file_name)"
|
||
>
|
||
编辑
|
||
</button>
|
||
<button class="text-sm text-red-500 hover:text-red-400" @click.stop="handleDelete(cfg.file_name)">
|
||
删除
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<ConfigEditor
|
||
:show="showEditor"
|
||
:file-name="editorFileName"
|
||
@close="showEditor = false"
|
||
@saved="onSaved"
|
||
/>
|
||
|
||
<AppModal :show="showQr" panel-class="w-full max-w-md" @close="showQr = false">
|
||
<template #header>
|
||
<div>
|
||
<h2 class="text-lg font-bold text-slate-900 dark:text-white">扫码加入网络</h2>
|
||
<p class="mt-1 text-xs text-slate-500 dark:text-slate-400">使用 VNT 安卓客户端扫描</p>
|
||
</div>
|
||
<button class="btn-ghost btn-sm" type="button" @click="showQr = false">关闭</button>
|
||
</template>
|
||
<template #body>
|
||
<div class="p-6">
|
||
<div v-if="qrLoading" class="py-20 text-center text-sm muted">正在生成二维码…</div>
|
||
<div v-else-if="qrError" class="rounded-lg bg-red-50 p-4 text-sm text-red-600 dark:bg-red-500/10 dark:text-red-400">
|
||
{{ qrError }}
|
||
</div>
|
||
<div v-else-if="qrConfig" class="space-y-5">
|
||
<div class="mx-auto w-fit rounded-xl border border-slate-200 bg-white p-3 shadow-sm">
|
||
<img :src="qrImage" class="h-auto w-full max-w-[320px]" alt="VNT 加入网络二维码" />
|
||
</div>
|
||
<dl class="space-y-2 rounded-lg bg-slate-50 p-4 text-sm dark:bg-slate-800/60">
|
||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">组网编号</dt><dd class="min-w-0 break-all font-mono text-slate-900 dark:text-white">{{ qrConfig.network_code }}</dd></div>
|
||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">服务器</dt><dd class="min-w-0 break-all font-mono text-slate-900 dark:text-white">{{ qrConfig.server.join(", ") }}</dd></div>
|
||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">MTU</dt><dd class="font-mono text-slate-900 dark:text-white">{{ qrConfig.mtu }}</dd></div>
|
||
<div class="flex gap-3"><dt class="w-20 shrink-0 muted">加密密码</dt><dd class="text-slate-900 dark:text-white">{{ qrConfig.password ? "已包含" : "未设置" }}</dd></div>
|
||
</dl>
|
||
<p class="text-xs leading-5 text-amber-600 dark:text-amber-400">二维码包含组网凭据,请仅分享给可信设备。</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</AppModal>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* 触屏和窄屏设备没有可靠的 hover,操作按钮必须直接可见。 */
|
||
.config-card-actions {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* 只有宽屏且确实支持精细悬停的设备才使用移入显示。 */
|
||
@media (min-width: 640px) and (hover: hover) and (pointer: fine) {
|
||
.config-card-actions {
|
||
opacity: 0;
|
||
}
|
||
|
||
.group:hover .config-card-actions,
|
||
.group:focus-within .config-card-actions {
|
||
opacity: 1;
|
||
}
|
||
}
|
||
</style>
|