web/frpc: redesign dashboard (#5145)

This commit is contained in:
fatedier 2026-01-31 12:43:31 +08:00 committed by GitHub
parent 266c492b5d
commit 886c9c2fdb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1540 additions and 441 deletions

View File

@ -9,16 +9,18 @@ declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElMenu: typeof import('element-plus/es')['ElMenu'] ElRow: typeof import('element-plus/es')['ElRow']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
StatCard: typeof import('./src/components/StatCard.vue')['default']
} }
export interface ComponentCustomProperties { export interface ComponentCustomProperties {
vLoading: typeof import('element-plus/es')['ElLoadingDirective'] vLoading: typeof import('element-plus/es')['ElLoadingDirective']

View File

@ -1,42 +1,50 @@
<template> <template>
<div id="app"> <div id="app">
<header class="header"> <header class="header">
<div class="header-top"> <div class="header-content">
<div class="brand"> <div class="header-top">
<a href="#" @click.prevent="router.push('/')">frpc</a> <div class="brand-section">
<div class="logo-wrapper">
<LogoIcon class="logo-icon" />
</div>
<span class="divider">/</span>
<span class="brand-name">frp</span>
<span class="badge client-badge">Client</span>
<span class="badge" v-if="currentRouteName">{{
currentRouteName
}}</span>
</div>
<div class="header-controls">
<a
class="github-link"
href="https://github.com/fatedier/frp"
target="_blank"
aria-label="GitHub"
>
<GitHubIcon class="github-icon" />
</a>
<el-switch
v-model="isDark"
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
class="theme-switch"
/>
</div>
</div> </div>
<div class="header-actions">
<a <nav class="nav-bar">
class="github-link" <router-link to="/" class="nav-link" active-class="active"
href="https://github.com/fatedier/frp" >Overview</router-link
target="_blank"
aria-label="GitHub"
> >
<GitHubIcon class="github-icon" /> <router-link to="/configure" class="nav-link" active-class="active"
</a> >Configure</router-link
<el-switch >
v-model="darkmodeSwitch" </nav>
inline-prompt
:active-icon="Moon"
:inactive-icon="Sunny"
@change="toggleDark"
class="theme-switch"
/>
</div>
</div> </div>
<nav class="header-nav">
<el-menu
:default-active="currentRoute"
mode="horizontal"
:ellipsis="false"
@select="handleSelect"
class="nav-menu"
>
<el-menu-item index="/">Overview</el-menu-item>
<el-menu-item index="/configure">Configure</el-menu-item>
</el-menu>
</nav>
</header> </header>
<main id="content"> <main id="content">
<router-view></router-view> <router-view></router-view>
</main> </main>
@ -44,252 +52,214 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useDark, useToggle } from '@vueuse/core' import { useDark } from '@vueuse/core'
import { Moon, Sunny } from '@element-plus/icons-vue' import { Moon, Sunny } from '@element-plus/icons-vue'
import GitHubIcon from './assets/icons/github.svg?component' import GitHubIcon from './assets/icons/github.svg?component'
import LogoIcon from './assets/icons/logo.svg?component'
const router = useRouter()
const route = useRoute() const route = useRoute()
const isDark = useDark() const isDark = useDark()
const darkmodeSwitch = ref(isDark)
const toggleDark = useToggle(isDark)
const currentRoute = computed(() => { const currentRouteName = computed(() => {
return route.path if (route.path === '/') return 'Overview'
if (route.path === '/configure') return 'Configure'
return ''
}) })
const handleSelect = (key: string) => {
router.push(key)
}
</script> </script>
<style> <style>
:root {
--header-height: 112px;
--header-bg: rgba(255, 255, 255, 0.8);
--header-border: #eaeaea;
--text-primary: #000;
--text-secondary: #666;
--hover-bg: #f5f5f5;
--active-link: #000;
}
html.dark {
--header-bg: rgba(0, 0, 0, 0.8);
--header-border: #333;
--text-primary: #fff;
--text-secondary: #888;
--hover-bg: #1a1a1a;
--active-link: #fff;
}
body { body {
margin: 0; margin: 0;
font-family: font-family:
-apple-system, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
BlinkMacSystemFont, Arial, sans-serif;
Helvetica Neue,
sans-serif;
} }
#app { #app {
min-height: 100vh; min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #f2f2f2; background-color: var(--el-bg-color-page);
}
html.dark #app {
background: #1a1a2e;
} }
.header { .header {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
background: #fff; background: var(--header-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--header-border);
} }
html.dark .header { .header-content {
background: #1e1e2d; max-width: 1200px;
margin: 0 auto;
padding: 0 40px;
} }
.header-top { .header-top {
height: 64px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 48px;
padding: 0 32px;
} }
.brand a { .brand-section {
color: #303133; display: flex;
font-size: 20px; align-items: center;
font-weight: 700; gap: 12px;
text-decoration: none; }
.logo-wrapper {
display: flex;
align-items: center;
}
.logo-icon {
width: 32px;
height: 32px;
}
.divider {
color: var(--header-border);
font-size: 24px;
font-weight: 200;
}
.brand-name {
font-weight: 600;
font-size: 18px;
color: var(--text-primary);
letter-spacing: -0.5px; letter-spacing: -0.5px;
} }
html.dark .brand a { .badge {
color: #e5e7eb; font-size: 12px;
color: var(--text-secondary);
background: var(--hover-bg);
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--header-border);
} }
.brand a:hover { .badge.client-badge {
color: #409eff; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
font-weight: 500;
} }
.header-actions { html.dark .badge.client-badge {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
.header-controls {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.github-link { .github-link {
width: 26px;
height: 26px;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 6px; justify-content: center;
border-radius: 6px; border-radius: 50%;
transition: all 0.2s; color: var(--text-primary);
} transition: background 0.2s;
background: transparent;
.github-link:hover { border: 1px solid transparent;
background: #f2f3f5; cursor: pointer;
}
html.dark .github-link:hover {
background: #2a2a3c;
} }
.github-icon { .github-icon {
width: 20px; width: 18px;
height: 20px; height: 18px;
color: #606266;
transition: color 0.2s;
} }
.github-link:hover .github-icon { .github-link:hover {
color: #303133; background: var(--hover-bg);
} border-color: var(--header-border);
html.dark .github-icon {
color: #a0a3ad;
}
html.dark .github-link:hover .github-icon {
color: #e5e7eb;
} }
.theme-switch { .theme-switch {
--el-switch-on-color: #2c2c3a; --el-switch-on-color: #2c2c3a;
--el-switch-off-color: #f2f2f2; --el-switch-off-color: #f2f2f2;
--el-switch-border-color: #dcdfe6; --el-switch-border-color: var(--header-border);
}
html.dark .theme-switch {
--el-switch-off-color: #333;
} }
.theme-switch .el-switch__core .el-switch__inner .el-icon { .theme-switch .el-switch__core .el-switch__inner .el-icon {
color: #909399 !important; color: #909399 !important;
} }
.header-nav { .nav-bar {
position: relative; height: 48px;
padding: 0 32px; display: flex;
border-bottom: 1px solid #e4e7ed; align-items: center;
gap: 24px;
} }
html.dark .header-nav { .nav-link {
border-bottom-color: #3a3d5c; text-decoration: none;
}
.nav-menu {
background: transparent !important;
border-bottom: none !important;
height: 46px;
}
.nav-menu .el-menu-item,
.nav-menu .el-sub-menu__title {
position: relative;
height: 32px !important;
line-height: 32px !important;
border-bottom: none !important;
border-radius: 6px !important;
color: #666 !important;
font-weight: 400;
font-size: 14px; font-size: 14px;
padding: 0 12px !important; color: var(--text-secondary);
margin: 7px 0; padding: 8px 0;
transition: border-bottom: 2px solid transparent;
background 0.15s ease, transition: all 0.2s;
color 0.15s ease;
} }
.nav-menu > .el-menu-item, .nav-link:hover {
.nav-menu > .el-sub-menu { color: var(--text-primary);
margin-right: 4px;
} }
.nav-menu > .el-sub-menu { .nav-link.active {
padding: 0 !important; color: var(--active-link);
} border-bottom-color: var(--active-link);
html.dark .nav-menu .el-menu-item,
html.dark .nav-menu .el-sub-menu__title {
color: #888 !important;
}
.nav-menu .el-menu-item:hover,
.nav-menu .el-sub-menu__title:hover {
background: #f2f2f2 !important;
color: #171717 !important;
}
html.dark .nav-menu .el-menu-item:hover,
html.dark .nav-menu .el-sub-menu__title:hover {
background: #2a2a3c !important;
color: #e5e7eb !important;
}
.nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #171717 !important;
font-weight: 500;
}
.nav-menu .el-menu-item.is-active::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: -3px;
height: 2px;
background: #171717;
border-radius: 1px;
}
.nav-menu .el-menu-item.is-active:hover {
background: #f2f2f2 !important;
}
html.dark .nav-menu .el-menu-item.is-active {
background: transparent !important;
color: #e5e7eb !important;
font-weight: 500;
}
html.dark .nav-menu .el-menu-item.is-active::after {
background: #e5e7eb;
}
html.dark .nav-menu .el-menu-item.is-active:hover {
background: #2a2a3c !important;
} }
#content { #content {
flex: 1; flex: 1;
padding: 24px 40px;
max-width: 1400px;
margin: 0 auto;
width: 100%; width: 100%;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
box-sizing: border-box; box-sizing: border-box;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.header-top { .header-content {
padding: 0 16px; padding: 0 20px;
}
.header-nav {
padding: 0 16px;
} }
#content { #content {
padding: 16px; padding: 20px;
}
.brand a {
font-size: 18px;
} }
} }
</style> </style>

