新增 XSLPrintDot 项目,包含打印服务的核心功能和相关配置。实现打印机查询、打印任务处理、远程转发功能,并支持多平台设备ID获取。优化打印数据准备逻辑,增强系统的可维护性和扩展性,同时更新工作区配置以支持新项目。

This commit is contained in:
geht
2026-05-14 12:04:18 +08:00
parent 296bc2a4b2
commit 687b9bebed
65 changed files with 9080 additions and 1 deletions

View File

@@ -0,0 +1,454 @@
<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>

View File

@@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { GetUsageGuide } from '../../wailsjs/go/main/App'
import { marked } from 'marked'
const content = ref('')
onMounted(async () => {
try {
const markdown = await GetUsageGuide()
content.value = await marked.parse(markdown)
} catch (e) {
console.error(e)
content.value = '<p class="text-red-500">Failed to load usage guide.</p>'
}
})
</script>
<template>
<div class="h-screen w-screen bg-white flex flex-col">
<div class="flex-1 overflow-y-auto p-8 prose prose-sm max-w-none prose-slate">
<div v-html="content"></div>
</div>
</div>
</template>
<style>
@reference "tailwindcss";
/* Add some basic markdown styling overrides if needed */
.prose h1 {
@apply text-2xl font-bold mb-4 pb-2 border-b border-gray-200 text-gray-800;
}
.prose h2 {
@apply text-xl font-bold mt-6 mb-3 text-gray-800;
}
.prose h3 {
@apply text-lg font-bold mt-4 mb-2 text-gray-800;
}
.prose p {
@apply mb-4 leading-relaxed text-gray-600;
}
.prose ul {
@apply list-disc list-inside mb-4 pl-4 text-gray-600;
}
.prose code {
@apply bg-gray-100 px-1 py-0.5 rounded text-sm font-mono text-pink-600;
}
.prose pre {
@apply bg-gray-900 text-gray-100 p-4 rounded-md overflow-x-auto mb-4 text-sm font-mono;
}
.prose pre code {
@apply bg-transparent p-0 text-gray-100;
}
.prose table {
@apply min-w-full border-collapse border border-gray-300 mb-4;
}
.prose thead {
@apply bg-gray-100;
}
.prose th {
@apply border border-gray-300 px-4 py-2 text-left font-semibold text-gray-700;
}
.prose td {
@apply border border-gray-300 px-4 py-2 text-gray-600;
}
.prose tr:nth-child(even) {
@apply bg-gray-50;
}
</style>

View File

