Files
lingma-proxy-compose/desktop/frontend/src/App.vue
2026-05-06 12:08:39 +08:00

286 lines
9.0 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { onMounted, onUnmounted, ref } from 'vue'
import Dashboard from './views/Dashboard.vue'
import Logs from './views/Logs.vue'
import Models from './views/Models.vue'
import Requests from './views/Requests.vue'
import Settings from './views/Settings.vue'
import { EventsOff, EventsOn } from '../wailsjs/runtime'
import { ClearLogs, GetLogs, GetStatus, HideWindow, MinimizeWindow } from '../wailsjs/go/main/App.js'
import lingmaIcon from './assets/images/lingma-icon.png'
const currentTab = ref('dashboard')
const logs = ref([])
const status = ref({ running: false, addr: '', models: 0 })
const toast = ref('')
const themeMode = ref(localStorage.getItem('lingma-theme-mode') || 'system')
const appliedTheme = ref('light')
let systemThemeQuery = null
let toastTimer = null
const navigation = [
{ key: 'dashboard', label: '仪表盘', icon: 'bi-house-door' },
{ key: 'requests', label: '请求流', icon: 'bi-file-earmark-text' },
{ key: 'models', label: '模型', icon: 'bi-box' },
{ key: 'settings', label: '设置', icon: 'bi-gear' },
{ key: 'logs', label: '日志', icon: 'bi-terminal' },
]
function addLog(level, message) {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false })
logs.value.unshift({ time, level, message })
if (logs.value.length > 500) {
logs.value = logs.value.slice(0, 500)
}
}
function showToast(message) {
toast.value = message
clearTimeout(toastTimer)
toastTimer = setTimeout(() => {
toast.value = ''
}, 2200)
}
async function clearLocalLogs() {
try {
await ClearLogs()
logs.value = []
} catch (e) {
logs.value = []
}
}
function setStatus(nextStatus) {
status.value = nextStatus
}
function handleNotice(message) {
showToast(message)
addLog('info', message)
}
function resolveTheme() {
if (themeMode.value === 'system') {
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
return themeMode.value
}
function applyTheme() {
appliedTheme.value = resolveTheme()
document.documentElement.dataset.theme = appliedTheme.value
localStorage.setItem('lingma-theme-mode', themeMode.value)
}
function toggleTheme() {
const modes = ['system', 'light', 'dark']
const index = modes.indexOf(themeMode.value)
themeMode.value = modes[(index + 1) % modes.length]
applyTheme()
}
function themeTitle() {
if (themeMode.value === 'system') return `跟随系统(当前${appliedTheme.value === 'dark' ? '夜间' : '日间'}`
return themeMode.value === 'dark' ? '夜间模式' : '日间模式'
}
function themeIcon() {
if (themeMode.value === 'system') return 'bi-circle-half'
return themeMode.value === 'dark' ? 'bi-moon-stars' : 'bi-sun'
}
async function refreshStatus() {
try {
status.value = await GetStatus()
} catch (e) {
addLog('error', '状态刷新失败:' + (e.message || String(e)))
}
}
async function copyEndpoint() {
if (!status.value.addr) return
const value = `http://${status.value.addr}`
await navigator.clipboard?.writeText(value)
handleNotice('已复制接口地址:' + value)
}
function safeEventsOn(name, handler) {
try {
EventsOn(name, handler)
} catch (e) {
console.debug('Wails runtime event unavailable:', name)
}
}
function safeEventsOff(name) {
try {
EventsOff(name)
} catch (e) {
console.debug('Wails runtime event unavailable:', name)
}
}
function handleAppShortcut(event) {
const key = event.key.toLowerCase()
if ((event.metaKey || event.ctrlKey) && key === 'w') {
event.preventDefault()
HideWindow()
}
if ((event.metaKey || event.ctrlKey) && key === 'm') {
event.preventDefault()
MinimizeWindow()
}
// Fallback copy for WebView where native Edit menu is unavailable
if ((event.metaKey || event.ctrlKey) && key === 'c') {
const selection = window.getSelection()?.toString()
if (selection) {
event.preventDefault()
navigator.clipboard?.writeText(selection).catch(() => {})
}
}
// Fallback select-all for log/request content areas
if ((event.metaKey || event.ctrlKey) && key === 'a') {
const active = document.activeElement
const isEditable = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)
if (!isEditable) {
const panel = document.querySelector('.log-list, .request-list, .detail-panel')
if (panel) {
event.preventDefault()
const range = document.createRange()
range.selectNodeContents(panel)
const sel = window.getSelection()
sel?.removeAllRanges()
sel?.addRange(range)
}
}
}
}
onMounted(() => {
window.addEventListener('keydown', handleAppShortcut, true)
systemThemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)')
systemThemeQuery?.addEventListener?.('change', applyTheme)
applyTheme()
refreshStatus()
GetLogs().then((items) => {
logs.value = Array.isArray(items) ? items : []
}).catch(() => {})
safeEventsOn('models:updated', (data) => {
status.value.models = Array.isArray(data) ? data.length : status.value.models
addLog('info', `模型列表已更新:${status.value.models} 个模型`)
})
safeEventsOn('log', (data) => {
if (data.time && data.message !== undefined) {
logs.value.unshift(data)
if (logs.value.length > 500) logs.value = logs.value.slice(0, 500)
} else {
addLog(data.level || 'info', data.message || '')
}
refreshStatus()
})
safeEventsOn('logs:updated', (data) => {
logs.value = Array.isArray(data) ? data : []
})
safeEventsOn('quit:confirm', (message) => {
showToast(message || '再按一次退出快捷键将停止代理并退出应用')
})
safeEventsOn('status:updated', (nextStatus) => {
status.value = nextStatus
})
safeEventsOn('requests:updated', () => {
refreshStatus()
})
})
onUnmounted(() => {
window.removeEventListener('keydown', handleAppShortcut, true)
clearTimeout(toastTimer)
systemThemeQuery?.removeEventListener?.('change', applyTheme)
safeEventsOff('models:updated')
safeEventsOff('log')
safeEventsOff('logs:updated')
safeEventsOff('quit:confirm')
safeEventsOff('status:updated')
safeEventsOff('requests:updated')
})
</script>
<template>
<div class="app-shell">
<aside class="sidebar">
<button class="brand" type="button" @click="currentTab = 'dashboard'">
<span class="brand-mark">
<img :src="lingmaIcon" alt="" />
</span>
<span>
<strong>灵码代理</strong>
<small>IPC Proxy</small>
</span>
</button>
<nav class="nav-list" aria-label="主导航">
<button
v-for="item in navigation"
:key="item.key"
class="nav-item"
:class="{ active: currentTab === item.key }"
type="button"
@click="currentTab = item.key"
>
<span class="nav-icon">
<i class="bi" :class="item.icon" aria-hidden="true"></i>
</span>
<span>{{ item.label }}</span>
</button>
</nav>
<div class="sidebar-status">
<span class="status-dot" :class="{ running: status.running }"></span>
<div>
<strong>{{ status.running ? 'Proxy Running' : 'Proxy Stopped' }}</strong>
<small>v1.4.5</small>
</div>
</div>
</aside>
<section class="workspace">
<header class="topbar">
<span class="topbar-spacer" aria-hidden="true"></span>
<div class="topbar-actions">
<button class="icon-button" type="button" title="刷新状态" @click="refreshStatus">
<i class="bi bi-arrow-clockwise" aria-hidden="true"></i>
</button>
<button class="icon-button" type="button" title="复制接口地址" @click="copyEndpoint">
<i class="bi bi-copy" aria-hidden="true"></i>
</button>
<button class="icon-button" type="button" title="设置" @click="currentTab = 'settings'">
<i class="bi bi-gear" aria-hidden="true"></i>
</button>
<button class="icon-button" type="button" :title="themeTitle()" @click="toggleTheme">
<i class="bi" :class="themeIcon()" aria-hidden="true"></i>
</button>
</div>
</header>
<main class="view-stage">
<Dashboard
v-if="currentTab === 'dashboard'"
:shell-status="status"
@log="addLog"
@status="setStatus"
@notice="handleNotice"
@open-settings="currentTab = 'settings'"
@open-requests="currentTab = 'requests'"
@open-models="currentTab = 'models'"
/>
<Requests v-else-if="currentTab === 'requests'" @notice="handleNotice" />
<Models v-else-if="currentTab === 'models'" @log="addLog" @status="setStatus" @notice="handleNotice" />
<Settings v-else-if="currentTab === 'settings'" @log="addLog" @status-refresh="refreshStatus" />
<Logs v-else-if="currentTab === 'logs'" :logs="logs" @clear="clearLocalLogs" @notice="handleNotice" />
</main>
</section>
<div v-if="toast" class="toast">{{ toast }}</div>
</div>
</template>