View File

@ -19,7 +19,11 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(url, { ...defaultOptions, ...options }) const response = await fetch(url, { ...defaultOptions, ...options })
if (!response.ok) { if (!response.ok) {
throw new HTTPError(response.status, response.statusText, `HTTP ${response.status}`) throw new HTTPError(
response.status,
response.statusText,
`HTTP ${response.status}`,
)
} }
// Handle empty response (e.g. 204 No Content) // Handle empty response (e.g. 204 No Content)
@ -35,42 +39,54 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
} }
export const http = { export const http = {
get: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'GET' }), get: <T>(url: string, options?: RequestInit) =>
request<T>(url, { ...options, method: 'GET' }),
post: <T>(url: string, body?: any, options?: RequestInit) => { post: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers } const headers: HeadersInit = { ...options?.headers }
let requestBody = body let requestBody = body
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) { if (
if (!('Content-Type' in headers)) { body &&
(headers as any)['Content-Type'] = 'application/json' typeof body === 'object' &&
} !(body instanceof FormData) &&
requestBody = JSON.stringify(body) !(body instanceof Blob)
) {
if (!('Content-Type' in headers)) {
;(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
} }
return request<T>(url, { return request<T>(url, {
...options, ...options,
method: 'POST', method: 'POST',
headers, headers,
body: requestBody body: requestBody,
}) })
}, },
put: <T>(url: string, body?: any, options?: RequestInit) => { put: <T>(url: string, body?: any, options?: RequestInit) => {
const headers: HeadersInit = { ...options?.headers } const headers: HeadersInit = { ...options?.headers }
let requestBody = body let requestBody = body
if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof Blob)) { if (
if (!('Content-Type' in headers)) { body &&
(headers as any)['Content-Type'] = 'application/json' typeof body === 'object' &&
} !(body instanceof FormData) &&
requestBody = JSON.stringify(body) !(body instanceof Blob)
) {
if (!('Content-Type' in headers)) {
;(headers as any)['Content-Type'] = 'application/json'
}
requestBody = JSON.stringify(body)
} }
return request<T>(url, { return request<T>(url, {
...options, ...options,
method: 'PUT', method: 'PUT',
headers, headers,
body: requestBody body: requestBody,
}) })
}, },
delete: <T>(url: string, options?: RequestInit) => request<T>(url, { ...options, method: 'DELETE' }), delete: <T>(url: string, options?: RequestInit) =>
} request<T>(url, { ...options, method: 'DELETE' }),
}