@@ -0,0 +1,338 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { GetSettings, SaveSettings, Restart, GetLogPort, GetRemoteForwarderStatus, DisconnectRemoteForwarder, ConnectRemoteForwarder } from '../../wailsjs/go/main/App'
import { main } from '../../wailsjs/go/models'
const { t, locale } = useI18n()
const settings = ref(new main.AppSettings({
language: 'zh-CN',
autoStart: false,
remoteAutoConnect: true,
remoteServer: '',
remoteAuthUrl: '',
remoteWsUrl: '',
remoteClientId: '',
remoteSecretKey: '',
remoteClientName: '',
windowWidth: 0,
windowHeight: 0,
windowX: 0,
windowY: 0,
maximized: false
}))
const logPort = ref(0)
const isConnecting = ref(false)
const isDisconnecting = ref(false)
const isSyncing = ref(false)
const hasLoaded = ref(false)
let autoSaveTimer: number | null = null
type RemoteStatus = {
connected: boolean
lastError: string
lastChange: number
autoReconnect?: boolean
}
const remoteStatus = ref<RemoteStatus>({
connected: false,
lastError: '',
lastChange: 0
})
let remoteStatusTimer: number | null = null
let remoteStatusStream: EventSource | null = null
const refreshRemoteStatus = async () => {
try {
if (logPort.value > 0) {
const resp = await fetch(`http://localhost:${logPort.value}/api/forwarder/status`)
if (resp.ok) {
remoteStatus.value = await resp.json()
if (typeof remoteStatus.value.autoReconnect === 'boolean') {
setSettingsSilently(() => {
settings.value.remoteAutoConnect = remoteStatus.value.autoReconnect as boolean
})
}
return
}
}
remoteStatus.value = await GetRemoteForwarderStatus()
if (typeof remoteStatus.value.autoReconnect === 'boolean') {
setSettingsSilently(() => {
settings.value.remoteAutoConnect = remoteStatus.value.autoReconnect as boolean
})
}
} catch (e) {
console.error(e)
}
}
const setSettingsSilently = (update: () => void) => {
isSyncing.value = true
try {
update()
} finally {
isSyncing.value = false
}
}
onMounted(async () => {
try {
const s = await GetSettings()
setSettingsSilently(() => {
settings.value = s
locale.value = s.language
})
logPort.value = await GetLogPort()
await refreshRemoteStatus()
if (logPort.value > 0 && 'EventSource' in window) {
remoteStatusStream = new EventSource(`http://localhost:${logPort.value}/api/forwarder/stream`)
remoteStatusStream.onmessage = (event) => {
try {
remoteStatus.value = JSON.parse(event.data)
if (typeof remoteStatus.value.autoReconnect === 'boolean') {
settings.value.remoteAutoConnect = remoteStatus.value.autoReconnect
}
} catch (e) {
console.error(e)
}
}
remoteStatusStream.onerror = () => {
if (remoteStatusStream) {
remoteStatusStream.close()
remoteStatusStream = null
}
if (remoteStatusTimer === null) {
remoteStatusTimer = window.setInterval(refreshRemoteStatus, 3000)
}
}
} else {
remoteStatusTimer = window.setInterval(refreshRemoteStatus, 3000)
}
} catch (e) {
console.error(e)
}
hasLoaded.value = true
})
onBeforeUnmount(() => {
if (remoteStatusTimer !== null) {
window.clearInterval(remoteStatusTimer)
remoteStatusTimer = null
}
if (autoSaveTimer !== null) {
window.clearTimeout(autoSaveTimer)
autoSaveTimer = null
}
if (remoteStatusStream) {
remoteStatusStream.close()
remoteStatusStream = null
}
})
const isSaving = ref(false)
const saveSettings = async () => {
if (isSaving.value) return
isSaving.value = true
try {
// Save settings
await SaveSettings(settings.value)
// Update locale immediately in this window
locale.value = settings.value.language
// Notify main process to reload settings
try {
if (logPort.value > 0) {
await fetch(`http://localhost:${logPort.value}/api/reload`, { method: 'POST' })
}
} catch (e) {
console.log("Main process reload trigger failed", e)
}
} catch (e) {
console.error(e)
} finally {
isSaving.value = false
}
}
watch(settings, () => {
if (isSyncing.value || !hasLoaded.value) return
if (autoSaveTimer !== null) {
window.clearTimeout(autoSaveTimer)
}
autoSaveTimer = window.setTimeout(() => {
saveSettings()
}, 400)
}, { deep: true })
const disconnectRemote = async () => {
if (isDisconnecting.value || !remoteStatus.value.connected) return
isDisconnecting.value = true
try {
settings.value.remoteAutoConnect = false
remoteStatus.value.autoReconnect = false
await saveSettings()
if (logPort.value > 0) {
await fetch(`http://localhost:${logPort.value}/api/forwarder/disconnect`, { method: 'POST' })
} else {
await DisconnectRemoteForwarder()
}
await refreshRemoteStatus()
} catch (e) {
console.error(e)
} finally {
isDisconnecting.value = false
}
}
const connectRemote = async () => {
if (isConnecting.value || remoteStatus.value.connected) return
isConnecting.value = true
try {
if (logPort.value > 0) {
await fetch(`http://localhost:${logPort.value}/api/forwarder/connect`, { method: 'POST' })
} else {
await ConnectRemoteForwarder()
}
await refreshRemoteStatus()
} catch (e) {
console.error(e)
} finally {
isConnecting.value = false
}
}
const toggleRemote = async () => {
if (remoteStatus.value.connected) {
await disconnectRemote()
} else {
await connectRemote()
}
}
</script>
<template>
<div class="h-screen w-screen bg-gray-50 flex flex-col p-4 relative">
<h1 class="text-2xl font-bold mb-6 text-gray-800 flex items-center gap-2">
<i-material-symbols-settings-outline />
{{ t('settings.title') }}
</h1>
<div class="space-y-6 flex-1 overflow-y-auto">
<!-- Language -->
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<label class="block text-sm font-medium text-gray-700 mb-2 flex items-center gap-2">
<i-material-symbols-language class="text-gray-500" />
{{ t('settings.language') }}
</label>
<select v-model="settings.language" class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-blue-500 focus:border-blue-500">
<option value="zh-CN">简体中文</option>
<option value="en">English</option>
</select>
</div>
<!-- Auto Start -->
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 flex items-center gap-2">
<i-material-symbols-power class="text-gray-500" />
{{ t('settings.autoStart') }}
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="settings.autoStart" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<!-- Forwarding Service -->
<div class="bg-white p-4 rounded-lg border border-gray-200 shadow-sm space-y-4">
<h3 class="text-sm font-semibold text-gray-800 border-b border-gray-100 pb-2 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') }}
</h3>
<div class="flex items-center justify-between">
<label class="text-sm font-medium text-gray-700 flex items-center gap-2">
<i-material-symbols-sync class="text-gray-500" />
{{ t('settings.autoConnect') }}
</label>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="settings.remoteAutoConnect" class="sr-only peer">
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div>
<button
@click="toggleRemote"
:disabled="isConnecting || isDisconnecting"
class="w-full py-2 px-4 font-semibold rounded-md shadow-sm transition-colors duration-200 text-sm 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>
</div>
<p v-if="remoteStatus.lastError" class="text-xs text-red-500">
<span class="font-medium">{{ t('settings.lastError') }}:</span>
<span class="break-words">{{ remoteStatus.lastError }}</span>
</p>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<i-material-symbols-lock-outline class="text-gray-400" />
{{ t('settings.authAddress') }}
</label>
<input v-model="settings.remoteAuthUrl" type="text" class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-blue-500 focus:border-blue-500" placeholder="http://server:8080/api/client/login" />
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<i-material-symbols-link class="text-gray-400" />
{{ t('settings.wsAddress') }}
</label>
<input v-model="settings.remoteWsUrl" type="text" class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-blue-500 focus:border-blue-500" placeholder="ws://server:8081/ws/client" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<i-material-symbols-badge class="text-gray-400" />
{{ t('settings.clientId') }}
</label>
<input v-model="settings.remoteClientId" type="text" disabled class="w-full border border-gray-200 bg-gray-100 text-gray-500 rounded-md p-2 text-sm cursor-not-allowed" :title="t('settings.deviceIdReadonly')" />
<p class="text-[11px] text-gray-400 mt-1">{{ t('settings.deviceIdReadonly') }}</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<i-material-symbols-key class="text-gray-400" />
{{ t('settings.secretKey') }}
</label>
<input v-model="settings.remoteSecretKey" type="password" class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-blue-500 focus:border-blue-500" />
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-500 mb-1 flex items-center gap-1">
<i-material-symbols-badge class="text-gray-400" />
{{ t('settings.clientName') }}
</label>
<input v-model="settings.remoteClientName" type="text" class="w-full border border-gray-300 rounded-md p-2 text-sm focus:ring-blue-500 focus:border-blue-500" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
import { createI18n } from 'vue-i18n'
import en from './locales/en.json'
import zh from './locales/zh.json'
const i18n = createI18n({
legacy: false,
locale: 'zh-CN', // default locale
fallbackLocale: 'en',
messages: {
'en': en,
'zh-CN': zh
}
})
export default i18n

