feat(web): share network configs via QR code

This commit is contained in:
lbl
2026-08-23 11:03:28 +08:00
parent 1acb5e8a86
commit 619a5bcf18
4 changed files with 317 additions and 4 deletions
+23
View File
@@ -0,0 +1,23 @@
export const NETWORK_QR_TYPE = "vnt-network";
export const NETWORK_QR_VERSION = 1;
export const buildNetworkQrPayload = (form) => {
const networkCode = String(form.network_code || "").trim();
const servers = (form.server || []).map((server) => String(server).trim()).filter(Boolean);
const mtu = Number(form.mtu || 1380);
if (!networkCode) throw new Error("组网编号不能为空");
if (servers.length === 0) throw new Error("服务器地址不能为空");
if (!Number.isInteger(mtu) || mtu < 576 || mtu > 9000) {
throw new Error("MTU 必须在 576-9000 之间");
}
return {
type: NETWORK_QR_TYPE,
version: NETWORK_QR_VERSION,
network_code: networkCode,
server: servers,
mtu,
password: String(form.password || ""),
};
};
+77 -4
View File
@@ -1,16 +1,25 @@
<script setup>
import { ref, onMounted } from "vue";
import QRCode from "qrcode";
import { useAppStore } from "../stores/app";
import { useUiStore } from "../stores/ui";
import { deleteConfig } from "../api";
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) => {
@@ -34,6 +43,29 @@ 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: "删除配置",
@@ -79,19 +111,30 @@ onMounted(() => app.fetchConfigList());
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-0 top-0 rounded-bl bg-green-500 px-2 py-1 text-xs text-white"
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-0 top-0 rounded-bl bg-indigo-500 px-2 py-1 text-xs text-white"
class="absolute right-12 top-3 rounded bg-indigo-500 px-2 py-1 text-xs text-white"
>
启动中
</div>
<div class="flex items-start">
<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
@@ -131,6 +174,36 @@ onMounted(() => app.fetchConfigList());
@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>