Merge pull request #2 from Flikify/codex/build-github-workflow-for-new-changes
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
Some checks failed
Build Multi-Platform Binaries / build-binaries (amd64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (amd64, windows, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm, 7, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, darwin, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, client, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, linux, server, true) (push) Has been cancelled
Build Multi-Platform Binaries / build-binaries (arm64, windows, server, false) (push) Has been cancelled
Build Multi-Platform Binaries / build-frontend (push) Has been cancelled
CI overhaul and major frontend UI refactor (new components, layouts, and release workflow)
This commit is contained in:
978
web/src/App.vue
978
web/src/App.vue
File diff suppressed because it is too large
Load Diff
62
web/src/components/MetricCard.vue
Normal file
62
web/src/components/MetricCard.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: string | number
|
||||
hint?: string
|
||||
tone?: 'default' | 'success' | 'warning' | 'info'
|
||||
}>()
|
||||
|
||||
const toneClass = props.tone || 'default'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="metric-card" :class="`metric-card--${toneClass}`">
|
||||
<span class="metric-card__label">{{ label }}</span>
|
||||
<strong class="metric-card__value">{{ value }}</strong>
|
||||
<span v-if="hint" class="metric-card__hint">{{ hint }}</span>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
min-height: 128px;
|
||||
border-radius: 20px;
|
||||
background: var(--glass-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.metric-card__label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.metric-card__value {
|
||||
font-size: clamp(26px, 4vw, 34px);
|
||||
line-height: 1;
|
||||
color: var(--color-text-primary);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.metric-card__hint {
|
||||
margin-top: auto;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.metric-card--success {
|
||||
border-color: rgba(16, 185, 129, 0.24);
|
||||
}
|
||||
|
||||
.metric-card--warning {
|
||||
border-color: rgba(245, 158, 11, 0.24);
|
||||
}
|
||||
|
||||
.metric-card--info {
|
||||
border-color: rgba(6, 182, 212, 0.24);
|
||||
}
|
||||
</style>
|
||||
154
web/src/components/PageShell.vue
Normal file
154
web/src/components/PageShell.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
subtitle?: string
|
||||
eyebrow?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="page-shell__glow page-shell__glow--primary"></div>
|
||||
<div class="page-shell__glow page-shell__glow--secondary"></div>
|
||||
|
||||
<header class="page-shell__header">
|
||||
<div class="page-shell__heading">
|
||||
<span v-if="eyebrow" class="page-shell__eyebrow">{{ eyebrow }}</span>
|
||||
<h1>{{ title }}</h1>
|
||||
<p v-if="subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-shell__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="$slots.metrics" class="page-shell__metrics">
|
||||
<slot name="metrics" />
|
||||
</div>
|
||||
|
||||
<div class="page-shell__content">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
position: relative;
|
||||
padding: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.page-shell__glow {
|
||||
position: absolute;
|
||||
border-radius: 999px;
|
||||
filter: blur(80px);
|
||||
opacity: 0.18;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.page-shell__glow--primary {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
top: -120px;
|
||||
right: -80px;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.page-shell__glow--secondary {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
bottom: -120px;
|
||||
left: -40px;
|
||||
background: #8b5cf6;
|
||||
}
|
||||
|
||||
.page-shell__header,
|
||||
.page-shell__metrics,
|
||||
.page-shell__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.page-shell__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-shell__heading {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.page-shell__eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
color: var(--color-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.page-shell__heading h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 40px);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.page-shell__heading p {
|
||||
margin: 10px 0 0;
|
||||
max-width: 640px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.page-shell__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.page-shell__metrics {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-shell__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-shell {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.page-shell__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-shell__actions {
|
||||
width: 100%;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.page-shell__actions :deep(*) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
web/src/components/SectionCard.vue
Normal file
79
web/src/components/SectionCard.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="section-card glass-card">
|
||||
<header class="section-card__header">
|
||||
<div>
|
||||
<h2>{{ title }}</h2>
|
||||
<p v-if="description">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.header" class="section-card__extra">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="section-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-card {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.section-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.section-card__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.section-card__header p {
|
||||
margin: 8px 0 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.section-card__extra {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-card {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.section-card__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section-card__extra {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,609 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getClients, generateInstallCommand } from '../api'
|
||||
import GlassModal from '../components/GlassModal.vue'
|
||||
import MetricCard from '../components/MetricCard.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import SectionCard from '../components/SectionCard.vue'
|
||||
import { generateInstallCommand, getClients } from '../api'
|
||||
import { useToast } from '../composables/useToast'
|
||||
import type { ClientStatus, InstallCommandResponse } from '../types'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useToast()
|
||||
const clients = ref<ClientStatus[]>([])
|
||||
const loading = ref(true)
|
||||
const showInstallModal = ref(false)
|
||||
const installData = ref<InstallCommandResponse | null>(null)
|
||||
const generatingInstall = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const loadClients = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getClients()
|
||||
clients.value = data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load clients', e)
|
||||
} catch (error) {
|
||||
console.error('Failed to load clients', error)
|
||||
message.error('客户端列表加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onlineClients = computed(() => clients.value.filter(c => c.online).length)
|
||||
|
||||
const viewClient = (id: string) => {
|
||||
router.push(`/client/${id}`)
|
||||
}
|
||||
|
||||
const openInstallModal = async () => {
|
||||
generatingInstall.value = true
|
||||
try {
|
||||
const { data } = await generateInstallCommand()
|
||||
installData.value = data
|
||||
showInstallModal.value = true
|
||||
} catch (e) {
|
||||
console.error('Failed to generate install command', e)
|
||||
} catch (error) {
|
||||
console.error('Failed to generate install command', error)
|
||||
message.error('安装命令生成失败')
|
||||
} finally {
|
||||
generatingInstall.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyCommand = (cmd: string) => {
|
||||
navigator.clipboard.writeText(cmd)
|
||||
const copyCommand = async (command: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command)
|
||||
message.success('命令已复制')
|
||||
} catch (error) {
|
||||
console.error('Failed to copy command', error)
|
||||
message.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const closeInstallModal = () => {
|
||||
showInstallModal.value = false
|
||||
installData.value = null
|
||||
}
|
||||
const filteredClients = computed(() => {
|
||||
const keyword = search.value.trim().toLowerCase()
|
||||
if (!keyword) return clients.value
|
||||
return clients.value.filter((client) => {
|
||||
return [client.id, client.nickname, client.remote_addr, client.os, client.arch]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(keyword))
|
||||
})
|
||||
})
|
||||
|
||||
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
|
||||
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
|
||||
|
||||
onMounted(loadClients)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="clients-page">
|
||||
<!-- Particles -->
|
||||
<div class="particles">
|
||||
<div class="particle particle-1"></div>
|
||||
<div class="particle particle-2"></div>
|
||||
<div class="particle particle-3"></div>
|
||||
</div>
|
||||
<PageShell title="客户端" eyebrow="Clients" subtitle="统一管理已注册节点、连接状态与快速安装命令,减少操作跳转。">
|
||||
<template #actions>
|
||||
<button class="glass-btn" :disabled="generatingInstall" @click="openInstallModal">
|
||||
{{ generatingInstall ? '生成中...' : '安装命令' }}
|
||||
</button>
|
||||
<button class="glass-btn primary" @click="loadClients">{{ loading ? '刷新中...' : '刷新列表' }}</button>
|
||||
</template>
|
||||
|
||||
<div class="clients-content">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">客户端管理</h1>
|
||||
<p class="page-subtitle">管理所有连接的客户端</p>
|
||||
</div>
|
||||
<template #metrics>
|
||||
<MetricCard label="客户端总数" :value="clients.length" hint="已接入的全部节点" />
|
||||
<MetricCard label="在线节点" :value="onlineClients" hint="可立即推送配置" tone="success" />
|
||||
<MetricCard label="离线节点" :value="offlineClients" hint="等待心跳恢复" tone="warning" />
|
||||
<MetricCard label="当前筛选结果" :value="filteredClients.length" hint="支持 ID / 昵称 / 地址搜索" tone="info" />
|
||||
</template>
|
||||
|
||||
<!-- Stats Row -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{{ clients.length }}</span>
|
||||
<span class="stat-label">总客户端</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value online">{{ onlineClients }}</span>
|
||||
<span class="stat-label">在线</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value offline">{{ clients.length - onlineClients }}</span>
|
||||
<span class="stat-label">离线</span>
|
||||
</div>
|
||||
</div>
|
||||
<SectionCard title="节点列表" description="使用统一卡片样式展示连接信息,便于快速判断状态与进入详情页。">
|
||||
<template #header>
|
||||
<input v-model="search" class="glass-input search-input" type="search" placeholder="搜索 ID / 昵称 / 地址" />
|
||||
</template>
|
||||
|
||||
<!-- Client List -->
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h3>客户端列表</h3>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="glass-btn small" @click="openInstallModal" :disabled="generatingInstall">
|
||||
{{ generatingInstall ? '生成中...' : '安装命令' }}
|
||||
</button>
|
||||
<button class="glass-btn small" @click="loadClients">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="clients.length === 0" class="empty-state">
|
||||
<p>暂无客户端连接</p>
|
||||
<p class="empty-hint">等待客户端连接...</p>
|
||||
</div>
|
||||
<div v-else class="clients-grid">
|
||||
<div
|
||||
v-for="client in clients"
|
||||
:key="client.id"
|
||||
class="client-card"
|
||||
@click="viewClient(client.id)"
|
||||
>
|
||||
<div class="client-header">
|
||||
<div class="client-status" :class="{ online: client.online }"></div>
|
||||
<h4 class="client-name">{{ client.nickname || client.id }}</h4>
|
||||
</div>
|
||||
<p v-if="client.nickname" class="client-id">{{ client.id }}</p>
|
||||
<div class="client-info">
|
||||
<span v-if="client.remote_addr && client.online">{{ client.remote_addr }}</span>
|
||||
<span>{{ client.rule_count || 0 }} 条规则</span>
|
||||
</div>
|
||||
<div class="client-tag" :class="client.online ? 'online' : 'offline'">
|
||||
{{ client.online ? '在线' : '离线' }}
|
||||
</div>
|
||||
<!-- Heartbeat indicator -->
|
||||
<div class="heartbeat-indicator" :class="{ online: client.online, offline: !client.online }">
|
||||
<span class="heartbeat-dot"></span>
|
||||
<div v-if="loading" class="empty-state">正在加载客户端列表...</div>
|
||||
<div v-else-if="filteredClients.length === 0" class="empty-state">未找到匹配的客户端。</div>
|
||||
<div v-else class="client-grid">
|
||||
<article v-for="client in filteredClients" :key="client.id" class="client-card" @click="router.push(`/client/${client.id}`)">
|
||||
<div class="client-card__header">
|
||||
<div>
|
||||
<div class="client-card__title">
|
||||
<span class="status-dot" :class="{ online: client.online }"></span>
|
||||
<strong>{{ client.nickname || client.id }}</strong>
|
||||
</div>
|
||||
<p>{{ client.nickname ? client.id : client.remote_addr || '等待首次连接' }}</p>
|
||||
</div>
|
||||
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Install Command Modal -->
|
||||
<div v-if="showInstallModal" class="modal-overlay" @click="closeInstallModal">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>客户端安装命令</h3>
|
||||
<button class="close-btn" @click="closeInstallModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body" v-if="installData">
|
||||
<p class="install-hint">选择您的操作系统,复制命令并在目标机器上执行:</p>
|
||||
<div class="install-section">
|
||||
<h4>Linux</h4>
|
||||
<div class="command-box">
|
||||
<code>{{ installData.commands.linux }}</code>
|
||||
<button class="copy-btn" @click="copyCommand(installData.commands.linux)">复制</button>
|
||||
<dl class="client-card__meta">
|
||||
<div>
|
||||
<dt>地址</dt>
|
||||
<dd>{{ client.remote_addr || '未上报' }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-section">
|
||||
<h4>macOS</h4>
|
||||
<div class="command-box">
|
||||
<code>{{ installData.commands.macos }}</code>
|
||||
<button class="copy-btn" @click="copyCommand(installData.commands.macos)">复制</button>
|
||||
<div>
|
||||
<dt>规则数</dt>
|
||||
<dd>{{ client.rule_count || 0 }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-section">
|
||||
<h4>Windows</h4>
|
||||
<div class="command-box">
|
||||
<code>{{ installData.commands.windows }}</code>
|
||||
<button class="copy-btn" @click="copyCommand(installData.commands.windows)">复制</button>
|
||||
<div>
|
||||
<dt>平台</dt>
|
||||
<dd>{{ [client.os, client.arch].filter(Boolean).join(' / ') || '未知' }}</dd>
|
||||
</div>
|
||||
</div>
|
||||
<p class="token-info">客户端 ID 会在目标机器上根据多种设备标识自动计算。</p>
|
||||
<p class="token-warning">⚠️ 此命令包含一次性token,使用后需重新生成</p>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<GlassModal :show="showInstallModal" title="安装命令" width="760px" @close="showInstallModal = false">
|
||||
<div v-if="installData" class="install-grid">
|
||||
<article v-for="item in [
|
||||
{ label: 'Linux', value: installData.commands.linux },
|
||||
{ label: 'macOS', value: installData.commands.macos },
|
||||
{ label: 'Windows', value: installData.commands.windows },
|
||||
]" :key="item.label" class="install-card">
|
||||
<header>
|
||||
<strong>{{ item.label }}</strong>
|
||||
<button class="glass-btn small" @click="copyCommand(item.value)">复制</button>
|
||||
</header>
|
||||
<code>{{ item.value }}</code>
|
||||
</article>
|
||||
</div>
|
||||
<template #footer>
|
||||
<span class="install-footnote">命令内含一次性 token,使用后请重新生成。</span>
|
||||
</template>
|
||||
</GlassModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.clients-page {
|
||||
min-height: calc(100vh - 116px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 32px;
|
||||
.search-input {
|
||||
min-width: min(320px, 100%);
|
||||
}
|
||||
|
||||
/* 动画背景粒子 */
|
||||
.particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.15;
|
||||
filter: blur(60px);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.particle-1 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: var(--color-accent);
|
||||
top: -80px;
|
||||
right: -80px;
|
||||
}
|
||||
|
||||
.particle-2 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: #8b5cf6;
|
||||
bottom: -40px;
|
||||
left: -40px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.particle-3 {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: var(--color-success);
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -30px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(-30px, -20px) scale(1.02); }
|
||||
}
|
||||
|
||||
.clients-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.page-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-row {
|
||||
.client-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-card);
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
display: block;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.stat-value.online { color: var(--color-success); }
|
||||
.stat-value.offline { color: var(--color-text-muted); }
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Glass Card */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.card-body { padding: 20px; }
|
||||
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Clients Grid */
|
||||
.clients-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.clients-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.stats-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.clients-grid { grid-template-columns: 1fr; }
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.client-card {
|
||||
background: var(--glass-bg-light);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 18px;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.client-card:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(59, 130, 246, 0.24);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.client-header {
|
||||
.client-card__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.client-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.client-status {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
.client-status.online {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 10px var(--color-success-glow);
|
||||
}
|
||||
|
||||
.client-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.client-id {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 8px 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.client-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
.client-card__header p {
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.client-tag {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.client-tag.online {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: var(--color-success);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
.client-tag.offline {
|
||||
background: var(--glass-bg-light);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.glass-btn {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur-light);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 10px;
|
||||
padding: 8px 16px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.glass-btn:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.glass-btn.small { padding: 6px 12px; font-size: 12px; }
|
||||
|
||||
/* Heartbeat Indicator */
|
||||
.heartbeat-indicator {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 18px;
|
||||
}
|
||||
|
||||
.heartbeat-dot {
|
||||
display: block;
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
border-radius: 999px;
|
||||
background: var(--color-error);
|
||||
}
|
||||
|
||||
.heartbeat-indicator.online .heartbeat-dot {
|
||||
.status-dot.online {
|
||||
background: var(--color-success);
|
||||
animation: heartbeat-pulse 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px var(--color-success-glow);
|
||||
}
|
||||
|
||||
.heartbeat-indicator.offline .heartbeat-dot {
|
||||
background: var(--color-error);
|
||||
animation: none;
|
||||
.state-pill {
|
||||
height: fit-content;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
@keyframes heartbeat-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 var(--color-success-glow);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(16, 185, 129, 0);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.state-pill.online {
|
||||
color: var(--color-success);
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
/* Install Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
.state-pill.offline {
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
.client-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-card__meta dt {
|
||||
margin-bottom: 6px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-card__meta dd {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.install-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.install-card {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
max-width: 800px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
.install-card header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--glass-border);
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.install-hint {
|
||||
margin-bottom: 20px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.install-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.install-section h4 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 14px;
|
||||
.install-card code {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.command-box {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.command-box code {
|
||||
flex: 1;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background: var(--glass-bg);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-primary);
|
||||
white-space: nowrap;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
.install-footnote {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.token-info {
|
||||
margin-top: 20px;
|
||||
padding: 12px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
.empty-state {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.token-warning {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
color: #f59e0b;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.client-card__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-card__meta {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,653 +1,324 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getClients, getTrafficStats, getTrafficHourly, type TrafficRecord } from '../api'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { getClients, getTrafficHourly, getTrafficStats, type TrafficRecord } from '../api'
|
||||
import type { ClientStatus } from '../types'
|
||||
import MetricCard from '../components/MetricCard.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import SectionCard from '../components/SectionCard.vue'
|
||||
|
||||
const clients = ref<ClientStatus[]>([])
|
||||
|
||||
// 流量统计数据
|
||||
const traffic24h = ref({ inbound: 0, outbound: 0 })
|
||||
const trafficTotal = ref({ inbound: 0, outbound: 0 })
|
||||
const trafficHistory = ref<TrafficRecord[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
// 格式化字节数
|
||||
const formatBytes = (bytes: number): { value: string; unit: string } => {
|
||||
if (bytes === 0) return { value: '0', unit: 'B' }
|
||||
const k = 1024
|
||||
const sizes: string[] = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
return {
|
||||
value: parseFloat((bytes / Math.pow(k, i)).toFixed(2)).toString(),
|
||||
unit: sizes[i] as string
|
||||
value: (bytes / Math.pow(1024, index)).toFixed(index === 0 ? 0 : 1),
|
||||
unit: units[index] ?? 'B',
|
||||
}
|
||||
}
|
||||
|
||||
// 加载流量统计
|
||||
const loadTrafficStats = async () => {
|
||||
const loadDashboard = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await getTrafficStats()
|
||||
traffic24h.value = data.traffic_24h
|
||||
trafficTotal.value = data.traffic_total
|
||||
} catch (e) {
|
||||
console.error('Failed to load traffic stats', e)
|
||||
}
|
||||
}
|
||||
const [{ data: clientData }, { data: statsData }, { data: hourlyData }] = await Promise.all([
|
||||
getClients(),
|
||||
getTrafficStats(),
|
||||
getTrafficHourly(),
|
||||
])
|
||||
|
||||
// 加载每小时流量
|
||||
const loadTrafficHourly = async () => {
|
||||
try {
|
||||
const { data } = await getTrafficHourly()
|
||||
const records = data.records || []
|
||||
// 如果没有数据,生成从当前时间开始的24小时空数据
|
||||
if (records.length === 0) {
|
||||
const now = new Date()
|
||||
const currentHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours())
|
||||
const emptyRecords: TrafficRecord[] = []
|
||||
for (let i = 23; i >= 0; i--) {
|
||||
const ts = new Date(currentHour.getTime() - i * 3600 * 1000)
|
||||
emptyRecords.push({
|
||||
timestamp: Math.floor(ts.getTime() / 1000),
|
||||
inbound: 0,
|
||||
outbound: 0
|
||||
})
|
||||
}
|
||||
trafficHistory.value = emptyRecords
|
||||
} else {
|
||||
trafficHistory.value = records
|
||||
clients.value = clientData || []
|
||||
traffic24h.value = statsData.traffic_24h
|
||||
trafficTotal.value = statsData.traffic_total
|
||||
|
||||
const records = hourlyData.records || []
|
||||
if (records.length) {
|
||||
trafficHistory.value = records.slice(-12)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load hourly traffic', e)
|
||||
|
||||
const now = new Date()
|
||||
trafficHistory.value = Array.from({ length: 12 }, (_, index) => {
|
||||
const slot = new Date(now.getTime() - (11 - index) * 3600 * 1000)
|
||||
return {
|
||||
timestamp: Math.floor(slot.getTime() / 1000),
|
||||
inbound: 0,
|
||||
outbound: 0,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadClients = async () => {
|
||||
try {
|
||||
const { data } = await getClients()
|
||||
clients.value = data || []
|
||||
} catch (e) {
|
||||
console.error('Failed to load clients', e)
|
||||
}
|
||||
}
|
||||
|
||||
const onlineClients = computed(() => {
|
||||
return clients.value.filter(client => client.online).length
|
||||
})
|
||||
|
||||
const totalRules = computed(() => {
|
||||
return clients.value.reduce((sum, c) => sum + (c.rule_count || 0), 0)
|
||||
})
|
||||
|
||||
// 格式化后的流量统计
|
||||
const onlineClients = computed(() => clients.value.filter((client) => client.online).length)
|
||||
const offlineClients = computed(() => Math.max(clients.value.length - onlineClients.value, 0))
|
||||
const totalRules = computed(() => clients.value.reduce((sum, client) => sum + (client.rule_count || 0), 0))
|
||||
const topClients = computed(() => [...clients.value].sort((a, b) => Number(b.online) - Number(a.online)).slice(0, 6))
|
||||
const chartMax = computed(() => Math.max(...trafficHistory.value.flatMap((item) => [item.inbound, item.outbound]), 1))
|
||||
const formatted24hInbound = computed(() => formatBytes(traffic24h.value.inbound))
|
||||
const formatted24hOutbound = computed(() => formatBytes(traffic24h.value.outbound))
|
||||
const formattedTotalInbound = computed(() => formatBytes(trafficTotal.value.inbound))
|
||||
const formattedTotalOutbound = computed(() => formatBytes(trafficTotal.value.outbound))
|
||||
const formatHour = (timestamp: number) => new Date(timestamp * 1000).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
// Chart helpers
|
||||
const maxTraffic = computed(() => {
|
||||
const max = Math.max(
|
||||
...trafficHistory.value.map(d => Math.max(d.inbound, d.outbound))
|
||||
)
|
||||
return max || 100
|
||||
})
|
||||
|
||||
const getBarHeight = (value: number) => {
|
||||
return (value / maxTraffic.value) * 100
|
||||
}
|
||||
|
||||
// 格式化时间戳为小时
|
||||
const formatHour = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000)
|
||||
return date.getHours().toString().padStart(2, '0') + ':00'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadClients()
|
||||
loadTrafficStats()
|
||||
loadTrafficHourly()
|
||||
})
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-container">
|
||||
<!-- Animated background particles -->
|
||||
<div class="particles">
|
||||
<div class="particle particle-1"></div>
|
||||
<div class="particle particle-2"></div>
|
||||
<div class="particle particle-3"></div>
|
||||
<div class="particle particle-4"></div>
|
||||
<div class="particle particle-5"></div>
|
||||
<PageShell title="控制台" eyebrow="Overview" subtitle="统一查看连接状态、流量趋势与客户端健康情况,减少页面层级并突出关键数据。">
|
||||
<template #actions>
|
||||
<button class="glass-btn" @click="loadDashboard">{{ loading ? '刷新中...' : '刷新数据' }}</button>
|
||||
</template>
|
||||
|
||||
<template #metrics>
|
||||
<MetricCard label="在线客户端" :value="onlineClients" :hint="`离线 ${offlineClients} 台`" tone="success" />
|
||||
<MetricCard label="代理规则" :value="totalRules" hint="全部客户端规则总数" />
|
||||
<MetricCard
|
||||
label="24H 出站"
|
||||
:value="formatted24hOutbound.value"
|
||||
:hint="formatted24hOutbound.unit"
|
||||
tone="info"
|
||||
/>
|
||||
<MetricCard
|
||||
label="总入站"
|
||||
:value="formattedTotalInbound.value"
|
||||
:hint="formattedTotalInbound.unit"
|
||||
tone="warning"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<SectionCard title="流量趋势" description="近 12 小时入站 / 出站流量概览。">
|
||||
<div class="traffic-summary">
|
||||
<div class="traffic-pill">
|
||||
<span>24H 入站</span>
|
||||
<strong>{{ formatted24hInbound.value }} {{ formatted24hInbound.unit }}</strong>
|
||||
</div>
|
||||
<div class="traffic-pill">
|
||||
<span>24H 出站</span>
|
||||
<strong>{{ formatted24hOutbound.value }} {{ formatted24hOutbound.unit }}</strong>
|
||||
</div>
|
||||
<div class="traffic-pill">
|
||||
<span>总出站</span>
|
||||
<strong>{{ formattedTotalOutbound.value }} {{ formattedTotalOutbound.unit }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="traffic-chart">
|
||||
<div v-for="item in trafficHistory" :key="item.timestamp" class="traffic-chart__item">
|
||||
<div class="traffic-chart__bars">
|
||||
<span class="bar bar--inbound" :style="{ height: `${(item.inbound / chartMax) * 100}%` }"></span>
|
||||
<span class="bar bar--outbound" :style="{ height: `${(item.outbound / chartMax) * 100}%` }"></span>
|
||||
</div>
|
||||
<span class="traffic-chart__label">{{ formatHour(item.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="客户端概况" description="优先展示在线客户端,并保留连接来源与规则数量。">
|
||||
<div v-if="topClients.length" class="client-list">
|
||||
<article v-for="client in topClients" :key="client.id" class="client-row">
|
||||
<div>
|
||||
<div class="client-row__title">
|
||||
<span class="client-dot" :class="{ online: client.online }"></span>
|
||||
<strong>{{ client.nickname || client.id }}</strong>
|
||||
</div>
|
||||
<p>{{ client.remote_addr || '等待连接地址' }}</p>
|
||||
</div>
|
||||
<div class="client-row__meta">
|
||||
<span>{{ client.rule_count || 0 }} 条规则</span>
|
||||
<span class="state-pill" :class="client.online ? 'online' : 'offline'">{{ client.online ? '在线' : '离线' }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div v-else class="empty-state">暂无客户端数据。</div>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="dashboard-content">
|
||||
<!-- Header -->
|
||||
<div class="dashboard-header">
|
||||
<h1 class="dashboard-title">仪表盘</h1>
|
||||
<p class="dashboard-subtitle">监控隧道连接和流量状态</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="stats-grid">
|
||||
<!-- 24H Traffic Combined -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large traffic-24h">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">24H 出站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formatted24hOutbound.value }}</span>
|
||||
<span class="stat-unit">{{ formatted24hOutbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">24H 入站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formatted24hInbound.value }}</span>
|
||||
<span class="stat-unit">{{ formatted24hInbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Traffic Combined -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large traffic-total">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">总出站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formattedTotalOutbound.value }}</span>
|
||||
<span class="stat-unit">{{ formattedTotalOutbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">总入站</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ formattedTotalInbound.value }}</span>
|
||||
<span class="stat-unit">{{ formattedTotalInbound.unit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Count -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large clients">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">在线客户端</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number online">{{ onlineClients }}</span>
|
||||
<span class="stat-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-title">总客户端</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ clients.length }}</span>
|
||||
<span class="stat-unit">个</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="online-indicator" :class="{ active: onlineClients > 0 }">
|
||||
<span class="pulse"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rules Count -->
|
||||
<div class="stat-card glass-stat">
|
||||
<div class="stat-icon-large rules">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon-lg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-details">
|
||||
<div class="stat-row single">
|
||||
<span class="stat-title">代理规则</span>
|
||||
<div class="stat-data">
|
||||
<span class="stat-number">{{ totalRules }}</span>
|
||||
<span class="stat-unit">条</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traffic Chart Section -->
|
||||
<div class="chart-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">24小时流量趋势</h2>
|
||||
<div class="chart-legend">
|
||||
<span class="legend-item inbound"><span class="legend-dot"></span>入站</span>
|
||||
<span class="legend-item outbound"><span class="legend-dot"></span>出站</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-card glass-card">
|
||||
<div class="chart-container">
|
||||
<div class="chart-bars">
|
||||
<div v-for="(data, index) in trafficHistory" :key="index" class="bar-group">
|
||||
<div class="bar-wrapper">
|
||||
<div class="bar inbound" :style="{ height: getBarHeight(data.inbound) + '%' }"></div>
|
||||
<div class="bar outbound" :style="{ height: getBarHeight(data.outbound) + '%' }"></div>
|
||||
</div>
|
||||
<span class="bar-label">{{ formatHour(data.timestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Container */
|
||||
.dashboard-container {
|
||||
min-height: calc(100vh - 116px);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 动画背景粒子 */
|
||||
.particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.15;
|
||||
filter: blur(60px);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.particle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--color-accent);
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.particle-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #8b5cf6;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.particle-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: var(--color-info);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.particle-4 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--color-success);
|
||||
bottom: 20%;
|
||||
right: 20%;
|
||||
animation-delay: -15s;
|
||||
}
|
||||
|
||||
.particle-5 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: #ec4899;
|
||||
top: 30%;
|
||||
left: 10%;
|
||||
animation-delay: -7s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -30px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(-30px, -20px) scale(1.02); }
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
.dashboard-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.dashboard-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.traffic-summary {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Glass stat card - 毛玻璃效果 */
|
||||
.glass-stat {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
.traffic-pill {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: var(--shadow-card);
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* 卡片顶部高光 */
|
||||
.glass-stat::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
.glass-stat:hover {
|
||||
background: var(--glass-bg-hover);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg), var(--shadow-glow);
|
||||
}
|
||||
|
||||
/* Large Stat icon */
|
||||
.stat-icon-large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.stat-icon-large.traffic-24h {
|
||||
background: var(--gradient-accent);
|
||||
}
|
||||
|
||||
.stat-icon-large.traffic-total {
|
||||
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
|
||||
}
|
||||
|
||||
.stat-icon-large.clients {
|
||||
background: linear-gradient(135deg, #10b981 0%, #34d399 100%);
|
||||
}
|
||||
|
||||
.stat-icon-large.rules {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%);
|
||||
}
|
||||
|
||||
.icon-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Stat details */
|
||||
.stat-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-row.single {
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-data {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.stat-number.online {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.stat-unit {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Online indicator with pulse */
|
||||
.online-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.online-indicator .pulse {
|
||||
.traffic-pill span {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.online-indicator.active .pulse {
|
||||
background: var(--color-success);
|
||||
animation: pulse-animation 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 8px var(--color-success-glow);
|
||||
}
|
||||
|
||||
@keyframes pulse-animation {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--color-success-glow); }
|
||||
50% { box-shadow: 0 0 0 8px rgba(16, 185, 129, 0); }
|
||||
}
|
||||
|
||||
/* Chart Section */
|
||||
.chart-section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
.traffic-pill strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.legend-item.inbound .legend-dot {
|
||||
background: #8b5cf6;
|
||||
.traffic-chart {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.legend-item.outbound .legend-dot {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
/* Chart Card */
|
||||
.chart-card {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
height: 200px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
min-width: 600px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.bar-group {
|
||||
flex: 1;
|
||||
.traffic-chart__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bar-wrapper {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
.traffic-chart__bars {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: flex-end;
|
||||
align-items: end;
|
||||
gap: 4px;
|
||||
height: 200px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
min-height: 4px;
|
||||
border-radius: 999px 999px 6px 6px;
|
||||
}
|
||||
|
||||
.bar.inbound {
|
||||
background: #8b5cf6;
|
||||
.bar--inbound {
|
||||
background: linear-gradient(180deg, rgba(6, 182, 212, 0.95), rgba(6, 182, 212, 0.25));
|
||||
}
|
||||
|
||||
.bar.outbound {
|
||||
background: var(--color-accent);
|
||||
.bar--outbound {
|
||||
background: linear-gradient(180deg, rgba(59, 130, 246, 0.95), rgba(59, 130, 246, 0.25));
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chart-hint {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
.traffic-chart__label {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Glass card base - 毛玻璃效果 */
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
.client-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.client-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
position: relative;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.glass-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 10%;
|
||||
right: 10%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%);
|
||||
.client-row__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.client-row p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.client-row__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.client-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-error);
|
||||
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.08);
|
||||
}
|
||||
|
||||
.client-dot.online {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.state-pill {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.state-pill.online {
|
||||
color: var(--color-success);
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border-color: rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.state-pill.offline {
|
||||
color: var(--color-text-secondary);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
border-color: rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 36px 16px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.traffic-chart {
|
||||
overflow-x: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.traffic-chart__bars {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.client-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.client-row__meta {
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { login, setToken } from '../api'
|
||||
|
||||
@@ -9,6 +9,14 @@ const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const features = [
|
||||
'统一管理隧道、客户端与规则状态',
|
||||
'自动下发配置,客户端零配置接入',
|
||||
'内置更新与运行状态查看,便于运维排障',
|
||||
]
|
||||
|
||||
const canSubmit = computed(() => Boolean(username.value && password.value) && !loading.value)
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请输入用户名和密码'
|
||||
@@ -17,7 +25,6 @@ const handleLogin = async () => {
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const { data } = await login(username.value, password.value)
|
||||
setToken(data.token)
|
||||
@@ -32,65 +39,39 @@ const handleLogin = async () => {
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Animated particles -->
|
||||
<div class="particles">
|
||||
<div class="particle particle-1"></div>
|
||||
<div class="particle particle-2"></div>
|
||||
<div class="particle particle-3"></div>
|
||||
<div class="particle particle-4"></div>
|
||||
</div>
|
||||
<div class="login-shell glass-card">
|
||||
<section class="login-hero">
|
||||
<span class="login-badge">GoTunnel Console</span>
|
||||
<h1>更统一、更轻量的管理界面。</h1>
|
||||
<p>聚焦连接状态、更新能力与节点管理,让日常操作更直观、页面更简洁。</p>
|
||||
<ul>
|
||||
<li v-for="item in features" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Login card -->
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo-icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="logo-text">GoTunnel</h1>
|
||||
<p class="subtitle">安全的内网穿透工具</p>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="login-form">
|
||||
<div class="form-group">
|
||||
<label class="form-label">用户名</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="glass-input"
|
||||
placeholder="请输入用户名"
|
||||
:disabled="loading"
|
||||
/>
|
||||
<section class="login-panel">
|
||||
<div class="login-panel__header">
|
||||
<h2>登录控制台</h2>
|
||||
<p>使用服务端配置的 Web 账号进入管理界面。</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">密码</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="glass-input"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
<form class="login-form" @submit.prevent="handleLogin">
|
||||
<label class="form-group">
|
||||
<span>用户名</span>
|
||||
<input v-model="username" class="glass-input" type="text" autocomplete="username" placeholder="请输入用户名" />
|
||||
</label>
|
||||
<label class="form-group">
|
||||
<span>密码</span>
|
||||
<input v-model="password" class="glass-input" type="password" autocomplete="current-password" placeholder="请输入密码" />
|
||||
</label>
|
||||
|
||||
<div v-if="error" class="error-alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
<div v-if="error" class="error-alert">{{ error }}</div>
|
||||
|
||||
<button type="submit" class="glass-button" :disabled="loading">
|
||||
<span v-if="loading" class="loading-spinner"></span>
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="login-footer">
|
||||
<span>欢迎使用 GoTunnel</span>
|
||||
</div>
|
||||
<button class="glass-btn primary submit-btn" type="submit" :disabled="!canSubmit">
|
||||
{{ loading ? '登录中...' : '进入控制台' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -101,145 +82,94 @@ const handleLogin = async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--gradient-bg);
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-shell {
|
||||
width: min(1080px, 100%);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 420px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 动画背景粒子 */
|
||||
.particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
.login-hero,
|
||||
.login-panel {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.2;
|
||||
filter: blur(60px);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.particle-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--color-accent);
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.particle-2 {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
background: #8b5cf6;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.particle-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: var(--color-info);
|
||||
top: 50%;
|
||||
left: 20%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
.particle-4 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: #ec4899;
|
||||
bottom: 30%;
|
||||
right: 10%;
|
||||
animation-delay: -15s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -30px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(-30px, -20px) scale(1.02); }
|
||||
}
|
||||
|
||||
/* Login card - 毛玻璃效果 */
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-card);
|
||||
padding: 48px 36px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* 卡片顶部高光 */
|
||||
.login-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.15) 50%,
|
||||
transparent 100%);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 20px;
|
||||
background: var(--gradient-accent);
|
||||
border-radius: 16px;
|
||||
.login-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8px 24px var(--color-accent-glow);
|
||||
gap: 18px;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(139, 92, 246, 0.08));
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.logo-icon svg {
|
||||
color: white;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
.login-badge {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-accent);
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
border: 1px solid rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
background: var(--gradient-accent);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
.login-hero h1 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-size: clamp(34px, 4.5vw, 54px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.login-hero p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.login-hero ul {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
margin: 8px 0 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.login-hero li {
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.login-panel__header h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.login-panel__header p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
@@ -248,111 +178,44 @@ const handleLogin = async () => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: var(--glass-bg-light);
|
||||
backdrop-filter: var(--glass-blur-light);
|
||||
-webkit-backdrop-filter: var(--glass-blur-light);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 15px;
|
||||
width: 100%;
|
||||
transition: all 0.2s ease;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-glow);
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.glass-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error alert */
|
||||
.error-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
border-radius: 10px;
|
||||
color: var(--color-error);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-alert svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Button */
|
||||
.glass-button {
|
||||
background: var(--gradient-accent);
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 14px 24px;
|
||||
color: white;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
box-shadow: 0 4px 15px var(--color-accent-glow);
|
||||
}
|
||||
|
||||
.glass-button:hover:not(:disabled) {
|
||||
box-shadow: 0 6px 20px var(--color-accent-glow);
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.glass-button:active:not(:disabled) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
}
|
||||
|
||||
.glass-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Loading spinner */
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 28px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
.form-group span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
color: var(--color-error);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-page {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.login-hero,
|
||||
.login-panel {
|
||||
padding: 28px 22px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { SaveOutline, ServerOutline, SettingsOutline } from '@vicons/ionicons5'
|
||||
import MetricCard from '../components/MetricCard.vue'
|
||||
import PageShell from '../components/PageShell.vue'
|
||||
import SectionCard from '../components/SectionCard.vue'
|
||||
import {
|
||||
getServerConfig,
|
||||
getVersionInfo,
|
||||
@@ -12,13 +14,11 @@ import {
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const message = useToast()
|
||||
|
||||
const versionInfo = ref<VersionInfo | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const serverConfig = ref<ServerConfigResponse | null>(null)
|
||||
const configLoading = ref(false)
|
||||
const savingConfig = ref(false)
|
||||
const loadingVersion = ref(true)
|
||||
const loadingConfig = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const configForm = ref({
|
||||
heartbeat_sec: 30,
|
||||
@@ -28,18 +28,19 @@ const configForm = ref({
|
||||
})
|
||||
|
||||
const loadVersionInfo = async () => {
|
||||
loadingVersion.value = true
|
||||
try {
|
||||
const { data } = await getVersionInfo()
|
||||
versionInfo.value = data
|
||||
} catch (error) {
|
||||
console.error('Failed to load version info', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingVersion.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadServerConfig = async () => {
|
||||
configLoading.value = true
|
||||
loadingConfig.value = true
|
||||
try {
|
||||
const { data } = await getServerConfig()
|
||||
serverConfig.value = data
|
||||
@@ -51,15 +52,16 @@ const loadServerConfig = async () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server config', error)
|
||||
message.error('服务器配置加载失败')
|
||||
} finally {
|
||||
configLoading.value = false
|
||||
loadingConfig.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
savingConfig.value = true
|
||||
saving.value = true
|
||||
try {
|
||||
const updateReq: UpdateServerConfigRequest = {
|
||||
const payload: UpdateServerConfigRequest = {
|
||||
server: {
|
||||
heartbeat_sec: configForm.value.heartbeat_sec,
|
||||
heartbeat_timeout: configForm.value.heartbeat_timeout,
|
||||
@@ -70,19 +72,19 @@ const handleSaveConfig = async () => {
|
||||
}
|
||||
|
||||
if (configForm.value.web_password) {
|
||||
updateReq.web = {
|
||||
...updateReq.web,
|
||||
payload.web = {
|
||||
...payload.web,
|
||||
password: configForm.value.web_password,
|
||||
}
|
||||
}
|
||||
|
||||
await updateServerConfig(updateReq)
|
||||
message.success('配置已保存,部分配置需要重启服务后生效')
|
||||
await updateServerConfig(payload)
|
||||
configForm.value.web_password = ''
|
||||
message.success('配置已保存,部分配置需要重启后生效')
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data || '保存配置失败')
|
||||
} finally {
|
||||
savingConfig.value = false
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,375 +95,122 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="particles">
|
||||
<div class="particle particle-1"></div>
|
||||
<div class="particle particle-2"></div>
|
||||
<div class="particle particle-3"></div>
|
||||
<PageShell title="系统设置" eyebrow="Settings" subtitle="统一整理运行版本与服务配置,减少样式重复并保留关键运维操作。">
|
||||
<template #actions>
|
||||
<button class="glass-btn" @click="loadVersionInfo">刷新版本</button>
|
||||
<button class="glass-btn primary" :disabled="saving" @click="handleSaveConfig">{{ saving ? '保存中...' : '保存配置' }}</button>
|
||||
</template>
|
||||
|
||||
<template #metrics>
|
||||
<MetricCard label="当前版本" :value="versionInfo?.version || '—'" :hint="versionInfo?.git_commit?.slice(0, 8) || '未知提交'" />
|
||||
<MetricCard label="Go 版本" :value="versionInfo?.go_version || '—'" hint="运行时版本" tone="info" />
|
||||
<MetricCard label="运行平台" :value="versionInfo ? `${versionInfo.os}/${versionInfo.arch}` : '—'" hint="服务端当前平台" tone="success" />
|
||||
<MetricCard label="Web 用户名" :value="configForm.web_username || '—'" hint="控制台登录账号" tone="warning" />
|
||||
</template>
|
||||
|
||||
<div class="settings-grid">
|
||||
<SectionCard title="版本信息" description="查看当前服务端构建信息,方便排查环境与升级状态。">
|
||||
<div v-if="loadingVersion" class="empty-state">正在加载版本信息...</div>
|
||||
<dl v-else-if="versionInfo" class="info-grid">
|
||||
<div><dt>版本号</dt><dd>{{ versionInfo.version }}</dd></div>
|
||||
<div><dt>Git 提交</dt><dd>{{ versionInfo.git_commit || 'N/A' }}</dd></div>
|
||||
<div><dt>构建时间</dt><dd>{{ versionInfo.build_time || 'N/A' }}</dd></div>
|
||||
<div><dt>Go 版本</dt><dd>{{ versionInfo.go_version }}</dd></div>
|
||||
<div><dt>操作系统</dt><dd>{{ versionInfo.os }}</dd></div>
|
||||
<div><dt>架构</dt><dd>{{ versionInfo.arch }}</dd></div>
|
||||
</dl>
|
||||
<div v-else class="empty-state">无法获取版本信息。</div>
|
||||
</SectionCard>
|
||||
|
||||
<SectionCard title="服务配置" description="保留最常用的心跳与登录项配置,页面结构更精简。">
|
||||
<div v-if="loadingConfig" class="empty-state">正在加载服务器配置...</div>
|
||||
<form v-else class="config-form" @submit.prevent="handleSaveConfig">
|
||||
<label class="form-group">
|
||||
<span>心跳间隔(秒)</span>
|
||||
<input v-model.number="configForm.heartbeat_sec" class="glass-input" min="1" max="300" type="number" />
|
||||
</label>
|
||||
<label class="form-group">
|
||||
<span>心跳超时(秒)</span>
|
||||
<input v-model.number="configForm.heartbeat_timeout" class="glass-input" min="1" max="600" type="number" />
|
||||
</label>
|
||||
<label class="form-group form-group--full">
|
||||
<span>Web 用户名</span>
|
||||
<input v-model="configForm.web_username" class="glass-input" type="text" placeholder="admin" />
|
||||
</label>
|
||||
<label class="form-group form-group--full">
|
||||
<span>Web 密码</span>
|
||||
<input v-model="configForm.web_password" class="glass-input" type="password" placeholder="留空则保持不变" />
|
||||
</label>
|
||||
</form>
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">系统设置</h1>
|
||||
<p class="page-subtitle">管理服务端配置和系统更新</p>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h3>版本信息</h3>
|
||||
<ServerOutline class="header-icon" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="loading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="versionInfo" class="info-grid">
|
||||
<div class="info-item">
|
||||
<span class="info-label">版本号</span>
|
||||
<span class="info-value">{{ versionInfo.version }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Git 提交</span>
|
||||
<span class="info-value mono">{{ versionInfo.git_commit?.slice(0, 8) || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">构建时间</span>
|
||||
<span class="info-value">{{ versionInfo.build_time || 'N/A' }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">Go 版本</span>
|
||||
<span class="info-value">{{ versionInfo.go_version }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">操作系统</span>
|
||||
<span class="info-value">{{ versionInfo.os }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="info-label">架构</span>
|
||||
<span class="info-value">{{ versionInfo.arch }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">无法加载版本信息</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<div class="card-header">
|
||||
<h3>服务器配置</h3>
|
||||
<SettingsOutline class="header-icon" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="configLoading" class="loading-state">加载中...</div>
|
||||
<div v-else-if="serverConfig" class="config-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">心跳间隔 (秒)</label>
|
||||
<input
|
||||
v-model.number="configForm.heartbeat_sec"
|
||||
type="number"
|
||||
class="glass-input"
|
||||
min="1"
|
||||
max="300"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">心跳超时 (秒)</label>
|
||||
<input
|
||||
v-model.number="configForm.heartbeat_timeout"
|
||||
type="number"
|
||||
class="glass-input"
|
||||
min="1"
|
||||
max="600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-divider"></div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Web 用户名</label>
|
||||
<input
|
||||
v-model="configForm.web_username"
|
||||
type="text"
|
||||
class="glass-input"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Web 密码</label>
|
||||
<input
|
||||
v-model="configForm.web_password"
|
||||
type="password"
|
||||
class="glass-input"
|
||||
placeholder="留空则不修改"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="glass-btn primary"
|
||||
:disabled="savingConfig"
|
||||
@click="handleSaveConfig"
|
||||
>
|
||||
<SaveOutline class="btn-icon" />
|
||||
保存配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">无法加载配置信息</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
min-height: calc(100vh - 108px);
|
||||
background: transparent;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.particles {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
opacity: 0.15;
|
||||
filter: blur(60px);
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.particle-1 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: var(--color-accent);
|
||||
top: -80px;
|
||||
right: -80px;
|
||||
}
|
||||
|
||||
.particle-2 {
|
||||
width: 280px;
|
||||
height: 280px;
|
||||
background: #8b5cf6;
|
||||
bottom: -40px;
|
||||
left: -40px;
|
||||
animation-delay: -5s;
|
||||
}
|
||||
|
||||
.particle-3 {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: var(--color-success);
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
animation-delay: -10s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -30px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(-30px, -20px) scale(1.02); }
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--color-bg-tertiary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.info-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.info-grid div,
|
||||
.form-group {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value.mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.glass-btn {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: var(--color-text-primary);
|
||||
.info-grid dt,
|
||||
.form-group span {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.glass-btn:hover:not(:disabled) {
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
.glass-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.glass-btn.primary {
|
||||
background: var(--color-accent);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.glass-btn.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.header-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.info-grid dd {
|
||||
color: var(--color-text-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 13px;
|
||||
.form-group--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
background: var(--glass-bg-light);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.form-row {
|
||||
@media (max-width: 960px) {
|
||||
.settings-grid,
|
||||
.info-grid,
|
||||
.config-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border-light);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.glass-input {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.glass-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.glass-input::placeholder {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user