View File

@@ -0,0 +1,58 @@
{
"settings": {
"title": "Settings",
"language": "Language",
"autoStart": "Start on Boot",
"remotePrint": "Remote Print Server",
"forwarding": "Cloud Print Forwarder",
"serverAddress": "Server Address",
"authAddress": "Auth Address",
"wsAddress": "WS Address",
"autoConnect": "Auto Reconnect",
"username": "Username",
"password": "Password",
"clientId": "Client ID",
"secretKey": "Secret Key",
"clientName": "Client Name (Optional)",
"deviceIdReadonly": "Device ID is auto-detected and cannot be edited",
"forwarderStatus": "Connection Status",
"connected": "Connected",
"disconnected": "Disconnected",
"connect": "Connect",
"connecting": "Connecting...",
"disconnect": "Disconnect",
"disconnecting": "Disconnecting...",
"lastError": "Last Error",
"save": "Save Settings",
"saving": "Saving...",
"saved": "Saved",
"restart": "Restart Now",
"confirmRestart": "Restart Required",
"confirmRestartMessage": "Applying these settings requires a restart. Do you want to restart now?",
"cancel": "Cancel"
},
"main": {
"title": "XSL-PrintDot",
"subtitle": "WebSocket Printer Bridge",
"clients": "connections",
"serverControl": "Server Control",
"port": "Port",
"secretKey": "Secret Key (Optional)",
"placeholderKey": "Leave empty for no auth",
"startServer": "Start Server",
"stopServer": "Stop Server",
"connectionUrl": "Connection URL",
"availablePrinters": "Available Printers",
"defaultPrinter": "Default",
"refresh": "Refresh",
"loading": "Loading...",
"noPrinters": "No printers found.",
"clientConnected": "Client Connected"
},
"logs": {
"title": "System Logs",
"subtitle": "Live system events",
"clearAll": "Clear All",
"empty": "No logs available yet..."
}
}

