初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
// Package engineerui embeds the production Wails frontend.
package engineerui
import "embed"
//go:embed all:dist
var Assets embed.FS
+49
View File
@@ -0,0 +1,49 @@
package engineerui
import (
"io"
"net/http"
"net/http/httptest"
"regexp"
"testing"
wailsassetserver "github.com/wailsapp/wails/v2/pkg/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
)
func TestEmbeddedProductionAssetsAreServedByWails(t *testing.T) {
handler, err := wailsassetserver.NewAssetHandler(assetserver.Options{Assets: Assets}, nil)
if err != nil {
t.Fatalf("create Wails asset handler: %v", err)
}
index := getEmbeddedAsset(t, handler, "/", "text/html")
references := regexp.MustCompile(`(?:src|href)="(/assets/[^"]+)"`).FindAllStringSubmatch(string(index), -1)
if len(references) < 2 {
t.Fatalf("expected JavaScript and CSS references in index.html, got %q", index)
}
for _, reference := range references {
getEmbeddedAsset(t, handler, reference[1], "")
}
}
func getEmbeddedAsset(t *testing.T, handler http.Handler, path string, expectedContentType string) []byte {
t.Helper()
request := httptest.NewRequest(http.MethodGet, "http://wails.localhost"+path, nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s returned %d: %s", path, response.Code, response.Body.String())
}
if expectedContentType != "" && response.Header().Get("Content-Type") != expectedContentType+"; charset=utf-8" {
t.Fatalf("GET %s returned Content-Type %q", path, response.Header().Get("Content-Type"))
}
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("read GET %s response: %v", path, err)
}
if len(body) == 0 {
t.Fatalf("GET %s returned an empty body", path)
}
return body
}
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f7f8fc" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23394ff0'/%3E%3Cpath d='M9 21V11h4v7h10v3z' fill='white'/%3E%3C/svg%3E" />
<title>RemLink Engineer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { checkCIDRs, createSession, disconnectSession, getState, saveSiteCIDRs } from './api'
import type { EngineerState, SiteSummary } from './types'
import { errorLabel, levelLabel, statusLabel } from './zh-cn'
const state = ref<EngineerState | null>(null)
const page = ref<'connection' | 'session' | 'logs' | 'settings'>('connection')
const selectedSiteID = ref('')
const cidrs = ref<string[]>([])
const cidrInput = ref('')
const busy = ref(false)
const error = ref('')
const preflightState = ref<'checking' | 'pass' | 'fail'>('checking')
const preflightMessage = ref('正在检查本地路由…')
let timer = 0
let unsubscribe: (() => void) | undefined
const active = computed(() => state.value?.session.status === 'ACTIVE')
const sessionBusy = computed(() => {
const status = state.value?.session.status
return status === 'CREATING' || status === 'PREPARING_SITE' || status === 'READY' || status === 'ACTIVE' || status === 'STOPPING'
})
const selectedSite = computed<SiteSummary | undefined>(() => state.value?.sites.find(site => site.node_id === selectedSiteID.value))
const canConnect = computed(() => !busy.value && !sessionBusy.value && state.value?.serverConnected && state.value.controlConnected && preflightState.value === 'pass' && selectedSite.value?.online && selectedSite.value.remote_subnet_capability && cidrs.value.length > 0)
function formatBytes(value: number) {
if (value < 1024) return `${value} B`
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`
return `${(value / 1024 ** 2).toFixed(2)} MB`
}
function formatTime(value?: string) { return value ? new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }).format(new Date(value)) : '—' }
async function addCIDR() {
const value = cidrInput.value.trim()
if (!value || cidrs.value.includes(value)) return
cidrs.value.push(value)
cidrInput.value = ''
if (await runPreflight()) await persistCurrentProfile()
}
async function removeCIDR(value: string) {
cidrs.value = cidrs.value.filter(item => item !== value)
const passed = await runPreflight()
if (passed || cidrs.value.length === 0) await persistCurrentProfile()
}
async function runPreflight() {
if (cidrs.value.length === 0) {
preflightState.value = 'fail'; preflightMessage.value = '请为当前现场添加至少一个网段'
return false
}
preflightState.value = 'checking'; preflightMessage.value = '正在检查本地路由…'
try { await checkCIDRs(Array.from(cidrs.value)); preflightState.value = 'pass'; preflightMessage.value = '通过,无冲突'; return true }
catch (cause) { preflightState.value = 'fail'; preflightMessage.value = errorLabel(cause); return false }
}
async function persistCurrentProfile() {
const siteID = selectedSiteID.value
if (!siteID || !state.value) return
try {
await saveSiteCIDRs(siteID, Array.from(cidrs.value))
if (cidrs.value.length === 0) delete state.value.siteCIDRs[siteID]
else state.value.siteCIDRs[siteID] = Array.from(cidrs.value)
} catch (cause) {
error.value = errorLabel(cause)
}
}
function siteCIDRCount(siteID: string) { return state.value?.siteCIDRs[siteID]?.length ?? 0 }
async function loadSelectedSiteProfile() {
if (sessionBusy.value) return
cidrInput.value = ''
cidrs.value = Array.from(state.value?.siteCIDRs[selectedSiteID.value] ?? [])
await runPreflight()
}
async function refresh() {
state.value = await getState()
if (['CREATING', 'PREPARING_SITE', 'READY', 'ACTIVE', 'STOPPING'].includes(state.value.session.status) && state.value.session.cidrs.length > 0) {
cidrs.value = Array.from(state.value.session.cidrs)
}
if (!selectedSiteID.value) selectedSiteID.value = state.value.sites.find(site => site.online)?.node_id ?? ''
}
async function connect() {
if (!canConnect.value) return
busy.value = true; error.value = ''
try { await createSession(selectedSiteID.value, cidrs.value); await refresh() } catch (cause) { error.value = errorLabel(cause) } finally { busy.value = false }
}
async function disconnect() {
busy.value = true; error.value = ''
try { await disconnectSession(); await refresh() } catch (cause) { error.value = errorLabel(cause) } finally { busy.value = false }
}
onMounted(async () => {
try {
await refresh()
await runPreflight()
const safeRefresh = () => { void refresh().catch(cause => { error.value = errorLabel(cause) }) }
timer = window.setInterval(safeRefresh, 2000)
unsubscribe = window.runtime?.EventsOn('remlink:state', safeRefresh)
} catch (cause) {
error.value = errorLabel(cause)
}
})
onBeforeUnmount(() => { clearInterval(timer); unsubscribe?.() })
watch(selectedSiteID, () => { void loadSelectedSiteProfile() })
</script>
<template>
<div class="app-shell" v-if="state">
<aside class="sidebar">
<div class="brand">RemLink<span>Engineer</span></div>
<nav aria-label="主导航">
<button class="nav-item" :class="{selected:page==='connection'}" @click="page='connection'"><Icon name="link"/>连接</button>
<button class="nav-item" :class="{selected:page==='session'}" @click="page='session'"><Icon name="session"/>会话</button>
<button class="nav-item" :class="{selected:page==='logs'}" @click="page='logs'"><Icon name="logs"/>日志</button>
<button class="nav-item" :class="{selected:page==='settings'}" @click="page='settings'"><Icon name="settings"/>设置</button>
</nav>
<div class="sidebar-foot"><span class="version">v{{ state.version }}</span><span>IPv4 · netstack</span></div>
</aside>
<main>
<header class="status-strip">
<div><i :class="state.serverConnected ? 'healthy' : 'offline'"></i>服务器 <strong>{{ state.serverConnected ? '已连接' : '未连接' }}</strong></div>
<div><i class="healthy"></i>Overlay <strong>{{ state.overlayIP || '配置中' }}</strong></div>
<div><i :class="state.controlConnected ? 'healthy' : 'offline'"></i>Control <strong>{{ state.controlConnected ? '在线' : '离线' }}</strong></div>
</header>
<div class="workspace">
<section v-show="page==='connection'" class="connection-path" aria-label="当前连接路径">
<div class="endpoint"><span class="endpoint-icon"><Icon name="monitor"/></span><div><b>Engineer</b><small>{{ state.overlayIP }}</small></div></div>
<div class="path-line"><span></span><i></i><i></i><i></i><span></span></div>
<div class="endpoint site"><span class="endpoint-icon"><Icon name="server"/></span><div><b>{{ selectedSite?.name || '选择现场' }}</b><small>{{ selectedSite?.overlay_ip || '—' }}</small></div></div>
</section>
<div v-show="page==='connection'" class="setup-grid">
<section class="panel sites-panel">
<div class="panel-heading"><h2>现场节点</h2><span>{{ state.sites.filter(site => site.online).length }} 个在线</span></div>
<div class="table-head"><span>站点名称</span><span>状态</span><span>Overlay IP</span><span>Remote Subnet</span><span>LastSeen</span></div>
<button v-for="site in state.sites" :key="site.node_id" class="site-row" :class="{ selected: selectedSiteID === site.node_id, disabled: sessionBusy && selectedSiteID !== site.node_id }" :disabled="sessionBusy && selectedSiteID !== site.node_id" @click="selectedSiteID = site.node_id">
<span><i :class="site.online ? 'healthy' : 'offline'"></i>{{ site.name }}</span>
<span :class="site.online ? 'good-text' : 'muted'">{{ site.online ? '在线' : '离线' }}</span>
<span>{{ site.overlay_ip || '—' }}</span>
<span :class="site.remote_subnet_capability ? 'good-text' : 'muted'">{{ site.remote_subnet_capability ? `可用 · 已存 ${siteCIDRCount(site.node_id)}` : '不可用' }}</span>
<span>{{ formatTime(site.last_seen) }}</span>
</button>
<button class="primary wide" :disabled="!canConnect" @click="connect">{{ busy ? '正在建立' : '连接现场' }}</button>
</section>
<section class="panel cidr-panel">
<div class="panel-heading"><div><h2>远程网段</h2><small>{{ selectedSite?.name ? `当前现场:${selectedSite.name}` : '请先选择现场' }}</small></div><button class="text-action" @click="addCIDR"><Icon name="plus"/>添加网段</button></div>
<p class="profile-hint">每个现场独立保存切换现场时自动加载建立会话时只发送当前现场的网段</p>
<label for="cidr">Remote CIDR</label>
<form @submit.prevent="addCIDR"><input id="cidr" v-model="cidrInput" placeholder="例如 192.168.13.0/24" :disabled="sessionBusy"/><button class="secondary" :disabled="sessionBusy || !cidrInput.trim()">添加</button></form>
<div class="cidr-list">
<div v-for="cidr in cidrs" :key="cidr"><span class="grip">⠿</span><code>{{ cidr }}</code><button :disabled="sessionBusy" @click="removeCIDR(cidr)" :aria-label="`删除 ${cidr}`"><Icon name="close"/></button></div>
<p v-if="!cidrs.length" class="cidr-empty">当前现场尚未配置远程网段</p>
</div>
<div class="preflight" :class="preflightState"><Icon name="check"/><span>本地冲突预检<strong>{{ preflightMessage }}</strong></span><button :disabled="sessionBusy || preflightState==='checking'" @click="runPreflight">再次检查</button></div>
</section>
</div>
<p v-if="error" class="error-banner">{{ error }}</p>
<section v-show="page==='connection'||page==='session'" class="session-panel">
<div class="session-copy"><h2>会话详情</h2><dl><dt>现场端</dt><dd>{{ state.session.siteName || '—' }}</dd><dt>远程网段</dt><dd>{{ state.session.cidrs.join(', ') || '—' }}</dd><dt>会话 ID</dt><dd class="mono">{{ state.session.id || '—' }}</dd><dt>状态</dt><dd class="good-text">{{ statusLabel(state.session.status) }}</dd><dt>结束原因</dt><dd>{{ state.session.reason ? errorLabel(state.session.reason) : '—' }}</dd><dt>开始时间</dt><dd>{{ formatTime(state.session.startedAt) }}</dd></dl></div>
<div class="metric"><span>上传</span><strong>{{ formatBytes(state.session.uploadBytes) }}</strong><small>{{ state.session.uploadPackets.toLocaleString() }} packets</small></div>
<div class="metric"><span>下载</span><strong>{{ formatBytes(state.session.downloadBytes) }}</strong><small>{{ state.session.downloadPackets.toLocaleString() }} packets</small></div>
<div class="metric"><span>时延</span><strong>{{ state.session.latencyMS || '—' }}</strong><small>ms</small></div>
<button class="danger" :disabled="!active || busy" @click="disconnect">断开会话</button>
</section>
<section v-show="page==='connection'||page==='logs'" class="logs-panel">
<div class="panel-heading"><h2>实时日志</h2><span>自动滚动</span></div>
<div class="logs"><div v-for="(log, index) in state.logs" :key="index"><time>{{ formatTime(log.time) }}</time><b :class="log.level.toLowerCase()">{{ levelLabel(log.level) }}</b><span>{{ log.message }}</span></div></div>
</section>
<section v-if="page==='settings'" class="panel settings-panel">
<div class="panel-heading"><h2>运行配置</h2><span>由本机配置与 Server Bootstrap 管理</span></div>
<dl><dt>Server 公网地址</dt><dd class="mono">{{ state.serverURL }}</dd><dt>客户端版本</dt><dd>{{ state.version }}</dd><dt>数据面</dt><dd>IPv4 · Wintun · wireguard-go</dd><dt>现场网关</dt><dd>gVisor netstack</dd></dl>
<p>网络地址密钥与 Node Token 不在界面中复制或明文保存Overlay 变更由 Server 触发重新 Bootstrap</p>
</section>
</div>
</main>
</div>
<div v-else class="loading">{{ error || '正在启动 RemLink Engineer…' }}</div>
</template>
<style>
.cidr-panel .panel-heading>div{display:grid;gap:3px}
.cidr-panel .panel-heading small{color:var(--muted);font-size:10px}
.sites-panel .table-head,.sites-panel .site-row{grid-template-columns:1.2fr .55fr .8fr 1.05fr .7fr;column-gap:8px}
.profile-hint{margin:11px 18px 0;color:#68738a;font-size:11px;line-height:1.5}
.cidr-empty{margin:3px 0;padding:14px;border:1px dashed #cbd2e0;border-radius:6px;color:var(--muted);font-size:11px;text-align:center}
</style>
+19
View File
@@ -0,0 +1,19 @@
<script setup lang="ts">
defineProps<{ name: 'link' | 'session' | 'logs' | 'settings' | 'server' | 'monitor' | 'plus' | 'close' | 'check' }>()
</script>
<template>
<svg viewBox="0 0 24 24" aria-hidden="true">
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<template v-if="name === 'link'"><circle cx="12" cy="5" r="2.5"/><circle cx="5" cy="18" r="2.5"/><circle cx="19" cy="18" r="2.5"/><path d="M12 7.5v4M7.2 16.4l4.8-4.9 4.8 4.9"/></template>
<template v-else-if="name === 'session'"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5V12l3 2"/></template>
<template v-else-if="name === 'logs'"><path d="M6 3.5h9l3 3V20.5H6z"/><path d="M15 3.5v3h3M9 11h6M9 15h6"/></template>
<template v-else-if="name === 'settings'"><circle cx="12" cy="12" r="3"/><path d="M12 2.8v2M12 19.2v2M2.8 12h2M19.2 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M18.5 5.5l-1.4 1.4M6.9 17.1l-1.4 1.4"/></template>
<template v-else-if="name === 'server'"><rect x="5" y="3.5" width="14" height="7" rx="2"/><rect x="5" y="13.5" width="14" height="7" rx="2"/><path d="M9 7h.01M9 17h.01M13 7h3M13 17h3"/></template>
<template v-else-if="name === 'monitor'"><rect x="3.5" y="4" width="17" height="12" rx="2"/><path d="M9 20h6M12 16v4"/></template>
<template v-else-if="name === 'plus'"><path d="M12 5v14M5 12h14"/></template>
<template v-else-if="name === 'close'"><path d="M6 6l12 12M18 6L6 18"/></template>
<template v-else><path d="M5 12.5l4.2 4.2L19 7"/></template>
</g>
</svg>
</template>
+82
View File
@@ -0,0 +1,82 @@
import type { EngineerState } from './types'
const dev = import.meta.env.DEV
const demoState: EngineerState = {
serverConnected: true,
controlConnected: true,
serverURL: 'https://remlink.example.com',
version: '1.0.0',
overlayIP: '10.88.0.2',
sites: [
{ node_id: 'site-qingdao', name: '青岛现场 01', overlay_ip: '10.88.0.25', online: true, remote_subnet_capability: true, last_seen: new Date().toISOString() },
{ node_id: 'site-shanghai', name: '上海实验室', overlay_ip: '10.88.0.31', online: false, remote_subnet_capability: true },
],
siteCIDRs: {
'site-qingdao': ['192.168.17.0/24'],
'site-shanghai': ['192.168.107.0/24'],
},
session: {
id: '4488624737516445881', siteName: '青岛现场 01', cidrs: ['192.168.17.0/24'], status: 'ACTIVE',
uploadBytes: 1321205, downloadBytes: 2940838, uploadPackets: 12345, downloadPackets: 15402, latencyMS: 28,
startedAt: new Date(Date.now() - 18 * 60_000).toISOString(),
},
logs: [
{ time: new Date(Date.now() - 50_000).toISOString(), level: 'INFO', message: 'Overlay 隧道已建立,连接路径正常' },
{ time: new Date(Date.now() - 34_000).toISOString(), level: 'INFO', message: 'Remote CIDR 已生效:192.168.17.0/24' },
{ time: new Date(Date.now() - 12_000).toISOString(), level: 'INFO', message: '192.168.17.5ICMP 回复,时延 28ms' },
],
}
const native = () => window.go?.main?.EngineerApp
function normalizeState(value: EngineerState): EngineerState {
return {
...value,
sites: value.sites ?? [],
siteCIDRs: Object.fromEntries(Object.entries(value.siteCIDRs ?? {}).map(([siteID, cidrs]) => [siteID, Array.from(cidrs ?? [])])),
logs: value.logs ?? [],
session: { ...value.session, cidrs: value.session?.cidrs ?? [] },
}
}
export async function saveSiteCIDRs(siteNodeID: string, cidrs: string[]): Promise<void> {
const current = native()
if (current) return current.SaveSiteCIDRs(siteNodeID, cidrs)
if (!dev) return nativeUnavailable()
if (cidrs.length === 0) delete demoState.siteCIDRs[siteNodeID]
else demoState.siteCIDRs[siteNodeID] = Array.from(cidrs)
}
function nativeUnavailable(): never {
throw new Error('RemLink native runtime is unavailable; production Demo fallback is disabled')
}
export async function getState(): Promise<EngineerState> {
const current = native()
if (current) return normalizeState(await current.GetState())
if (!dev) return nativeUnavailable()
return structuredClone(demoState)
}
export async function createSession(siteNodeID: string, cidrs: string[]): Promise<string> {
const current = native()
if (current) return current.CreateSession(siteNodeID, cidrs)
if (!dev) return nativeUnavailable()
demoState.session = { ...demoState.session, id: 'pending', siteName: demoState.sites.find(site => site.node_id === siteNodeID)?.name ?? '', cidrs: Array.from(cidrs), status: 'CREATING' }
setTimeout(() => { demoState.session.status = 'ACTIVE'; demoState.session.id = '4488624737516445881' }, 450)
return 'dev-request'
}
export async function disconnectSession(): Promise<void> {
const current = native()
if (current) return current.DisconnectSession()
if (!dev) return nativeUnavailable()
demoState.session.status = 'IDLE'
demoState.session.id = ''
}
export async function checkCIDRs(cidrs: string[]): Promise<void> {
const current = native()
if (current) return current.CheckCIDRs(cidrs)
if (!dev) return nativeUnavailable()
}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vite/client" />
import type { EngineerState } from './types'
declare global {
interface Window {
go?: {
main?: {
EngineerApp?: {
GetState(): Promise<EngineerState>
CreateSession(siteNodeID: string, cidrs: string[]): Promise<string>
SaveSiteCIDRs(siteNodeID: string, cidrs: string[]): Promise<void>
DisconnectSession(): Promise<void>
CheckCIDRs(cidrs: string[]): Promise<void>
}
}
}
runtime?: {
EventsOn(name: string, callback: (payload: unknown) => void): () => void
}
}
}
@@ -0,0 +1,12 @@
.table-head,
.site-row {
grid-template-columns: minmax(105px, 1.25fr) minmax(48px, .48fr) minmax(82px, .75fr) minmax(72px, .72fr) minmax(68px, .72fr);
column-gap: 8px;
}
.site-row > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles.css'
import './layout-overrides.css'
createApp(App).mount('#app')
File diff suppressed because one or more lines are too long
+35
View File
@@ -0,0 +1,35 @@
export type NodeStatus = 'ONLINE' | 'UNSTABLE' | 'OFFLINE'
export type SessionStatus = 'IDLE' | 'CREATING' | 'PREPARING_SITE' | 'READY' | 'ACTIVE' | 'STOPPING' | 'CLOSED' | 'FAILED'
export interface SiteSummary {
node_id: string
name: string
overlay_ip: string
online: boolean
remote_subnet_capability: boolean
last_seen?: string
}
export interface EngineerState {
serverConnected: boolean
controlConnected: boolean
serverURL: string
version: string
overlayIP: string
sites: SiteSummary[]
siteCIDRs: Record<string, string[]>
session: {
id: string
siteName: string
cidrs: string[]
status: SessionStatus
uploadBytes: number
downloadBytes: number
uploadPackets: number
downloadPackets: number
latencyMS: number
startedAt?: string
reason?: string
}
logs: Array<{ time: string; level: 'INFO' | 'WARN' | 'ERROR'; message: string }>
}
+27
View File
@@ -0,0 +1,27 @@
const statusLabels: Record<string, string> = {
IDLE: '空闲(IDLE', CREATING: '正在创建(CREATING', PREPARING_SITE: '正在准备现场端(PREPARING_SITE',
READY: '准备就绪(READY', ACTIVE: '活动中(ACTIVE', STOPPING: '正在停止(STOPPING',
CLOSED: '已关闭(CLOSED', FAILED: '失败(FAILED',
}
const levelLabels: Record<string, string> = { INFO: '信息', WARN: '警告', ERROR: '错误' }
const errorLabels: Record<string, string> = {
SITE_NO_ROUTE: '现场端没有通往远程网段的明确路由', SESSION_TIMEOUT: '会话建立超时', SITE_OFFLINE: '现场端离线',
CIDR_INVALID: '远程网段格式无效', CIDR_LOCAL_CONFLICT: '远程网段与本地网络冲突',
CIDR_OVERLAY_CONFLICT: '远程网段与 Overlay 网段冲突', NETSTACK_UNAVAILABLE: '现场端 netstack 网关不可用',
FLOW_LIMIT_REACHED: '现场端连接流数量已达到上限', SESSION_INJECT_FAILED: '会话数据包注入失败',
ENGINEER_SESSION_EXISTS: 'Engineer 已存在未结束的会话',
}
export function statusLabel(value: string) { return statusLabels[value] ?? value }
export function levelLabel(value: string) { return levelLabels[value] ?? value }
export function errorLabel(cause: unknown) {
const value = String(cause).replace(/^Error:\s*/, '')
for (const [code, label] of Object.entries(errorLabels)) if (value.includes(code)) return `${label}${code}`
if (value.includes('administrator privileges are required')) return '需要以管理员身份运行,才能管理 RemLink Wintun 网卡'
if (value.includes('decrypt WireGuard private key')) return '无法解密 WireGuard 私钥:identity.json 不是由当前 Windows 系统生成,请重新注册节点'
if (value.includes('Join Token is required')) return '首次注册需要在 engineer.yaml 中填写 Join Token'
if (value.includes('conflicts with existing route')) return `远程网段与现有本地路由冲突;原始信息:${value}`
if (value.includes('Site has no route to Remote CIDR')) return '现场端没有通往远程网段的明确路由(SITE_NO_ROUTE'
if (value.includes('native runtime is unavailable')) return 'RemLink 原生运行时不可用,请使用正式 Engineer.exe 启动'
return value
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
root: fileURLToPath(new URL('.', import.meta.url)),
plugins: [vue()],
build: { outDir: 'dist', emptyOutDir: true, sourcemap: false },
server: { host: '127.0.0.1', port: 34115, strictPort: true },
})