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

CI overhaul and major frontend UI refactor (new components, layouts, and release workflow)
This commit is contained in:
Flik
2026-03-19 20:22:23 +08:00
committed by GitHub
8 changed files with 1352 additions and 2324 deletions

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View File

@@ -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>
<div class="clients-content">
<!-- Header -->
<div class="page-header">
<h1 class="page-title">客户端管理</h1>
<p class="page-subtitle">管理所有连接的客户端</p>
</div>
<!-- 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>
<!-- 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">
<PageShell title="客户端" eyebrow="Clients" subtitle="统一管理已注册节点、连接状态与快速安装命令,减少操作跳转。">
<template #actions>
<button class="glass-btn" :disabled="generatingInstall" @click="openInstallModal">
{{ 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>
</div>
</div>
<button class="glass-btn primary" @click="loadClients">{{ loading ? '刷新中...' : '刷新列表' }}</button>
</template>
<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>
<SectionCard title="节点列表" description="使用统一卡片样式展示连接信息,便于快速判断状态与进入详情页。">
<template #header>
<input v-model="search" class="glass-input search-input" type="search" placeholder="搜索 ID / 昵称 / 地址" />
</template>
<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>
<!-- 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>
<dl class="client-card__meta">
<div>
<dt>地址</dt>
<dd>{{ client.remote_addr || '未上报' }}</dd>
</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>
</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>
</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>
</div>
<p class="token-info">客户端 ID 会在目标机器上根据多种设备标识自动计算</p>
<p class="token-warning"> 此命令包含一次性token使用后需重新生成</p>
<div>
<dt>规则数</dt>
<dd>{{ client.rule_count || 0 }}</dd>
</div>
<div>
<dt>平台</dt>
<dd>{{ [client.os, client.arch].filter(Boolean).join(' / ') || '未知' }}</dd>
</div>
</dl>
</article>
</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>

View File

@@ -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(),
])
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
}
// 加载每小时流量
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),
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
outbound: 0,
}
})
}
trafficHistory.value = emptyRecords
} else {
trafficHistory.value = records
}
} catch (e) {
console.error('Failed to load hourly traffic', e)
} 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>
</div>
<PageShell title="控制台" eyebrow="Overview" subtitle="统一查看连接状态、流量趋势与客户端健康情况,减少页面层级并突出关键数据。">
<template #actions>
<button class="glass-btn" @click="loadDashboard">{{ loading ? '刷新中...' : '刷新数据' }}</button>
</template>
<!-- Main content -->
<div class="dashboard-content">
<!-- Header -->
<div class="dashboard-header">
<h1 class="dashboard-title">仪表盘</h1>
<p class="dashboard-subtitle">监控隧道连接和流量状态</p>
</div>
<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>
<!-- 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 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>
<!-- 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 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>
<!-- 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>
<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>
</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>

View File

@@ -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 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>
<section class="login-panel">
<div class="login-panel__header">
<h2>登录控制台</h2>
<p>使用服务端配置的 Web 账号进入管理界面</p>
</div>
<!-- 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 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>
<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"
/>
</div>
<div v-if="error" class="error-alert">{{ error }}</div>
<div class="form-group">
<label class="form-label">密码</label>
<input
v-model="password"
type="password"
class="glass-input"
placeholder="请输入密码"
:disabled="loading"
/>
</div>
<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>
<button type="submit" class="glass-button" :disabled="loading">
<span v-if="loading" class="loading-spinner"></span>
{{ loading ? '登录中...' : '登录' }}
<button class="glass-btn primary submit-btn" type="submit" :disabled="!canSubmit">
{{ loading ? '登录中...' : '进入控制台' }}
</button>
</form>
<div class="login-footer">
<span>欢迎使用 GoTunnel</span>
</div>
</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>

View File

@@ -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>
</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>
<div class="settings-content">
<div class="page-header">
<h1 class="page-title">系统设置</h1>
<p class="page-subtitle">管理服务端配置和系统更新</p>
</div>
<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="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="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>
<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>
<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>
</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>