View File

@@ -0,0 +1,58 @@
{
"settings": {
"title": "设置",
"language": "语言",
"autoStart": "开机自启动",
"remotePrint": "远程打印服务器",
"forwarding": "云打印中转服务",
"serverAddress": "服务器地址",
"authAddress": "鉴权地址",
"wsAddress": "WS地址",
"autoConnect": "自动重连",
"username": "用户名",
"password": "密码",
"clientId": "客户端ID",
"secretKey": "密钥",
"clientName": "客户端名称(可选)",
"deviceIdReadonly": "设备ID自动获取无法修改",
"forwarderStatus": "连接状态",
"connected": "已连接",
"disconnected": "未连接",
"connect": "连接",
"connecting": "连接中...",
"disconnect": "断开",
"disconnecting": "断开中...",
"lastError": "最近错误",
"save": "保存",
"saving": "保存中...",
"saved": "已保存",
"restart": "立即重启",
"confirmRestart": "需要重启",
"confirmRestartMessage": "应用这些设置需要重启程序。是否立即重启?",
"cancel": "取消"
},
"main": {
"title": "XSL-PrintDot",
"subtitle": "WebSocket 打印桥接器",
"clients": "个连接",
"serverControl": "服务控制",
"port": "端口",
"secretKey": "密钥 (可选)",
"placeholderKey": "留空则无需认证",
"startServer": "启动服务",
"stopServer": "停止服务",
"connectionUrl": "连接地址",
"availablePrinters": "可用打印机",
"defaultPrinter": "默认",
"refresh": "刷新",
"loading": "加载中...",
"noPrinters": "未找到打印机",
"clientConnected": "客户端已连接"
},
"logs": {
"title": "系统日志",
"subtitle": "实时系统事件",
"clearAll": "清除所有",
"empty": "暂无日志..."
}
}

View File

@@ -0,0 +1,6 @@
import {createApp} from 'vue'
import App from './App.vue'
import './style.css';
import i18n from './i18n'
createApp(App).use(i18n).mount('#app')

View File

@@ -0,0 +1,42 @@
@import "tailwindcss";
html {
background-color: white;
/* text-align: center; Removed to fix left alignment issues */
color: #1a202c;
overflow: hidden; /* Prevent window scrollbar */
}
body {
margin: 0;
color: #1a202c;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
overflow: hidden; /* Prevent body scrollbar */
}
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
html, body, #app {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
/* text-align: center; Removed to fix left alignment issues */
}

View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type {DefineComponent} from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}