feat: UI美化

This commit is contained in:
tbphp
2025-07-03 23:23:02 +08:00
parent e06038ddb9
commit 5b0fcc5739
14 changed files with 1894 additions and 123 deletions

View File

@@ -1,5 +1,232 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
// 模拟数据
const stats = ref([
{
title: "总请求数",
value: "125,842",
icon: "📈",
color: "var(--primary-gradient)",
trend: "+12.5%",
trendUp: true,
},
{
title: "活跃连接",
value: "1,234",
icon: "🔗",
color: "var(--success-gradient)",
trend: "+5.2%",
trendUp: true,
},
{
title: "响应时间",
value: "245ms",
icon: "⚡",
color: "var(--warning-gradient)",
trend: "-8.1%",
trendUp: false,
},
{
title: "错误率",
value: "0.12%",
icon: "🛡️",
color: "var(--secondary-gradient)",
trend: "-2.3%",
trendUp: false,
},
]);
const animatedValues = ref<Record<string, number>>({});
onMounted(() => {
// 动画效果
stats.value.forEach((stat, index) => {
setTimeout(() => {
animatedValues.value[stat.title] = 1;
}, index * 150);
});
});
</script>
<template>
<n-card title="基础统计">
<div>基础统计</div>
</n-card>
<div class="stats-container">
<div class="stats-grid">
<div
v-for="(stat, index) in stats"
:key="stat.title"
class="stat-card modern-card"
:style="{ animationDelay: `${index * 0.1}s` }"
>
<div class="stat-header">
<div class="stat-icon" :style="{ background: stat.color }">
{{ stat.icon }}
</div>
<div
class="stat-trend"
:class="{ 'trend-up': stat.trendUp, 'trend-down': !stat.trendUp }"
>
{{ stat.trend }}
</div>
</div>
<div class="stat-content">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-title">{{ stat.title }}</div>
</div>
<div class="stat-bar">
<div
class="stat-bar-fill"
:style="{
background: stat.color,
width: `${animatedValues[stat.title] * 100}%`,
}"
/>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.stats-container {
width: 100%;
animation: fadeInUp 0.6s ease-out;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.stat-card {
padding: 24px;
background: rgba(255, 255, 255, 0.98);
border-radius: var(--border-radius-lg);
border: 1px solid rgba(255, 255, 255, 0.3);
position: relative;
overflow: hidden;
animation: slideInUp 0.6s ease-out both;
}
.stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: var(--border-radius-md);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
box-shadow: var(--shadow-md);
}
.stat-trend {
font-size: 0.875rem;
font-weight: 600;
padding: 4px 8px;
border-radius: 6px;
display: flex;
align-items: center;
}
.trend-up {
background: rgba(34, 197, 94, 0.1);
color: #16a34a;
}
.trend-down {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.trend-up::before {
content: "↗";
margin-right: 4px;
}
.trend-down::before {
content: "↘";
margin-right: 4px;
}
.stat-content {
margin-bottom: 16px;
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
line-height: 1.2;
color: #1e293b;
margin-bottom: 4px;
}
.stat-title {
font-size: 0.95rem;
color: #64748b;
font-weight: 500;
}
.stat-bar {
width: 100%;
height: 4px;
background: rgba(0, 0, 0, 0.05);
border-radius: 2px;
overflow: hidden;
position: relative;
}
.stat-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 1s ease-out;
transition-delay: 0.3s;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.stats-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.stat-card {
padding: 20px;
}
.stat-value {
font-size: 2rem;
}
}
</style>

View File

