286 lines
9.0 KiB
Vue
286 lines
9.0 KiB
Vue
<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>
|