View File

@ -1,32 +1,9 @@
.el-form-item span { /* Modern Base Styles */
margin-left: 15px;
}
.proxy-table-expand {
font-size: 0;
}
.proxy-table-expand .el-form-item__label{
width: 90px;
color: #99a9bf;
}
.proxy-table-expand .el-form-item {
margin-right: 0;
margin-bottom: 0;
width: 50%;
}
.el-table .el-table__expanded-cell {
padding: 20px 50px;
}
/* Modern styles */
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
/* Smooth transitions */ /* Smooth transitions for Element Plus components */
.el-button, .el-button,
.el-card, .el-card,
.el-input, .el-input,
@ -37,7 +14,7 @@
/* Card hover effects */ /* Card hover effects */
.el-card:hover { .el-card:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); box-shadow: 0 8px 16px rgba(0, 0, 0, 0.08);
} }
/* Better scrollbar */ /* Better scrollbar */
@ -60,16 +37,6 @@
background: #a8a8a8; background: #a8a8a8;
} }
/* Page headers */
.el-page-header {
padding: 16px 0;
}
.el-page-header__title {
font-size: 20px;
font-weight: 600;
}
/* Better form layouts */ /* Better form layouts */
.el-form-item { .el-form-item {
margin-bottom: 18px; margin-bottom: 18px;
@ -87,3 +54,52 @@
padding-right: 10px !important; padding-right: 10px !important;
} }
} }
/* Input enhancements */
.el-input__wrapper {
transition: all 0.2s ease;
}
.el-input__wrapper:hover {
box-shadow: 0 0 0 1px var(--el-border-color-hover) inset;
}
/* Button enhancements */
.el-button {
font-weight: 500;
}
/* Tag enhancements */
.el-tag {
font-weight: 500;
}
/* Card enhancements */
.el-card__header {
padding: 16px 20px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.el-card__body {
padding: 20px;
}
/* Table enhancements */
.el-table {
font-size: 14px;
}
.el-table th {
font-weight: 600;
}
/* Empty state */
.el-empty__description {
margin-top: 16px;
font-size: 14px;
}
/* Loading state */
.el-loading-mask {
border-radius: 12px;
}

View File

@ -1,11 +1,14 @@
/* Dark Mode Theme */
html.dark { html.dark {
--el-bg-color: #1e1e2e; --el-bg-color: #1e1e2e;
--el-bg-color-page: #1a1a2e;
--el-bg-color-overlay: #27293d;
--el-fill-color-blank: #1e1e2e; --el-fill-color-blank: #1e1e2e;
background-color: #1e1e2e; background-color: #1a1a2e;
} }
html.dark body { html.dark body {
background-color: #1e1e2e; background-color: #1a1a2e;
color: #e5e7eb; color: #e5e7eb;
} }
@ -28,23 +31,50 @@ html.dark .el-card {
border-color: #3a3d5c; border-color: #3a3d5c;
} }
html.dark .el-card__header {
border-bottom-color: #3a3d5c;
}
/* Dark mode inputs */ /* Dark mode inputs */
html.dark .el-input__wrapper { html.dark .el-input__wrapper {
background-color: #27293d; background-color: #27293d;
border-color: #3a3d5c; box-shadow: 0 0 0 1px #3a3d5c inset;
}
html.dark .el-input__wrapper:hover {
box-shadow: 0 0 0 1px #4a4d6c inset;
}
html.dark .el-input__wrapper.is-focus {
box-shadow: 0 0 0 1px var(--el-color-primary) inset;
} }
html.dark .el-input__inner { html.dark .el-input__inner {
color: #e5e7eb; color: #e5e7eb;
} }
html.dark .el-input__inner::placeholder {
color: #6b7280;
}
/* Dark mode textarea */
html.dark .el-textarea__inner {
background-color: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-textarea__inner::placeholder {
color: #6b7280;
}
/* Dark mode table */ /* Dark mode table */
html.dark .el-table { html.dark .el-table {
background-color: #27293d; background-color: #27293d;
color: #e5e7eb; color: #e5e7eb;
} }
html.dark .el-table th { html.dark .el-table th.el-table__cell {
background-color: #1e1e2e; background-color: #1e1e2e;
color: #e5e7eb; color: #e5e7eb;
} }
@ -53,6 +83,98 @@ html.dark .el-table tr {
background-color: #27293d; background-color: #27293d;
} }
html.dark .el-table--striped .el-table__body tr.el-table__row--striped td { html.dark .el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell {
background-color: #1e1e2e; background-color: #1e1e2e;
} }
html.dark .el-table__row:hover > td.el-table__cell {
background-color: #2a2a3c !important;
}
/* Dark mode tags */
html.dark .el-tag--info {
background-color: #3a3d5c;
border-color: #3a3d5c;
color: #e5e7eb;
}
/* Dark mode buttons */
html.dark .el-button--default {
background-color: #27293d;
border-color: #3a3d5c;
color: #e5e7eb;
}
html.dark .el-button--default:hover {
background-color: #2a2a3c;
border-color: #4a4d6c;
color: #fff;
}
/* Dark mode select */
html.dark .el-select .el-input__wrapper {
background-color: #27293d;
}
html.dark .el-select-dropdown {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-select-dropdown__item {
color: #e5e7eb;
}
html.dark .el-select-dropdown__item:hover {
background-color: #2a2a3c;
}
/* Dark mode dialog */
html.dark .el-dialog {
background-color: #27293d;
}
html.dark .el-dialog__header {
border-bottom-color: #3a3d5c;
}
html.dark .el-dialog__title {
color: #e5e7eb;
}
html.dark .el-dialog__body {
color: #e5e7eb;
}
/* Dark mode message box */
html.dark .el-message-box {
background-color: #27293d;
border-color: #3a3d5c;
}
html.dark .el-message-box__title {
color: #e5e7eb;
}
html.dark .el-message-box__message {
color: #e5e7eb;
}
/* Dark mode empty */
html.dark .el-empty__description {
color: #9ca3af;
}
/* Dark mode loading */
html.dark .el-loading-mask {
background-color: rgba(30, 30, 46, 0.9);
}
html.dark .el-loading-text {
color: #e5e7eb;
}
/* Dark mode tooltip */
html.dark .el-tooltip__trigger {
color: #e5e7eb;
}

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 100 100" aria-label="F icon" role="img">
<circle cx="50" cy="50" r="46" fill="#477EE5"/>
<g transform="translate(50 50) skewX(-12) translate(-50 -50)">
<path
d="M37 28 V72
M37 28 H63
M37 50 H55"
fill="none"
stroke="#FFFFFF"
stroke-width="14"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@ -0,0 +1,236 @@
<template>
<div class="proxy-card" :class="{ 'has-error': proxy.err }">
<div class="card-main">
<div class="card-left">
<div class="card-header">
<span class="proxy-name">{{ proxy.name }}</span>
<span class="type-tag">{{ proxy.type.toUpperCase() }}</span>
</div>
<div class="card-meta">
<span v-if="proxy.local_addr" class="meta-item">
<span class="meta-label">Local:</span>
<span class="meta-value code">{{ proxy.local_addr }}</span>
</span>
<span v-if="proxy.plugin" class="meta-item">
<span class="meta-label">Plugin:</span>
<span class="meta-value code">{{ proxy.plugin }}</span>
</span>
<span v-if="proxy.remote_addr" class="meta-item">
<span class="meta-label">Remote:</span>
<span class="meta-value code">{{ proxy.remote_addr }}</span>
</span>
</div>
</div>
<div class="card-right">
<div v-if="proxy.err" class="error-info">
<el-icon class="error-icon"><Warning /></el-icon>
<span class="error-text">{{ proxy.err }}</span>
</div>
<div class="status-badge" :class="statusClass">
{{ proxy.status }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { Warning } from '@element-plus/icons-vue'
import type { ProxyStatus } from '../types/proxy'
interface Props {
proxy: ProxyStatus
}
const props = defineProps<Props>()
const statusClass = computed(() => {
switch (props.proxy.status) {
case 'running':
return 'running'
case 'error':
return 'error'
default:
return 'waiting'
}
})
</script>
<style scoped>
.proxy-card {
display: block;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition: all 0.2s ease-in-out;
overflow: hidden;
}
.proxy-card:hover {
border-color: var(--el-border-color-light);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
}
.proxy-card.has-error {
border-color: var(--el-color-danger-light-5);
}
html.dark .proxy-card.has-error {
border-color: var(--el-color-danger-dark-2);
}
.card-main {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
gap: 24px;
min-height: 80px;
}
/* Left Section */
.card-left {
display: flex;
flex-direction: column;
justify-content: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
}
.proxy-name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
line-height: 1.4;
}
.type-tag {
font-size: 11px;
font-weight: 500;
padding: 2px 6px;
border-radius: 4px;
background: var(--el-fill-color);
color: var(--el-text-color-secondary);
}
.card-meta {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 6px;
line-height: 1;
}
.meta-label {
color: var(--el-text-color-placeholder);
font-size: 13px;
font-weight: 500;
}
.meta-value {
font-size: 13px;
font-weight: 500;
color: var(--el-text-color-regular);
}
.meta-value.code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
background: var(--el-fill-color-light);
padding: 2px 6px;
border-radius: 4px;
font-size: 12px;
}
/* Right Section */
.card-right {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.error-info {
display: flex;
align-items: center;
gap: 6px;
max-width: 200px;
}
.error-icon {
color: var(--el-color-danger);
font-size: 16px;
flex-shrink: 0;
}
.error-text {
font-size: 12px;
color: var(--el-color-danger);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-flex;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
text-transform: capitalize;
}
.status-badge.running {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.status-badge.error {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.status-badge.waiting {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.card-main {
flex-direction: column;
align-items: stretch;
gap: 16px;
padding: 16px;
}
.card-right {
flex-direction: row;
align-items: center;
justify-content: space-between;
border-top: 1px solid var(--el-border-color-lighter);
padding-top: 16px;
}
.error-info {
max-width: none;
flex: 1;
}
}
</style>

View File

@ -0,0 +1,202 @@
<template>
<el-card
class="stat-card"
:class="{ clickable: !!to }"
:body-style="{ padding: '20px' }"
shadow="hover"
@click="handleClick"
>
<div class="stat-card-content">
<div class="stat-icon" :class="`icon-${type}`">
<component :is="iconComponent" class="icon" />
</div>
<div class="stat-info">
<div class="stat-value">{{ value }}</div>
<div class="stat-label">{{ label }}</div>
</div>
<el-icon v-if="to" class="arrow-icon"><ArrowRight /></el-icon>
</div>
<div v-if="subtitle" class="stat-subtitle">{{ subtitle }}</div>
</el-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import {
Connection,
CircleCheck,
Warning,
Setting,
ArrowRight,
} from '@element-plus/icons-vue'
interface Props {
label: string
value: string | number
type?: 'proxies' | 'running' | 'error' | 'config'
subtitle?: string
to?: string
}
const props = withDefaults(defineProps<Props>(), {
type: 'proxies',
})
const router = useRouter()
const iconComponent = computed(() => {
switch (props.type) {
case 'proxies':
return Connection
case 'running':
return CircleCheck
case 'error':
return Warning
case 'config':
return Setting
default:
return Connection
}
})
const handleClick = () => {
if (props.to) {
router.push(props.to)
}
}
</script>
<style scoped>
.stat-card {
border-radius: 12px;
transition: all 0.3s ease;
border: 1px solid #e4e7ed;
}
.stat-card.clickable {
cursor: pointer;
}
.stat-card.clickable:hover {
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
.stat-card.clickable:hover .arrow-icon {
transform: translateX(4px);
}
html.dark .stat-card {
border-color: #3a3d5c;
background: #27293d;
}
.stat-card-content {
display: flex;
align-items: center;
gap: 16px;
}
.arrow-icon {
color: #909399;
font-size: 18px;
transition: transform 0.2s ease;
flex-shrink: 0;
}
html.dark .arrow-icon {
color: #9ca3af;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.stat-icon .icon {
width: 28px;
height: 28px;
}
.icon-proxies {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.icon-running {
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
color: white;
}
.icon-error {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
color: white;
}
.icon-config {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
}
html.dark .icon-proxies {
background: linear-gradient(135deg, #818cf8 0%, #a78bfa 100%);
}
html.dark .icon-running {
background: linear-gradient(135deg, #34d399 0%, #10b981 100%);
}
html.dark .icon-error {
background: linear-gradient(135deg, #fb7185 0%, #f43f5e 100%);
}
html.dark .icon-config {
background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 100%);
}
.stat-info {
flex: 1;
min-width: 0;
}
.stat-value {
font-size: 28px;
font-weight: 500;
line-height: 1.2;
color: #303133;
margin-bottom: 4px;
}
html.dark .stat-value {
color: #e5e7eb;
}
.stat-label {
font-size: 14px;
color: #909399;
font-weight: 500;
}
html.dark .stat-label {
color: #9ca3af;
}
.stat-subtitle {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #e4e7ed;
font-size: 12px;
color: #909399;
}
html.dark .stat-subtitle {
border-top-color: #3a3d5c;
color: #9ca3af;
}
</style>

View File

@ -10,4 +10,4 @@ const app = createApp(App)
app.use(router) app.use(router)
app.mount('#app') app.mount('#app')

View File

@ -18,4 +18,4 @@ const router = createRouter({
], ],
}) })
export default router export default router

View File

@ -1,12 +1,12 @@
export interface ProxyStatus { export interface ProxyStatus {
name: string name: string
type: string type: string
status: string status: string
err: string err: string
local_addr: string local_addr: string
plugin: string plugin: string
remote_addr: string remote_addr: string
[key: string]: any [key: string]: any
} }
export type StatusResponse = Record<string, ProxyStatus[]> export type StatusResponse = Record<string, ProxyStatus[]>

View File

@ -28,6 +28,6 @@ export function formatFileSize(bytes: number): string {
// Prevent index out of bounds for extremely large numbers // Prevent index out of bounds for extremely large numbers
const unit = sizes[i] || sizes[sizes.length - 1] const unit = sizes[i] || sizes[sizes.length - 1]
const val = bytes / Math.pow(k, i) const val = bytes / Math.pow(k, i)
return parseFloat(val.toFixed(2)) + ' ' + unit return parseFloat(val.toFixed(2)) + ' ' + unit
} }

View File

@ -1,33 +1,120 @@
<template> <template>
<div class="configure-page"> <div class="configure-page">
<el-card class="main-card" shadow="never"> <div class="page-header">
<div class="toolbar-header"> <div class="title-section">
<h2 class="card-title">Client Configuration</h2> <h1 class="page-title">Configuration</h1>
<div class="toolbar-actions"> <p class="page-subtitle">
<el-tooltip content="Refresh" placement="top"> Edit and manage your frpc configuration file
<el-button :icon="Refresh" circle @click="fetchData" /> </p>
</el-tooltip>
<el-button type="primary" :icon="Upload" @click="handleUpload">Update</el-button>
</div>
</div> </div>
</div>
<div class="config-editor"> <el-row :gutter="20">
<el-input <el-col :xs="24" :lg="16">
type="textarea" <el-card class="editor-card" shadow="hover">
:autosize="{ minRows: 10, maxRows: 30 }" <template #header>
v-model="configContent" <div class="card-header">
placeholder="frpc configuration file content..." <div class="header-left">
class="code-input" <span class="card-title">Configuration Editor</span>
></el-input> <el-tag size="small" type="success">TOML</el-tag>
</div> </div>
</el-card> <div class="header-actions">
<el-tooltip content="Refresh" placement="top">
<el-button :icon="Refresh" circle @click="fetchData" />
</el-tooltip>
<el-button type="primary" :icon="Upload" @click="handleUpload">
Update & Reload
</el-button>
</div>
</div>
</template>
<div class="editor-wrapper">
<el-input
type="textarea"
:autosize="{ minRows: 20, maxRows: 40 }"
v-model="configContent"
placeholder="# frpc configuration file content...
[common]
server_addr = 127.0.0.1
server_port = 7000"
class="code-editor"
></el-input>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card class="help-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Quick Reference</span>
</div>
</template>
<div class="help-content">
<div class="help-section">
<h4 class="help-section-title">Common Settings</h4>
<div class="help-items">
<div class="help-item">
<code>serverAddr</code>
<span>Server address</span>
</div>
<div class="help-item">
<code>serverPort</code>
<span>Server port (default: 7000)</span>
</div>
<div class="help-item">
<code>auth.token</code>
<span>Authentication token</span>
</div>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Proxy Types</h4>
<div class="proxy-type-tags">
<el-tag type="primary" effect="plain">TCP</el-tag>
<el-tag type="success" effect="plain">UDP</el-tag>
<el-tag type="warning" effect="plain">HTTP</el-tag>
<el-tag type="danger" effect="plain">HTTPS</el-tag>
<el-tag type="info" effect="plain">STCP</el-tag>
<el-tag effect="plain">XTCP</el-tag>
</div>
</div>
<div class="help-section">
<h4 class="help-section-title">Example Proxy</h4>
<pre class="code-example">
[[proxies]]
name = "web"
type = "http"
localPort = 80
customDomains = ["example.com"]</pre
>
</div>
<div class="help-section">
<a
href="https://github.com/fatedier/frp#configuration-files"
target="_blank"
class="docs-link"
>
<el-icon><Link /></el-icon>
View Full Documentation
</a>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh, Upload } from '@element-plus/icons-vue' import { Refresh, Upload, Link } from '@element-plus/icons-vue'
import { getConfig, putConfig, reloadConfig } from '../api/frpc' import { getConfig, putConfig, reloadConfig } from '../api/frpc'
const configContent = ref('') const configContent = ref('')
@ -53,7 +140,7 @@ const handleUpload = () => {
confirmButtonText: 'Update', confirmButtonText: 'Update',
cancelButtonText: 'Cancel', cancelButtonText: 'Cancel',
type: 'warning', type: 'warning',
} },
) )
.then(async () => { .then(async () => {
if (!configContent.value.trim()) { if (!configContent.value.trim()) {
@ -80,7 +167,7 @@ const handleUpload = () => {
} }
}) })
.catch(() => { .catch(() => {
// cancelled // cancelled
}) })
} }
@ -88,28 +175,227 @@ fetchData()
</script> </script>
<style scoped> <style scoped>
.main-card { .configure-page {
border-radius: 12px; display: flex;
border: none; flex-direction: column;
gap: 24px;
} }
.toolbar-header { .page-header {
display: flex;
flex-direction: column;
gap: 8px;
}
.title-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
line-height: 1.2;
}
.page-subtitle {
font-size: 14px;
color: var(--el-text-color-secondary);
margin: 0;
}
.editor-card,
.help-card {
border-radius: 12px;
border: 1px solid #e4e7ed;
}
html.dark .editor-card,
html.dark .help-card {
border-color: #3a3d5c;
background: #27293d;
}
.card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px; flex-wrap: wrap;
border-bottom: 1px solid var(--el-border-color-lighter); gap: 12px;
padding-bottom: 16px; }
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
} }
.card-title { .card-title {
margin: 0; font-size: 16px;
font-size: 18px; font-weight: 500;
font-weight: 600; color: #303133;
} }
.code-input { html.dark .card-title {
font-family: 'Menlo', 'Monaco', 'Courier New', monospace; color: #e5e7eb;
font-size: 14px;
} }
</style>
.editor-wrapper {
position: relative;
}
.code-editor :deep(.el-textarea__inner) {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 13px;
line-height: 1.6;
padding: 16px;
border-radius: 8px;
background: #f8f9fa;
border: 1px solid #e4e7ed;
resize: none;
}
html.dark .code-editor :deep(.el-textarea__inner) {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.code-editor :deep(.el-textarea__inner:focus) {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-5);
}
/* Help Card */
.help-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.help-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.help-section-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
margin: 0;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.help-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.help-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #f8f9fa;
border-radius: 6px;
font-size: 13px;
}
html.dark .help-item {
background: #1e1e2d;
}
.help-item code {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.help-item span {
color: var(--el-text-color-secondary);
flex: 1;
}
.proxy-type-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.code-example {
font-family:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e4e7ed;
margin: 0;
overflow-x: auto;
white-space: pre;
}
html.dark .code-example {
background: #1e1e2d;
border-color: #3a3d5c;
color: #e5e7eb;
}
.docs-link {
display: flex;
align-items: center;
gap: 8px;
color: var(--el-color-primary);
text-decoration: none;
font-size: 14px;
font-weight: 500;
padding: 12px 16px;
background: var(--el-color-primary-light-9);
border-radius: 8px;
transition: all 0.2s;
}
.docs-link:hover {
background: var(--el-color-primary-light-8);
}
@media (max-width: 768px) {
.card-header {
flex-direction: column;
align-items: stretch;
}
.header-left {
justify-content: space-between;
}
.header-actions {
justify-content: flex-end;
}
}
@media (max-width: 992px) {
.help-card {
margin-top: 20px;
}
}
</style>

View File

@ -1,92 +1,132 @@
<template> <template>
<div class="overview-page"> <div class="overview-page">
<el-card class="main-card" shadow="never"> <el-row :gutter="20" class="stats-row">
<div class="toolbar-header"> <el-col :xs="24" :sm="12" :lg="6">
<h2 class="card-title">Proxy Status</h2> <StatCard
<div class="toolbar-actions"> label="Total Proxies"
<el-input :value="stats.total"
v-model="searchText" type="proxies"
placeholder="Search..." subtitle="Configured proxies"
:prefix-icon="Search" />
clearable </el-col>
class="search-input" <el-col :xs="24" :sm="12" :lg="6">
/> <StatCard
<el-tooltip content="Refresh" placement="top"> label="Running"
<el-button :icon="Refresh" circle @click="fetchData" /> :value="stats.running"
</el-tooltip> type="running"
</div> subtitle="Active connections"
</div> />
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Error"
:value="stats.error"
type="error"
subtitle="Failed proxies"
/>
</el-col>
<el-col :xs="24" :sm="12" :lg="6">
<StatCard
label="Configure"
value="Edit"
type="config"
subtitle="Manage settings"
to="/configure"
/>
</el-col>
</el-row>
<el-table <el-row :gutter="20" class="content-row">
v-loading="loading" <el-col :xs="24" :lg="16">
:data="filteredStatus" <el-card class="proxy-list-card" shadow="hover">
:default-sort="{ prop: 'name', order: 'ascending' }" <template #header>
stripe <div class="card-header">
style="width: 100%" <div class="header-left">
class="proxy-table" <span class="card-title">Proxy Status</span>
> <el-tag size="small" type="info"
<el-table-column >{{ stats.total }} proxies</el-tag
prop="name" >
label="Name" </div>
sortable <div class="header-actions">
min-width="120" <el-input
></el-table-column> v-model="searchText"
<el-table-column placeholder="Search..."
prop="type" :prefix-icon="Search"
label="Type" clearable
width="100" class="search-input"
sortable />
> <el-tooltip content="Refresh" placement="top">
<template #default="scope"> <el-button :icon="Refresh" circle @click="fetchData" />
<span class="type-text">{{ scope.row.type }}</span> </el-tooltip>
</div>
</div>
</template> </template>
</el-table-column>
<el-table-column <div v-loading="loading" class="proxy-list-content">
prop="local_addr" <div v-if="filteredStatus.length > 0" class="proxy-list">
label="Local Address" <ProxyCard
min-width="150" v-for="proxy in filteredStatus"
sortable :key="proxy.name"
show-overflow-tooltip :proxy="proxy"
></el-table-column> />
<el-table-column </div>
prop="plugin" <div v-else-if="!loading" class="empty-state">
label="Plugin" <el-empty description="No proxies found" />
width="120" </div>
sortable </div>
show-overflow-tooltip </el-card>
></el-table-column> </el-col>
<el-table-column
prop="remote_addr" <el-col :xs="24" :lg="8">
label="Remote Address" <el-card class="types-card" shadow="hover">
min-width="150" <template #header>
sortable <div class="card-header">
show-overflow-tooltip <span class="card-title">Proxy Types</span>
></el-table-column> <el-tag size="small" type="info">Distribution</el-tag>
<el-table-column </div>
prop="status" </template>
label="Status" <div class="proxy-types-grid">
width="120" <div
sortable v-for="(count, type) in proxyTypeCounts"
align="center" :key="type"
> class="proxy-type-item"
<template #default="scope"> v-show="count > 0"
<el-tag
:type="getStatusColor(scope.row.status)"
effect="light"
round
> >
{{ scope.row.status }} <div class="proxy-type-name">
</el-tag> {{ String(type).toUpperCase() }}
</div>
<div class="proxy-type-count">{{ count }}</div>
</div>
<div v-if="!hasActiveProxies" class="no-data">No proxy data</div>
</div>
</el-card>
<el-card class="status-summary-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">Status Summary</span>
</div>
</template> </template>
</el-table-column> <div class="status-list">
<el-table-column prop="err" label="Info" min-width="150" show-overflow-tooltip> <div class="status-item">
<template #default="scope"> <div class="status-indicator running"></div>
<span v-if="scope.row.err" class="error-text">{{ scope.row.err }}</span> <span class="status-name">Running</span>
<span v-else>-</span> <span class="status-count">{{ stats.running }}</span>
</template> </div>
</el-table-column> <div class="status-item">
</el-table> <div class="status-indicator waiting"></div>
</el-card> <span class="status-name">Waiting</span>
<span class="status-count">{{ stats.waiting }}</span>
</div>
<div class="status-item">
<div class="status-indicator error"></div>
<span class="status-name">Error</span>
<span class="status-count">{{ stats.error }}</span>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div> </div>
</template> </template>
@ -96,11 +136,33 @@ import { ElMessage } from 'element-plus'
import { Search, Refresh } from '@element-plus/icons-vue' import { Search, Refresh } from '@element-plus/icons-vue'
import { getStatus } from '../api/frpc' import { getStatus } from '../api/frpc'
import type { ProxyStatus } from '../types/proxy' import type { ProxyStatus } from '../types/proxy'
import StatCard from '../components/StatCard.vue'
import ProxyCard from '../components/ProxyCard.vue'
const status = ref<ProxyStatus[]>([]) const status = ref<ProxyStatus[]>([])
const loading = ref(false) const loading = ref(false)
const searchText = ref('') const searchText = ref('')
const stats = computed(() => {
const total = status.value.length
const running = status.value.filter((p) => p.status === 'running').length
const error = status.value.filter((p) => p.status === 'error').length
const waiting = total - running - error
return { total, running, error, waiting }
})
const proxyTypeCounts = computed(() => {
const counts: Record<string, number> = {}
status.value.forEach((p) => {
counts[p.type] = (counts[p.type] || 0) + 1
})
return counts
})
const hasActiveProxies = computed(() => {
return status.value.length > 0
})
const filteredStatus = computed(() => { const filteredStatus = computed(() => {
if (!searchText.value) { if (!searchText.value) {
return status.value return status.value
@ -111,28 +173,16 @@ const filteredStatus = computed(() => {
p.name.toLowerCase().includes(search) || p.name.toLowerCase().includes(search) ||
p.type.toLowerCase().includes(search) || p.type.toLowerCase().includes(search) ||
p.local_addr.toLowerCase().includes(search) || p.local_addr.toLowerCase().includes(search) ||
p.remote_addr.toLowerCase().includes(search) p.remote_addr.toLowerCase().includes(search),
) )
}) })
const getStatusColor = (status: string) => {
switch (status) {
case 'running':
return 'success'
case 'error':
return 'danger'
default:
return 'warning'
}
}
const fetchData = async () => { const fetchData = async () => {
loading.value = true loading.value = true
try { try {
const json = await getStatus() const json = await getStatus()
status.value = [] status.value = []
for (const key in json) { for (const key in json) {
// json[key] is generic array, we assume it matches ProxyStatus
for (const ps of json[key]) { for (const ps of json[key]) {
status.value.push(ps) status.value.push(ps)
} }
@ -153,63 +203,240 @@ fetchData()
<style scoped> <style scoped>
.overview-page { .overview-page {
/* No special padding needed if App.vue handles content padding */ display: flex;
flex-direction: column;
gap: 20px;
} }
.main-card { .stats-row {
margin-bottom: 0;
}
.stats-row .el-col {
margin-bottom: 20px;
}
.content-row .el-col {
margin-bottom: 20px;
}
.proxy-list-card,
.types-card,
.status-summary-card {
border-radius: 12px; border-radius: 12px;
border: none; border: 1px solid #e4e7ed;
} }
.card-title { html.dark .proxy-list-card,
margin: 0; html.dark .types-card,
font-size: 18px; html.dark .status-summary-card {
font-weight: 600; border-color: #3a3d5c;
background: #27293d;
} }
.toolbar-header { .status-summary-card {
margin-top: 20px;
}
.card-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-bottom: 20px;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
padding-bottom: 16px;
} }
.toolbar-actions { .header-left {
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
gap: 12px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.card-title {
font-size: 16px;
font-weight: 500;
color: #303133;
}
html.dark .card-title {
color: #e5e7eb;
} }
.search-input { .search-input {
width: 240px; width: 200px;
} }
.error-text { .proxy-list-content {
color: var(--el-color-danger); min-height: 200px;
} }
.type-text { .proxy-list {
display: inline-block; display: flex;
padding: 2px 8px; flex-direction: column;
font-size: 12px; gap: 12px;
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Monaco, Consolas, monospace; }
background: var(--el-fill-color-light);
border-radius: 4px; .empty-state {
padding: 40px 0;
}
/* Proxy Types Grid */
.proxy-types-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 12px;
min-height: 80px;
align-content: center;
}
.proxy-type-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 8px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.proxy-type-item:hover {
background: #f0f2f5;
transform: translateY(-2px);
}
html.dark .proxy-type-item {
background: #1e1e2d;
}
html.dark .proxy-type-item:hover {
background: #2a2a3c;
}
.proxy-type-name {
font-size: 11px;
color: #909399;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: 0.5px;
}
.proxy-type-count {
font-size: 20px;
font-weight: 500;
color: #303133;
}
html.dark .proxy-type-count {
color: #e5e7eb;
}
.no-data {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 80px;
color: #909399;
font-size: 14px;
}
/* Status Summary */
.status-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.status-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #f8f9fa;
border-radius: 8px;
transition: all 0.2s;
}
.status-item:hover {
background: #f0f2f5;
}
html.dark .status-item {
background: #1e1e2d;
}
html.dark .status-item:hover {
background: #2a2a3c;
}
.status-indicator {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-indicator.running {
background: var(--el-color-success);
box-shadow: 0 0 0 3px var(--el-color-success-light-8);
}
.status-indicator.waiting {
background: var(--el-color-warning);
box-shadow: 0 0 0 3px var(--el-color-warning-light-8);
}
.status-indicator.error {
background: var(--el-color-danger);
box-shadow: 0 0 0 3px var(--el-color-danger-light-8);
}
.status-name {
flex: 1;
font-size: 14px;
color: var(--el-text-color-regular); color: var(--el-text-color-regular);
font-weight: 500;
}
.status-count {
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.toolbar-header { .card-header {
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
.header-left {
justify-content: space-between;
}
.header-actions {
justify-content: space-between;
}
.search-input { .search-input {
width: 100%; flex: 1;
width: auto;
}
.proxy-types-grid {
grid-template-columns: repeat(3, 1fr);
} }
} }
</style>
@media (max-width: 992px) {
.status-summary-card {
margin-top: 0;
}
}
</style>

View File

@ -39,7 +39,9 @@ export default defineConfig({
}, },
}, },
server: { server: {
allowedHosts: process.env.ALLOWED_HOSTS ? process.env.ALLOWED_HOSTS.split(',') : [], allowedHosts: process.env.ALLOWED_HOSTS
? process.env.ALLOWED_HOSTS.split(',')
: [],
proxy: { proxy: {
'/api': { '/api': {
target: process.env.VITE_API_URL || 'http://127.0.0.1:7400', target: process.env.VITE_API_URL || 'http://127.0.0.1:7400',
@ -47,4 +49,4 @@ export default defineConfig({
}, },
}, },
}, },
}) })

View File

@ -11,13 +11,7 @@ declare module 'vue' {
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard'] ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol'] ElCol: typeof import('element-plus/es')['ElCol']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDivider: typeof import('element-plus/es')['ElDivider']
ElEmpty: typeof import('element-plus/es')['ElEmpty'] ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon'] ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput'] ElInput: typeof import('element-plus/es')['ElInput']
ElOption: typeof import('element-plus/es')['ElOption'] ElOption: typeof import('element-plus/es')['ElOption']
@ -26,7 +20,6 @@ declare module 'vue' {
ElSelect: typeof import('element-plus/es')['ElSelect'] ElSelect: typeof import('element-plus/es')['ElSelect']
ElSwitch: typeof import('element-plus/es')['ElSwitch'] ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTag: typeof import('element-plus/es')['ElTag'] ElTag: typeof import('element-plus/es')['ElTag']
ElText: typeof import('element-plus/es')['ElText']
ElTooltip: typeof import('element-plus/es')['ElTooltip'] ElTooltip: typeof import('element-plus/es')['ElTooltip']
ProxyCard: typeof import('./src/components/ProxyCard.vue')['default'] ProxyCard: typeof import('./src/components/ProxyCard.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View File

@ -9,6 +9,7 @@
</div> </div>
<span class="divider">/</span> <span class="divider">/</span>
<span class="brand-name">frp</span> <span class="brand-name">frp</span>
<span class="badge server-badge">Server</span>
<span class="badge" v-if="currentRouteName">{{ <span class="badge" v-if="currentRouteName">{{
currentRouteName currentRouteName
}}</span> }}</span>
@ -170,6 +171,17 @@ body {
border: 1px solid var(--header-border); border: 1px solid var(--header-border);
} }
.badge.server-badge {
background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);
color: white;
border: none;
font-weight: 500;
}
html.dark .badge.server-badge {
background: linear-gradient(135deg, #60a5fa 0%, #22d3ee 100%);
}
.header-controls { .header-controls {
display: flex; display: flex;
align-items: center; align-items: center;