@@ -1,14 +1,44 @@
<script setup lang="ts">
import { appState } from "@/utils/app-state";
import {
NConfigProvider,
NDialogProvider,
NLoadingBarProvider,
NMessageProvider,
useLoadingBar,
useMessage,
type GlobalThemeOverrides,
} from "naive-ui";
import { defineComponent, watch } from "vue";
// 自定义主题配置
const themeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: "#667eea",
primaryColorHover: "#5a6fd8",
primaryColorPressed: "#4c63d2",
primaryColorSuppl: "#8b9df5",
borderRadius: "12px",
borderRadiusSmall: "8px",
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
},
Card: {
paddingMedium: "24px",
},
Button: {
fontWeight: "600",
heightMedium: "40px",
heightLarge: "48px",
},
Input: {
heightMedium: "40px",
heightLarge: "48px",
},
Menu: {
itemHeight: "42px",
},
};
function useGlobalMessage() {
window.$message = useMessage();
}
@@ -39,13 +69,15 @@ const Message = defineComponent({
</script>
<template>
<n-loading-bar-provider>
<n-message-provider>
<n-dialog-provider>
<slot />
<loading-bar />
<message />
</n-dialog-provider>
</n-message-provider>
</n-loading-bar-provider>
<n-config-provider :theme-overrides="themeOverrides">
<n-loading-bar-provider>
<n-message-provider>
<n-dialog-provider>
<slot />
<loading-bar />
<message />
</n-dialog-provider>
</n-message-provider>
</n-loading-bar-provider>
</n-config-provider>
</template>

View File

@@ -4,25 +4,176 @@ import NavBar from "@/components/NavBar.vue";
</script>
<template>
<n-layout>
<n-layout-header class="flex items-center">
<h1 class="layout-header-title">T.COM</h1>
<nav-bar />
<logout />
<n-layout class="main-layout">
<n-layout-header class="layout-header">
<div class="header-content">
<div class="header-brand">
<div class="brand-icon">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none">
<path
d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<h1 class="brand-title">GPT Load</h1>
</div>
<nav-bar class="header-nav" />
<div class="header-actions">
<logout />
</div>
</div>
</n-layout-header>
<n-layout-content class="layout-content" content-style="padding: 24px;">
<router-view />
<n-layout-content class="layout-content">
<div class="content-wrapper">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</n-layout-content>
</n-layout>
</template>
<style scoped>
.layout-header-title {
margin-top: 0;
margin-bottom: 8px;
margin-right: 20px;
.main-layout {
/* height: 100vh; */
background: transparent;
}
.layout-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
box-shadow: var(--shadow-sm);
position: sticky;
top: 0;
z-index: 100;
padding: 0 24px;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
max-width: 1400px;
margin: 0 auto;
height: 100%;
padding: 0 8px;
}
.header-brand {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.brand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--primary-gradient);
border-radius: var(--border-radius-md);
color: white;
box-shadow: var(--shadow-md);
}
.brand-title {
font-size: 1.4rem;
font-weight: 700;
background: var(--primary-gradient);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
letter-spacing: -0.3px;
}
.header-nav {
flex: 1;
display: flex;
justify-content: center;
max-width: 600px;
}
.header-actions {
flex-shrink: 0;
}
.layout-content {
width: 100%;
flex: 1;
overflow: auto;
background: transparent;
}
.content-wrapper {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
min-height: calc(100vh - 64px);
}
/* 响应式设计 */
@media (max-width: 768px) {
.header-content {
padding: 0 4px;
}
.brand-title {
font-size: 1.2rem;
}
.content-wrapper {
padding: 16px;
}
.header-nav {
display: none;
}
}
@media (max-width: 480px) {
.brand-title {
display: none;
}
.content-wrapper {
padding: 12px;
}
}
/* 过渡动画 */
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateY(10px);
}
.fade-leave-to {
opacity: 0;
transform: translateY(-10px);
}
:deep(.n-layout-header) {
height: 64px;
padding: 0;
}
:deep(.n-layout-content) {
padding: 0;
}
</style>

View File

