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:
@@ -0,0 +1,65 @@
|
||||
<script setup>
|
||||
import { watch, onUnmounted } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
// 点击遮罩是否关闭
|
||||
maskClosable: { type: Boolean, default: true },
|
||||
// ESC 是否关闭
|
||||
escClosable: { type: Boolean, default: true },
|
||||
panelClass: { type: String, default: "w-full max-w-2xl" },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const onKeydown = (e) => {
|
||||
if (props.escClosable && e.key === "Escape") emit("close");
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(val) => {
|
||||
if (val) window.addEventListener("keydown", onKeydown);
|
||||
else window.removeEventListener("keydown", onKeydown);
|
||||
},
|
||||
);
|
||||
|
||||
onUnmounted(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
const onMaskClick = () => {
|
||||
if (props.maskClosable) emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 p-4 backdrop-blur-sm"
|
||||
@click.self="onMaskClick"
|
||||
>
|
||||
<div
|
||||
class="modal-panel flex max-h-[90vh] flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-slate-900"
|
||||
:class="panelClass"
|
||||
>
|
||||
<div
|
||||
v-if="$slots.header"
|
||||
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<div class="custom-scrollbar min-h-0 flex-1 overflow-y-auto">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.footer"
|
||||
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 bg-slate-50/60 px-6 py-4 dark:border-slate-700 dark:bg-slate-800/50"
|
||||
>
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
|
||||
// 全局 NAT tooltip(保留原定位/悬停逻辑)
|
||||
const tooltipState = ref({ show: false, x: 0, y: 0, info: null });
|
||||
let tooltipHideTimer = null;
|
||||
|
||||
const showPeerTooltip = (event, peer) => {
|
||||
if (!peer.nat_info) return;
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
tooltipState.value = {
|
||||
show: true,
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10,
|
||||
info: peer.nat_info,
|
||||
};
|
||||
};
|
||||
|
||||
const hidePeerTooltip = () => {
|
||||
tooltipHideTimer = setTimeout(() => {
|
||||
tooltipState.value.show = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const onTooltipEnter = () => {
|
||||
if (tooltipHideTimer) {
|
||||
clearTimeout(tooltipHideTimer);
|
||||
tooltipHideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onTooltipLeave = () => {
|
||||
tooltipState.value.show = false;
|
||||
};
|
||||
|
||||
defineExpose({ showPeerTooltip, hidePeerTooltip });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div
|
||||
v-if="tooltipState.show"
|
||||
:style="{ top: tooltipState.y + 'px', left: tooltipState.x + 'px' }"
|
||||
class="fixed z-[9999] mt-1 -translate-x-1/2 transform"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
>
|
||||
<div
|
||||
class="w-auto min-w-[260px] max-w-[320px] rounded-lg border border-slate-200 bg-white p-4 text-left text-sm text-slate-600 shadow-2xl dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
<div
|
||||
class="absolute -top-2 left-1/2 h-4 w-4 -translate-x-1/2 rotate-45 transform border-l border-t border-slate-200 bg-white dark:border-slate-600 dark:bg-slate-800"
|
||||
></div>
|
||||
<div class="relative z-10 mb-2 flex items-center justify-between border-b border-slate-200 pb-2 dark:border-slate-600">
|
||||
<span class="text-xs font-bold uppercase text-slate-400">NAT Type</span>
|
||||
<span class="badge-green border border-green-200 dark:border-green-800">{{
|
||||
tooltipState.info.nat_type
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="tooltipState.info.public_ips && tooltipState.info.public_ips.length > 0"
|
||||
class="relative z-10 mb-3"
|
||||
>
|
||||
<span class="mb-1 block text-xs text-slate-400">Public IPv4:</span>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="pip in tooltipState.info.public_ips"
|
||||
:key="pip"
|
||||
class="rounded border border-slate-200 bg-slate-100 px-1.5 py-0.5 font-mono text-xs tabular-nums text-slate-600 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200"
|
||||
>{{ pip }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tooltipState.info.ipv6" class="relative z-10">
|
||||
<span class="mb-1 block text-xs text-slate-400">IPv6:</span>
|
||||
<div
|
||||
class="whitespace-normal break-all rounded border border-slate-200 bg-slate-50 p-1.5 font-mono text-xs leading-relaxed text-slate-600 dark:border-slate-700/50 dark:bg-slate-900/50 dark:text-slate-200"
|
||||
>
|
||||
{{ tooltipState.info.ipv6 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { useUiStore } from "../stores/ui";
|
||||
import AppModal from "./AppModal.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppModal
|
||||
:show="ui.confirmState.show"
|
||||
panel-class="w-full max-w-sm"
|
||||
@close="ui.confirmCancel"
|
||||
>
|
||||
<template #header>
|
||||
<h3 class="text-lg font-bold text-slate-900 flex items-center gap-2 dark:text-white">
|
||||
<svg
|
||||
v-if="ui.confirmState.danger"
|
||||
class="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ ui.confirmState.title }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="px-6 py-5 text-sm text-slate-600 break-all dark:text-slate-300">
|
||||
{{ ui.confirmState.message }}
|
||||
</p>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button class="btn-ghost" @click="ui.confirmCancel">取消</button>
|
||||
<button
|
||||
:class="ui.confirmState.danger ? 'btn-danger' : 'btn-primary'"
|
||||
@click="ui.confirmOk"
|
||||
>
|
||||
{{ ui.confirmState.confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</AppModal>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
text: { type: String, default: "暂无数据" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-12 text-center text-slate-500 dark:text-slate-400">
|
||||
<svg
|
||||
class="w-12 h-12 mx-auto mb-3 text-slate-300 dark:text-slate-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
<p>{{ text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const props = defineProps({
|
||||
inst: { type: Object, required: true },
|
||||
// 是否显示选中高亮(实例页用)
|
||||
selectable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const info = computed(() => app.infoOf(props.inst.file_name));
|
||||
const loading = computed(() => !!app.loadingMap[props.inst.file_name]);
|
||||
|
||||
const statusBadgeClass = (status) =>
|
||||
status === "running" ? "badge-green" : status === "starting" ? "badge-blue" : "badge-gray";
|
||||
const statusText = (status) =>
|
||||
status === "running" ? "运行中" : status === "starting" ? "启动中" : "已停止";
|
||||
|
||||
const select = () => {
|
||||
if (props.selectable) app.selectedInstance = props.inst.file_name;
|
||||
};
|
||||
|
||||
const confirmStop = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "停止组网",
|
||||
message: `确定要停止 ${props.inst.file_name} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "停止",
|
||||
});
|
||||
if (ok) app.stopVnt(props.inst.file_name);
|
||||
};
|
||||
|
||||
const confirmRestart = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "重启组网",
|
||||
message: `确定要重启 ${props.inst.file_name} 吗?`,
|
||||
});
|
||||
if (ok) app.restartVnt(props.inst.file_name);
|
||||
};
|
||||
|
||||
const confirmDismiss = async () => {
|
||||
const ok = await ui.confirm({
|
||||
title: "移除实例",
|
||||
message: `确定要移除已停止的实例 ${props.inst.file_name} 吗?`,
|
||||
danger: true,
|
||||
confirmText: "移除",
|
||||
});
|
||||
if (ok) app.dismissInstance(props.inst.file_name);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card"
|
||||
:class="[
|
||||
selectable ? 'cursor-pointer' : '',
|
||||
selectable && app.selectedInstance === inst.file_name
|
||||
? 'ring-2 ring-indigo-500 dark:ring-indigo-400'
|
||||
: '',
|
||||
]"
|
||||
@click="select"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="truncate text-base font-bold text-slate-900 dark:text-white" :title="inst.file_name">
|
||||
{{ inst.config_name || inst.file_name }}
|
||||
</h3>
|
||||
<span class="shrink-0" :class="statusBadgeClass(inst.status)">{{ statusText(inst.status) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-sm">
|
||||
<span class="muted">虚拟 IP:</span>
|
||||
<span class="ml-1 font-mono tabular-nums text-indigo-600 dark:text-indigo-400">{{
|
||||
info.ip || "-"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-4 text-xs muted">
|
||||
<span>
|
||||
在线
|
||||
<span class="font-bold tabular-nums text-blue-600 dark:text-blue-400">{{
|
||||
info.online_client_num || 0
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
直连
|
||||
<span class="font-bold tabular-nums text-green-600 dark:text-green-400">{{
|
||||
info.direct_client_num || 0
|
||||
}}</span>
|
||||
</span>
|
||||
<span>
|
||||
离线
|
||||
<span class="font-bold tabular-nums text-slate-400">{{ info.offline_client_num || 0 }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap justify-end gap-2">
|
||||
<button
|
||||
v-if="inst.status === 'running' || inst.status === 'stopped'"
|
||||
class="btn-primary btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmRestart"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
{{ inst.status === "stopped" ? "重新启动" : "重启" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status !== 'stopped'"
|
||||
class="btn-danger btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmStop"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
v-if="inst.status === 'stopped'"
|
||||
class="btn-ghost btn-sm"
|
||||
:disabled="loading"
|
||||
@click.stop="confirmDismiss"
|
||||
>
|
||||
<span v-if="loading" class="animate-spin">⟳</span>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, nextTick } from "vue";
|
||||
import { formatSpeed, niceNumber } from "../utils/format";
|
||||
|
||||
const props = defineProps({
|
||||
history: { type: Object, required: true }, // { tx: [], rx: [] }
|
||||
size: { type: Number, default: 60 }, // 历史点数
|
||||
});
|
||||
|
||||
const canvasRef = ref(null);
|
||||
const maxLabel = ref("");
|
||||
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.value;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
const txArr = props.history ? props.history.tx : [];
|
||||
const rxArr = props.history ? props.history.rx : [];
|
||||
const HISTORY_SIZE = props.size;
|
||||
|
||||
// 高清适配
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
if (rect.width === 0) return;
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const isDark = document.documentElement.classList.contains("dark");
|
||||
const colors = isDark
|
||||
? { bg: "#0c1222", grid: "rgba(71, 85, 105, 0.3)", rx: "#60a5fa", rxFill: "rgba(96, 165, 250, 0.15)", tx: "#4ade80", txFill: "rgba(74, 222, 128, 0.15)" }
|
||||
: { bg: "#f8fafc", grid: "rgba(148, 163, 184, 0.35)", rx: "#3b82f6", rxFill: "rgba(59, 130, 246, 0.12)", tx: "#22c55e", txFill: "rgba(34, 197, 94, 0.12)" };
|
||||
|
||||
const padTop = 8,
|
||||
padBottom = 4,
|
||||
padLeft = 0,
|
||||
padRight = 0;
|
||||
const chartW = w - padLeft - padRight;
|
||||
const chartH = h - padTop - padBottom;
|
||||
|
||||
// 背景
|
||||
ctx.fillStyle = colors.bg;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// 计算Y轴最大值
|
||||
const allValues = [...txArr, ...rxArr];
|
||||
let maxVal = allValues.length > 0 ? Math.max(...allValues) : 0;
|
||||
if (maxVal < 1024) maxVal = 1024; // 最小1KB
|
||||
const niceMax = niceNumber(maxVal);
|
||||
|
||||
maxLabel.value = "峰值: " + formatSpeed(niceMax);
|
||||
|
||||
// 网格线
|
||||
const gridLines = 4;
|
||||
ctx.strokeStyle = colors.grid;
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= gridLines; i++) {
|
||||
const y = padTop + (chartH / gridLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft, y);
|
||||
ctx.lineTo(padLeft + chartW, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// 垂直网格线
|
||||
const vLines = 6;
|
||||
for (let i = 0; i <= vLines; i++) {
|
||||
const x = padLeft + (chartW / vLines) * i;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, padTop);
|
||||
ctx.lineTo(x, padTop + chartH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制曲线
|
||||
const drawLine = (data, strokeColor, fillColor) => {
|
||||
if (data.length < 2) return;
|
||||
const step = chartW / (HISTORY_SIZE - 1);
|
||||
const offset = HISTORY_SIZE - data.length;
|
||||
|
||||
// 填充区域
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(padLeft + offset * step, padTop + chartH);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.lineTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(padLeft + (offset + data.length - 1) * step, padTop + chartH);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = fillColor;
|
||||
ctx.fill();
|
||||
|
||||
// 线条
|
||||
ctx.beginPath();
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const x = padLeft + (offset + i) * step;
|
||||
const y = padTop + chartH - (data[i] / niceMax) * chartH;
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.strokeStyle = strokeColor;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
drawLine(rxArr, colors.rx, colors.rxFill);
|
||||
drawLine(txArr, colors.tx, colors.txFill);
|
||||
};
|
||||
|
||||
onMounted(() => nextTick(draw));
|
||||
|
||||
// 历史数据更新时重绘(数组原地 push/shift,监听引用内每个点)
|
||||
watch(
|
||||
() => [props.history?.tx?.length, props.history?.rx?.length, props.history?.tx?.at(-1), props.history?.rx?.at(-1)],
|
||||
() => nextTick(draw),
|
||||
);
|
||||
|
||||
defineExpose({ draw });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-center gap-4 mb-2 text-xs text-slate-400">
|
||||
<span class="flex items-center"
|
||||
><span class="inline-block w-3 h-0.5 bg-green-400 mr-1"></span>上传速度</span
|
||||
>
|
||||
<span class="flex items-center"
|
||||
><span class="inline-block w-3 h-0.5 bg-blue-400 mr-1"></span>下载速度</span
|
||||
>
|
||||
<span class="ml-auto">{{ maxLabel }}</span>
|
||||
</div>
|
||||
<canvas ref="canvasRef" class="rounded w-full block h-[150px]"></canvas>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useAppStore } from "../stores/app";
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
// 启动组网面板:选择配置 + 启动,总览页与实例页复用
|
||||
const app = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const localSelectedConfig = ref("");
|
||||
|
||||
// 只列出没有对应实例的配置(同一配置最多一个实例)
|
||||
const availableConfigs = computed(() =>
|
||||
app.configList.filter(
|
||||
(cfg) => !app.instanceList.some((inst) => inst.file_name === cfg.file_name),
|
||||
),
|
||||
);
|
||||
|
||||
// 默认选中第一个可用配置;当前选中项不可用时(如已启动)自动切到下一个
|
||||
watch(
|
||||
availableConfigs,
|
||||
(list) => {
|
||||
if (!list.some((cfg) => cfg.file_name === localSelectedConfig.value)) {
|
||||
localSelectedConfig.value = list.length ? list[0].file_name : "";
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const handleStart = () => {
|
||||
if (!localSelectedConfig.value) {
|
||||
ui.toast.error("请先选择一个配置");
|
||||
return;
|
||||
}
|
||||
app.startVnt(localSelectedConfig.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<h2 class="mb-4 text-base font-bold text-slate-900 dark:text-white">启动组网</h2>
|
||||
|
||||
<div v-if="app.configList.length === 0" class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm muted">还没有任何配置,先创建一个组网配置吧。</p>
|
||||
<router-link to="/config" class="btn-primary btn-sm">去新建配置</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else-if="availableConfigs.length === 0" class="text-sm muted">
|
||||
所有配置均已启动。
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div class="flex-1">
|
||||
<label class="mb-1.5 block text-xs font-medium muted">选择配置</label>
|
||||
<select v-model="localSelectedConfig" class="input">
|
||||
<option value="" disabled>请选择配置...</option>
|
||||
<option v-for="cfg in availableConfigs" :key="cfg.file_name" :value="cfg.file_name">
|
||||
{{ cfg.config_name || cfg.file_name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary px-8"
|
||||
:disabled="!localSelectedConfig || !!app.loadingMap[localSelectedConfig]"
|
||||
@click="handleStart"
|
||||
>
|
||||
<span v-if="app.loadingMap[localSelectedConfig]" class="animate-spin">⟳</span>
|
||||
启动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
status: { type: String, default: "stopped" },
|
||||
size: { type: String, default: "w-2.5 h-2.5" },
|
||||
});
|
||||
|
||||
const dotClass = (status) =>
|
||||
status === "running"
|
||||
? "bg-green-500"
|
||||
: status === "starting"
|
||||
? "bg-blue-500 animate-pulse"
|
||||
: "bg-slate-400 dark:bg-slate-500";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="rounded-full inline-block shrink-0" :class="[size, dotClass(status)]" />
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup>
|
||||
import { useUiStore } from "../stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
// 浅色卡片 + 左侧色条
|
||||
const barClass = (type) =>
|
||||
type === "success" ? "bg-green-500" : type === "error" ? "bg-red-500" : "bg-indigo-500";
|
||||
|
||||
const iconClass = (type) =>
|
||||
type === "success"
|
||||
? "text-green-500"
|
||||
: type === "error"
|
||||
? "text-red-500"
|
||||
: "text-indigo-500";
|
||||
|
||||
const iconPath = (type) =>
|
||||
type === "success"
|
||||
? "M5 13l4 4L19 7"
|
||||
: type === "error"
|
||||
? "M6 18L18 6M6 6l12 12"
|
||||
: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<teleport to="body">
|
||||
<div class="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col items-end gap-2">
|
||||
<transition-group name="toast">
|
||||
<div
|
||||
v-for="t in ui.toasts"
|
||||
:key="t.id"
|
||||
class="pointer-events-auto flex max-w-sm items-stretch overflow-hidden rounded-lg border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||
>
|
||||
<span class="w-1 shrink-0" :class="barClass(t.type)"></span>
|
||||
<div class="flex items-center gap-2 px-4 py-2.5">
|
||||
<svg class="h-4 w-4 shrink-0" :class="iconClass(t.type)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="iconPath(t.type)" />
|
||||
</svg>
|
||||
<span class="break-all text-sm text-slate-700 dark:text-slate-200">{{ t.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user