455 lines
16 KiB
Vue
455 lines
16 KiB
Vue
<script lang="ts" setup>
|
|
import { reactive, ref, onMounted, onUnmounted, computed } from 'vue'
|
|
import { GetPrinters, StartServer, StopServer, GetAppMode, GetLogPort, GetSettings, SaveSettings } from '../wailsjs/go/main/App'
|
|
import { EventsOn } from '../wailsjs/runtime/runtime'
|
|
import Help from './components/Help.vue'
|
|
import Settings from './components/Settings.vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
|
|
const { t, locale } = useI18n()
|
|
const appMode = ref('main')
|
|
const logPort = ref(0)
|
|
const logs = ref<string[]>([])
|
|
const clientCount = ref(0)
|
|
let logPollInterval: number | null = null
|
|
let forwarderStream: EventSource | null = null
|
|
let forwarderPoll: number | null = null
|
|
|
|
const config = reactive({
|
|
port: '1122',
|
|
key: ''
|
|
})
|
|
|
|
const persistServerSettings = async () => {
|
|
try {
|
|
const s = await GetSettings()
|
|
if (s) {
|
|
s.serverPort = config.port
|
|
s.serverKey = config.key
|
|
await SaveSettings(s)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to save server settings', e)
|
|
}
|
|
}
|
|
|
|
const connectionUrl = computed(() => {
|
|
let url = `ws://localhost:${config.port}/ws`
|
|
if (config.key) {
|
|
url += `?key=${encodeURIComponent(config.key)}`
|
|
}
|
|
return url
|
|
})
|
|
|
|
const serverStatus = ref('Stopped')
|
|
type PrinterInfo = {
|
|
name: string
|
|
isDefault: boolean
|
|
}
|
|
|
|
const printers = ref<PrinterInfo[]>([])
|
|
const isLoadingPrinters = ref(false)
|
|
|
|
const refreshPrinters = async () => {
|
|
if (isLoadingPrinters.value) return
|
|
isLoadingPrinters.value = true
|
|
printers.value = []
|
|
|
|
const minDelay = new Promise((resolve) => setTimeout(resolve, 800))
|
|
|
|
try {
|
|
const [fetchedPrinters] = await Promise.all([
|
|
GetPrinters(),
|
|
minDelay
|
|
])
|
|
printers.value = fetchedPrinters.slice().sort((a, b) => {
|
|
if (a.isDefault !== b.isDefault) {
|
|
return a.isDefault ? -1 : 1
|
|
}
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
} catch (e) {
|
|
console.error(e)
|
|
} finally {
|
|
isLoadingPrinters.value = false
|
|
}
|
|
}
|
|
|
|
const toggleServer = async () => {
|
|
if (serverStatus.value === 'Running') {
|
|
try {
|
|
await StopServer()
|
|
serverStatus.value = 'Stopped'
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
} else {
|
|
try {
|
|
await StartServer(config.port, config.key)
|
|
serverStatus.value = 'Running'
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
}
|
|
|
|
type RemoteStatus = {
|
|
connected: boolean
|
|
lastError: string
|
|
lastChange: number
|
|
autoReconnect?: boolean
|
|
}
|
|
|
|
const remoteStatus = ref<RemoteStatus>({
|
|
connected: false,
|
|
lastError: '',
|
|
lastChange: 0
|
|
})
|
|
const isConnecting = ref(false)
|
|
const isDisconnecting = ref(false)
|
|
const forwarderVisible = ref(false)
|
|
|
|
const fetchLogs = async () => {
|
|
try {
|
|
const res = await fetch(`http://localhost:${logPort.value}/api/logs`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
logs.value = data.reverse()
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch logs', e)
|
|
}
|
|
}
|
|
|
|
const clearAllLogs = async () => {
|
|
logs.value = []
|
|
try {
|
|
await fetch(`http://localhost:${logPort.value}/api/logs/clear`, { method: 'POST' })
|
|
} catch (e) {
|
|
console.error('Failed to clear logs', e)
|
|
}
|
|
}
|
|
|
|
const refreshRemoteStatus = async () => {
|
|
if (logPort.value <= 0) return
|
|
try {
|
|
const resp = await fetch(`http://localhost:${logPort.value}/api/forwarder/status`)
|
|
if (resp.ok) {
|
|
remoteStatus.value = await resp.json()
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch forwarder status', e)
|
|
}
|
|
}
|
|
|
|
const updateForwarderVisibility = (settings: any) => {
|
|
if (!settings) {
|
|
forwarderVisible.value = false
|
|
return
|
|
}
|
|
const auth = (settings.remoteAuthUrl || '').trim()
|
|
const ws = (settings.remoteWsUrl || '').trim()
|
|
const clientId = (settings.remoteClientId || '').trim()
|
|
const secret = (settings.remoteSecretKey || '').trim()
|
|
forwarderVisible.value = auth !== '' && ws !== '' && clientId !== '' && secret !== ''
|
|
}
|
|
|
|
const connectForwarder = async () => {
|
|
if (isConnecting.value || remoteStatus.value.connected || logPort.value <= 0) return
|
|
isConnecting.value = true
|
|
try {
|
|
await fetch(`http://localhost:${logPort.value}/api/forwarder/connect`, { method: 'POST' })
|
|
} catch (e) {
|
|
console.error('Failed to connect forwarder', e)
|
|
} finally {
|
|
isConnecting.value = false
|
|
}
|
|
}
|
|
|
|
const disconnectForwarder = async () => {
|
|
if (isDisconnecting.value || !remoteStatus.value.connected || logPort.value <= 0) return
|
|
isDisconnecting.value = true
|
|
try {
|
|
const s = await GetSettings()
|
|
if (s) {
|
|
s.remoteAutoConnect = false
|
|
await SaveSettings(s)
|
|
}
|
|
await fetch(`http://localhost:${logPort.value}/api/forwarder/disconnect`, { method: 'POST' })
|
|
await fetch(`http://localhost:${logPort.value}/api/reload`, { method: 'POST' })
|
|
} catch (e) {
|
|
console.error('Failed to disconnect forwarder', e)
|
|
} finally {
|
|
isDisconnecting.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
appMode.value = await GetAppMode()
|
|
|
|
// Load settings for language
|
|
try {
|
|
const s = await GetSettings()
|
|
if (s && s.language) {
|
|
locale.value = s.language
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load settings", e)
|
|
}
|
|
|
|
if (appMode.value === "logs") {
|
|
logPort.value = await GetLogPort()
|
|
fetchLogs()
|
|
logPollInterval = setInterval(fetchLogs, 1000)
|
|
} else if (appMode.value === "main") {
|
|
// Main mode
|
|
logPort.value = await GetLogPort()
|
|
try {
|
|
const s = await GetSettings()
|
|
if (s) {
|
|
config.port = s.serverPort || config.port
|
|
config.key = s.serverKey || ''
|
|
updateForwarderVisibility(s)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load server settings', e)
|
|
}
|
|
await refreshPrinters()
|
|
await toggleServer()
|
|
|
|
await refreshRemoteStatus()
|
|
if (logPort.value > 0 && 'EventSource' in window) {
|
|
forwarderStream = new EventSource(`http://localhost:${logPort.value}/api/forwarder/stream`)
|
|
forwarderStream.onmessage = (event) => {
|
|
try {
|
|
remoteStatus.value = JSON.parse(event.data)
|
|
} catch (e) {
|
|
console.error(e)
|
|
}
|
|
}
|
|
forwarderStream.onerror = () => {
|
|
if (forwarderStream) {
|
|
forwarderStream.close()
|
|
forwarderStream = null
|
|
}
|
|
if (forwarderPoll === null) {
|
|
forwarderPoll = window.setInterval(refreshRemoteStatus, 3000)
|
|
}
|
|
}
|
|
} else {
|
|
forwarderPoll = window.setInterval(refreshRemoteStatus, 3000)
|
|
}
|
|
|
|
// Listen for client count updates
|
|
EventsOn("client_count", (count: number) => {
|
|
clientCount.value = count
|
|
})
|
|
|
|
|
|
// Listen for settings reload
|
|
EventsOn("reload_settings", async () => {
|
|
try {
|
|
const s = await GetSettings()
|
|
if (s && s.language) {
|
|
locale.value = s.language
|
|
}
|
|
if (s) {
|
|
config.port = s.serverPort || config.port
|
|
config.key = s.serverKey || ''
|
|
updateForwarderVisibility(s)
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to reload settings", e)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (logPollInterval) clearInterval(logPollInterval)
|
|
if (forwarderPoll !== null) {
|
|
window.clearInterval(forwarderPoll)
|
|
forwarderPoll = null
|
|
}
|
|
if (forwarderStream) {
|
|
forwarderStream.close()
|
|
forwarderStream = null
|
|
}
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Help v-if="appMode === 'help'" />
|
|
<Settings v-else-if="appMode === 'settings'" />
|
|
|
|
<div v-else class="h-screen w-screen overflow-hidden bg-white text-gray-900 font-sans text-left flex flex-col relative">
|
|
|
|
<!-- Content Area -->
|
|
<div class="flex-1 overflow-hidden relative">
|
|
|
|
<!-- LOGS MODE UI -->
|
|
<div v-if="appMode === 'logs'" class="w-full h-full flex flex-col">
|
|
<header class="p-4 border-b border-gray-200 bg-gray-50 flex justify-between items-center">
|
|
<div>
|
|
<h1 class="text-xl font-bold text-gray-800 mb-1 flex items-center gap-2">
|
|
<i-material-symbols-terminal class="text-gray-700" />
|
|
{{ t('logs.title') }}
|
|
</h1>
|
|
<p class="text-xs text-gray-500">{{ t('logs.subtitle') }}</p>
|
|
</div>
|
|
<button @click="clearAllLogs" class="text-xs text-red-600 hover:bg-red-50 px-3 py-1.5 border border-red-200 rounded-md transition-colors flex items-center gap-1">
|
|
<i-material-symbols-delete-outline />
|
|
{{ t('logs.clearAll') }}
|
|
</button>
|
|
</header>
|
|
<div class="flex-1 bg-gray-900 text-gray-300 p-4 font-mono text-xs overflow-y-auto scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
|
<div v-for="(log, i) in logs" :key="i" class="border-b border-gray-800 last:border-0 pb-1 mb-1 break-words hover:bg-gray-800/50">
|
|
{{ log }}
|
|
</div>
|
|
<div v-if="logs.length === 0" class="text-gray-600 italic py-4 text-center">{{ t('logs.empty') }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- MAIN APP UI -->
|
|
<div v-else class="w-full h-full flex flex-col">
|
|
<!-- Header -->
|
|
<header
|
|
class="p-4 border-b border-gray-200 flex-none transition-colors duration-300"
|
|
:class="clientCount > 0 ? 'bg-green-600 text-white' : 'bg-gray-50 text-gray-900'"
|
|
>
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<h1 class="text-xl font-bold mb-1 flex items-center gap-2">
|
|
<i-material-symbols-print-connect :class="clientCount > 0 ? 'text-white' : 'text-blue-600'" />
|
|
{{ t('main.title') }}
|
|
</h1>
|
|
<p class="text-xs" :class="clientCount > 0 ? 'text-green-100' : 'text-gray-500'">
|
|
{{ t('main.subtitle') }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Client Count Badge -->
|
|
<div
|
|
class="flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-bold transition-all"
|
|
:class="clientCount > 0 ? 'bg-white text-green-700 shadow-sm' : 'bg-gray-200 text-gray-600'"
|
|
>
|
|
<i-material-symbols-devices />
|
|
<span>{{ clientCount }} {{ t('main.clients') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
</header>
|
|
|
|
<div class="flex-1 overflow-y-auto scrollbar-hide">
|
|
<!-- Server Control -->
|
|
<div class="p-4 border-b border-gray-200">
|
|
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
|
<i-material-symbols-dns class="text-gray-600" />
|
|
<span class="w-2.5 h-2.5 rounded-full" :class="serverStatus === 'Running' ? 'bg-green-500' : 'bg-red-500'"></span>
|
|
{{ t('main.serverControl') }}
|
|
</h2>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">{{ t('main.port') }}</label>
|
|
<input v-model="config.port" @change="persistServerSettings" type="text" class="w-full bg-white border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all rounded-md" :disabled="serverStatus === 'Running'" />
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">{{ t('main.secretKey') }}</label>
|
|
<input v-model="config.key" @change="persistServerSettings" type="password" class="w-full bg-white border border-gray-300 px-3 py-2 text-sm text-gray-800 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all rounded-md" :disabled="serverStatus === 'Running'" :placeholder="t('main.placeholderKey')" />
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
@click="toggleServer"
|
|
class="w-full py-2 px-4 font-semibold text-white transition-all active:opacity-90 rounded-md flex items-center justify-center gap-2"
|
|
:class="serverStatus === 'Running' ? 'bg-red-500 hover:bg-red-600' : 'bg-blue-600 hover:bg-blue-700'"
|
|
>
|
|
<i-material-symbols-stop v-if="serverStatus === 'Running'" />
|
|
<i-material-symbols-play-arrow v-else />
|
|
{{ serverStatus === 'Running' ? t('main.stopServer') : t('main.startServer') }}
|
|
</button>
|
|
|
|
<div class="mt-4 p-3 bg-gray-50 border border-gray-200 rounded-md">
|
|
<label class="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">{{ t('main.connectionUrl') }}</label>
|
|
<div class="flex items-center gap-2">
|
|
<code class="flex-1 bg-white border border-gray-300 px-2 py-1.5 text-xs text-gray-600 rounded select-all font-mono break-all">
|
|
{{ connectionUrl }}
|
|
</code>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Forwarder -->
|
|
<div v-if="forwarderVisible" class="p-4 border-t border-gray-200">
|
|
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
|
<i-material-symbols-cloud-sync class="text-gray-600" />
|
|
<span class="w-2.5 h-2.5 rounded-full" :class="remoteStatus.connected ? 'bg-green-500' : 'bg-red-500'"></span>
|
|
{{ t('settings.forwarding') }}
|
|
</h2>
|
|
|
|
<button
|
|
@click="remoteStatus.connected ? disconnectForwarder() : connectForwarder()"
|
|
:disabled="isConnecting || isDisconnecting"
|
|
class="w-full py-2 px-4 font-semibold rounded-md transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
|
:class="remoteStatus.connected ? 'bg-red-500 hover:bg-red-600 text-white' : 'bg-blue-600 hover:bg-blue-700 text-white'"
|
|
>
|
|
<i-material-symbols-stop v-if="remoteStatus.connected" />
|
|
<i-material-symbols-play-arrow v-else />
|
|
{{ remoteStatus.connected ? (isDisconnecting ? t('settings.disconnecting') : t('settings.disconnect')) : (isConnecting ? t('settings.connecting') : t('settings.connect')) }}
|
|
</button>
|
|
|
|
<p v-if="remoteStatus.lastError" class="text-xs text-red-500 mt-2">
|
|
<span class="font-medium">{{ t('settings.lastError') }}:</span>
|
|
<span class="break-words">{{ remoteStatus.lastError }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Printers -->
|
|
<div class="p-4 border-t border-gray-200">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h2 class="text-base font-semibold text-gray-800 flex items-center gap-2">
|
|
<i-material-symbols-print class="text-gray-600" />
|
|
{{ t('main.availablePrinters') }}
|
|
</h2>
|
|
<button
|
|
@click="refreshPrinters"
|
|
class="text-xs bg-gray-100 hover:bg-gray-200 text-blue-600 px-3 py-1.5 border border-gray-200 transition-colors rounded-md flex items-center gap-1"
|
|
:disabled="isLoadingPrinters"
|
|
>
|
|
<i-material-symbols-refresh :class="{ 'animate-spin': isLoadingPrinters }" />
|
|
{{ t('main.refresh') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="isLoadingPrinters" class="text-gray-500 italic text-center py-6 bg-gray-50 border border-dashed border-gray-200 flex flex-col items-center gap-2">
|
|
<span>{{ t('main.loading') }}</span>
|
|
</div>
|
|
<div v-else-if="printers.length === 0" class="text-gray-400 italic text-center py-6 bg-gray-50 border border-dashed border-gray-200">
|
|
{{ t('main.noPrinters') }}
|
|
</div>
|
|
<ul v-else class="grid grid-cols-1 gap-0 border border-gray-200 divide-y divide-gray-200">
|
|
<li v-for="p in printers" :key="p.name" class="px-3 py-2 flex items-center gap-2 hover:bg-gray-50 transition-colors text-sm bg-white">
|
|
<i-material-symbols-print class="text-lg opacity-70 text-gray-500" />
|
|
<span class="font-medium truncate text-gray-700" :title="p.name">{{ p.name }}</span>
|
|
<span v-if="p.isDefault" class="ml-auto text-[10px] px-2 py-0.5 rounded-full bg-blue-50 text-blue-700 border border-blue-100">
|
|
{{ t('main.defaultPrinter') }}
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style>
|
|
/* Reset some Wails default styles if needed */
|
|
body {
|
|
margin: 0;
|
|
background-color: #f9fafb; /* gray-50 */
|
|
}
|
|
</style>
|