@@ -1,5 +1,265 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
// 模拟图表数据
const chartData = ref({
labels: ["00:00", "04:00", "08:00", "12:00", "16:00", "20:00", "24:00"],
datasets: [
{
label: "请求数量",
data: [120, 150, 300, 450, 380, 280, 200],
color: "#667eea",
},
{
label: "响应时间",
data: [200, 180, 250, 300, 220, 190, 160],
color: "#f093fb",
},
],
});
const chartContainer = ref<HTMLElement>();
const animationProgress = ref(0);
// 生成SVG路径
const generatePath = (data: number[]) => {
const points = data.map((value, index) => {
const x = (index / (data.length - 1)) * 380 + 10;
const y = 200 - (value / 500) * 180 - 10;
return `${x},${y}`;
});
return `M ${points.join(" L ")}`;
};
onMounted(() => {
// 简单的动画效果
let start = 0;
const animate = (timestamp: number) => {
if (!start) {
start = timestamp;
}
const progress = Math.min((timestamp - start) / 2000, 1);
animationProgress.value = progress;
if (progress < 1) {
requestAnimationFrame(animate);
}
};
requestAnimationFrame(animate);
});
</script>
<template>
<n-card title="拆线图">
<div>拆线图</div>
</n-card>
<div class="chart-container">
<n-card class="chart-card modern-card" :bordered="false">
<template #header>
<div class="chart-header">
<h3 class="chart-title">性能监控</h3>
<p class="chart-subtitle">实时系统性能指标</p>
</div>
</template>
<div ref="chartContainer" class="chart-content">
<div class="chart-legend">
<div v-for="dataset in chartData.datasets" :key="dataset.label" class="legend-item">
<div class="legend-color" :style="{ backgroundColor: dataset.color }" />
<span class="legend-label">{{ dataset.label }}</span>
</div>
</div>
<div class="chart-area">
<div class="chart-grid">
<div
v-for="(label, index) in chartData.labels"
:key="label"
class="grid-line"
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
/>
</div>
<svg class="chart-svg" viewBox="0 0 400 200">
<!-- 数据线条 -->
<g v-for="dataset in chartData.datasets" :key="dataset.label">
<path
:d="generatePath(dataset.data)"
:stroke="dataset.color"
stroke-width="3"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
class="chart-line"
:style="{
strokeDasharray: '1000',
strokeDashoffset: `${1000 * (1 - animationProgress)}`,
}"
/>
<!-- 数据点 -->
<g v-for="(value, index) in dataset.data" :key="index">
<circle
:cx="(index / (dataset.data.length - 1)) * 380 + 10"
:cy="200 - (value / 500) * 180 - 10"
:r="animationProgress > index / dataset.data.length ? 4 : 0"
:fill="dataset.color"
class="chart-point"
/>
</g>
</g>
</svg>
<div class="chart-labels">
<div
v-for="(label, index) in chartData.labels"
:key="label"
class="chart-label"
:style="{ left: `${(index / (chartData.labels.length - 1)) * 100}%` }"
>
{{ label }}
</div>
</div>
</div>
</div>
</n-card>
</div>
</template>
<style scoped>
.chart-container {
width: 100%;
}
.chart-card {
background: rgba(255, 255, 255, 0.98);
}
.chart-header {
text-align: center;
margin-bottom: 8px;
}
.chart-title {
font-size: 1.3rem;
font-weight: 600;
color: #1e293b;
margin: 0 0 4px 0;
}
.chart-subtitle {
font-size: 0.9rem;
color: #64748b;
margin: 0;
}
.chart-content {
position: relative;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
box-shadow: var(--shadow-sm);
}
.legend-label {
font-size: 0.9rem;
color: #374151;
font-weight: 500;
}
.chart-area {
position: relative;
height: 240px;
background: linear-gradient(180deg, rgba(102, 126, 234, 0.02) 0%, rgba(102, 126, 234, 0.08) 100%);
border-radius: var(--border-radius-md);
border: 1px solid rgba(102, 126, 234, 0.1);
overflow: hidden;
}
.chart-grid {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
.grid-line {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: rgba(102, 126, 234, 0.1);
}
.chart-svg {
width: 100%;
height: 200px;
position: relative;
z-index: 1;
}
.chart-line {
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
transition: stroke-dashoffset 2s ease-out;
}
.chart-point {
transition: r 0.3s ease;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
}
.chart-point:hover {
r: 6;
}
.chart-labels {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 40px;
display: flex;
align-items: center;
}
.chart-label {
position: absolute;
font-size: 0.8rem;
color: #64748b;
font-weight: 500;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.9);
padding: 4px 8px;
border-radius: 4px;
backdrop-filter: blur(4px);
}
@media (max-width: 768px) {
.chart-legend {
gap: 16px;
}
.chart-area {
height: 200px;
}
.chart-svg {
height: 160px;
}
}
</style>

