feat(web): add standalone WASM config generator (#2480)

Add a small Vite workspace that builds and publishes independently
of the dashboard. Reuse frontend-lib for the form and expose the
existing NetworkConfig conversions through wasm-bindgen.

Initialize the Aura theme in the standalone entry, detect the browser
language, and provide a persistent selector in the form header. Present
Generate Config and Copy Config as the page actions.

Keep the shared network secret field fluid so both form columns align.
Bundle the generator under dist/config-generator in the dashboard
artifact while preserving its standalone build output.

Build the optimized WASM module with the project and remove the
API-backed generator route from the dashboard.
This commit is contained in:
KKRainbow
2026-08-07 16:25:33 +08:00
committed by GitHub
parent 86222771c5
commit e31bde1836
22 changed files with 359 additions and 97 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EasyTier Config Generator</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,24 @@
{
"name": "easytier-config-generator",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"build:config-wasm": "wasm-pack build ../../easytier-core --target web --out-dir ../easytier-web/config-generator/src/generated/config-wasm --out-name easytier_config --release --no-pack -- --no-default-features --features browser-config",
"dev": "pnpm build:config-wasm && pnpm --dir ../frontend-lib build && vite",
"build": "pnpm build:config-wasm && pnpm --dir ../frontend-lib build && vue-tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@primeuix/themes": "^1.2.3",
"easytier-frontend-lib": "workspace:*",
"primevue": "^4.3.9",
"vue": "^3.5.12"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.1.4",
"typescript": "~5.6.2",
"vite": "^5.4.21",
"vue-tsc": "^2.1.10"
}
}
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { Config, I18nUtils, NetworkTypes } from 'easytier-frontend-lib'
import { Button, SelectButton, Textarea } from 'primevue'
import { computed, onMounted, ref, watch } from 'vue'
import initConfigWasm, {
generate_config as generateTomlConfig,
} from './generated/config-wasm/easytier_config'
let configWasmReady: ReturnType<typeof initConfigWasm> | undefined
const ensureConfigWasm = () => configWasmReady ??= initConfigWasm()
const networkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG())
const tomlConfig = ref('')
const errorMessage = ref('')
const configCopied = ref(false)
const t = (key: string) => I18nUtils.i18n.global.t(key)
const copyButtonLabel = computed(() => t(configCopied.value ? 'config_copied' : 'copy_config'))
type Language = 'cn' | 'en'
const languageOptions: { label: string, value: Language }[] = [
{ label: '中文', value: 'cn' },
{ label: 'EN', value: 'en' },
]
const currentLanguage = computed(() => I18nUtils.i18n.global.locale.value as Language)
watch(tomlConfig, () => {
configCopied.value = false
})
onMounted(async () => {
const savedLanguage = localStorage.getItem('lang')
const language = savedLanguage === 'cn' || savedLanguage === 'en'
? savedLanguage
: navigator.language.toLowerCase().startsWith('zh') ? 'cn' : 'en'
await I18nUtils.loadLanguageAsync(language)
})
const setLanguage = (language: Language) => {
if (language !== currentLanguage.value) {
void I18nUtils.loadLanguageAsync(language)
}
}
const generateConfig = async (config: NetworkTypes.NetworkConfig) => {
try {
errorMessage.value = ''
tomlConfig.value = ''
const configJson = JSON.stringify(NetworkTypes.toBackendNetworkConfig(config))
await ensureConfigWasm()
tomlConfig.value = generateTomlConfig(configJson)
} catch (error) {
errorMessage.value = t('config_generation_failed') + ': ' + (error instanceof Error ? error.message : String(error))
}
}
const copyConfig = async () => {
try {
errorMessage.value = ''
configCopied.value = false
await navigator.clipboard.writeText(tomlConfig.value)
configCopied.value = true
} catch (error) {
errorMessage.value = t('config_copy_failed') + ': ' + (error instanceof Error ? error.message : String(error))
}
}
</script>
<template>
<main class="config-generator">
<section class="config-panel">
<SelectButton
class="language-switch"
:model-value="currentLanguage"
:options="languageOptions"
option-label="label"
option-value="value"
size="small"
:allow-empty="false"
:aria-label="t('exchange_language')"
@update:model-value="setLanguage"
/>
<Config :cur-network="networkConfig" :action-label="t('generate_config')" @run-network="generateConfig" />
</section>
<section class="output-panel">
<pre v-if="errorMessage" class="error-message">{{ errorMessage }}</pre>
<Textarea
v-model="tomlConfig"
spellcheck="false"
class="toml-config"
:placeholder="t('config_generator_placeholder')"
/>
<Button :label="copyButtonLabel" icon="pi pi-copy" :disabled="!tomlConfig" @click="copyConfig" />
</section>
</main>
</template>
<style scoped>
.config-generator {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 2rem;
padding: 1.25rem;
}
.config-panel {
position: relative;
}
.language-switch {
position: absolute;
z-index: 2;
top: 0.4rem;
right: 0.75rem;
}
.config-panel,
.output-panel {
min-width: 0;
}
.output-panel {
display: flex;
flex-direction: column;
gap: 0.75rem;
height: calc(100vh - 2.5rem);
height: calc(100dvh - 2.5rem);
}
.toml-config {
width: 100%;
flex: 1;
resize: none;
font-family: monospace;
}
.error-message {
max-height: 10rem;
overflow: auto;
padding: 0.5rem;
color: #b91c1c;
background: #fee2e2;
border-radius: 0.25rem;
}
@media (max-width: 768px) {
.config-generator {
grid-template-columns: 1fr;
gap: 1.25rem;
padding: 0.75rem;
}
}
</style>
+23
View File
@@ -0,0 +1,23 @@
import { createApp } from 'vue'
import Aura from '@primeuix/themes/aura'
import EasytierFrontendLib from 'easytier-frontend-lib'
import 'easytier-frontend-lib/style.css'
import PrimeVue from 'primevue/config'
import App from './App.vue'
createApp(App)
.use(PrimeVue, {
theme: {
preset: Aura,
options: {
prefix: 'p',
darkModeSelector: 'system',
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities',
},
},
},
})
.use(EasytierFrontendLib)
.mount('#app')
@@ -0,0 +1,8 @@
{
"extends": "../frontend/tsconfig.app.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
@@ -0,0 +1,7 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
export default defineConfig({
base: './',
plugins: [vue()],
})