View File

@@ -12,5 +12,38 @@ const handleLogout = () => {
</script>
<template>
<n-button quaternary round @click="handleLogout">退出</n-button>
<n-button quaternary round class="logout-button" @click="handleLogout">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path
d="M17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.59L17 17l5-5zM4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4V5z"
/>
</svg>
</template>
退出登录
</n-button>
</template>
<style scoped>
.logout-button {
color: #64748b;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(8px);
border: 1px solid rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
font-weight: 500;
letter-spacing: 0.2px;
}
.logout-button:hover {
color: #dc2626;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.2);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
:deep(.n-button__content) {
gap: 6px;
}
</style>

View File

@@ -4,16 +4,16 @@ import { computed, h } from "vue";
import { RouterLink, useRoute } from "vue-router";
const menuOptions: MenuOption[] = [
renderMenuItem("dashboard", "仪表盘"),
renderMenuItem("keys", "密钥管理"),
renderMenuItem("logs", "日志"),
renderMenuItem("settings", "系统设置"),
renderMenuItem("dashboard", "仪表盘", "📊"),
renderMenuItem("keys", "密钥管理", "🔑"),
renderMenuItem("logs", "日志", "📋"),
renderMenuItem("settings", "系统设置", "⚙️"),
];
const route = useRoute();
const activeMenu = computed(() => route.name);
function renderMenuItem(key: string, label: string): MenuOption {
function renderMenuItem(key: string, label: string, icon: string): MenuOption {
return {
label: () =>
h(
@@ -22,8 +22,14 @@ function renderMenuItem(key: string, label: string): MenuOption {
to: {
name: key,
},
class: "nav-menu-item",
},
{ default: () => label }
{
default: () => [
h("span", { class: "nav-item-icon" }, icon),
h("span", { class: "nav-item-text" }, label),
],
}
),
key,
};
@@ -31,5 +37,86 @@ function renderMenuItem(key: string, label: string): MenuOption {
</script>
<template>
<n-menu mode="horizontal" :options="menuOptions" :value="activeMenu" responsive />
<div class="navbar-container">
<n-menu mode="horizontal" :options="menuOptions" :value="activeMenu" class="modern-menu" />
</div>
</template>
<style scoped>
.navbar-container {
flex: 1;
display: flex;
justify-content: center;
max-width: 600px;
}
.nav-menu-item {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: inherit;
padding: 8px 16px;
border-radius: var(--border-radius-md);
transition: all 0.3s ease;
font-weight: 500;
}
.nav-menu-item:hover {
background: rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.nav-item-icon {
font-size: 1.1rem;
display: flex;
align-items: center;
}
.nav-item-text {
font-size: 0.95rem;
letter-spacing: 0.2px;
}
:deep(.n-menu) {
background: transparent;
border-bottom: none;
}
:deep(.n-menu-item) {
border-radius: var(--border-radius-md);
margin: 0 4px;
transition: all 0.3s ease;
}
:deep(.n-menu-item:hover) {
background: rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
:deep(.n-menu-item--selected) {
background: var(--primary-gradient);
color: white;
font-weight: 600;
box-shadow: var(--shadow-md);
}
:deep(.n-menu-item--selected:hover) {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
transform: translateY(-1px);
}
:deep(.n-menu-item-content) {
border-radius: var(--border-radius-md);
}
:deep(.n-menu-item-content-header) {
overflow: visible;
}
@media (max-width: 768px) {
.navbar-container {
display: none;
}
}
</style>