Merge branch 'dev' of github.com:engigu/baihu into dev
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
import { resetAuthCache } from '@/router'
|
||||
import { LayoutDashboard, ListTodo, FileCode, Settings, LogOut, ScrollText, Terminal, Variable, KeyRound, Package, Menu, X, Server } from 'lucide-vue-next'
|
||||
@@ -17,7 +17,7 @@ function loadSentenceFromCache(): string | null {
|
||||
try {
|
||||
const cached = localStorage.getItem(SENTENCE_CACHE_KEY)
|
||||
const cacheTime = localStorage.getItem(SENTENCE_CACHE_TIME_KEY)
|
||||
|
||||
|
||||
if (cached && cacheTime) {
|
||||
const age = Date.now() - parseInt(cacheTime)
|
||||
// 如果缓存未过期,使用缓存
|
||||
@@ -46,6 +46,10 @@ const cachedSentence = loadSentenceFromCache()
|
||||
const sentence = ref(cachedSentence || '欢迎使用白虎面板')
|
||||
const { siteSettings, loadSettings } = useSiteSettings()
|
||||
const mobileMenuOpen = ref(false)
|
||||
const sentenceContent = computed(() => {
|
||||
const match = sentence.value.match(/^"(.+)"—— /)
|
||||
return match ? match[1] : sentence.value
|
||||
})
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, label: '数据仪表', exact: true },
|
||||
@@ -101,19 +105,13 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="flex h-screen bg-muted/40">
|
||||
<!-- Mobile Menu Overlay -->
|
||||
<div
|
||||
v-if="mobileMenuOpen"
|
||||
class="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
@click="mobileMenuOpen = false"
|
||||
/>
|
||||
<div v-if="mobileMenuOpen" class="fixed inset-0 bg-black/50 z-40 lg:hidden" @click="mobileMenuOpen = false" />
|
||||
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
:class="[
|
||||
'fixed lg:static inset-y-0 left-0 z-50 w-44 border-r bg-background flex flex-col transform transition-transform duration-200 ease-in-out lg:transform-none',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
]"
|
||||
>
|
||||
<aside :class="[
|
||||
'fixed lg:static inset-y-0 left-0 z-50 w-44 border-r bg-background flex flex-col transform transition-transform duration-200 ease-in-out lg:transform-none',
|
||||
mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'
|
||||
]">
|
||||
<div class="h-14 flex items-center justify-center px-4 font-semibold text-lg border-b relative">
|
||||
<span>{{ siteSettings.title }}</span>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 lg:hidden absolute right-2" @click="mobileMenuOpen = false">
|
||||
@@ -121,25 +119,18 @@ onMounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
<nav class="flex-1 px-3 py-6 space-y-1 flex flex-col items-center">
|
||||
<RouterLink
|
||||
v-for="item in navItems"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
custom
|
||||
v-slot="{ navigate }"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to" custom v-slot="{ navigate }">
|
||||
<Button variant="ghost"
|
||||
:class="['justify-start gap-3 h-9 px-3', isItemActive(item) && 'bg-accent text-accent-foreground']"
|
||||
@click="handleNavClick(navigate)"
|
||||
>
|
||||
@click="handleNavClick(navigate)">
|
||||
<component :is="item.icon" class="h-4 w-4" />
|
||||
{{ item.label }}
|
||||
</Button>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
<div class="px-3 py-4 border-t flex justify-center">
|
||||
<Button variant="ghost" class="justify-start gap-3 h-9 px-3 text-muted-foreground hover:text-foreground" @click="logout">
|
||||
<Button variant="ghost" class="justify-start gap-3 h-9 px-3 text-muted-foreground hover:text-foreground"
|
||||
@click="logout">
|
||||
<LogOut class="h-4 w-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
@@ -149,11 +140,14 @@ onMounted(() => {
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-auto w-full">
|
||||
<div class="h-14 border-b bg-background flex items-center justify-between px-4 lg:px-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 lg:hidden" @click="mobileMenuOpen = true">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 lg:hidden shrink-0" @click="mobileMenuOpen = true">
|
||||
<Menu class="h-5 w-5" />
|
||||
</Button>
|
||||
<span class="text-sm text-muted-foreground truncate max-w-[200px] sm:max-w-none">{{ sentence }}</span>
|
||||
<span class="text-sm text-muted-foreground truncate" :title="sentence">
|
||||
<span class="hidden sm:inline">{{ sentence }}</span>
|
||||
<span class="sm:hidden">{{ sentenceContent }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
@@ -37,23 +37,23 @@ function animateNumber(key: keyof Stats, target: number) {
|
||||
const start = displayStats.value[key]
|
||||
const duration = 800 // 动画持续时间
|
||||
const startTime = performance.now()
|
||||
|
||||
|
||||
function update(currentTime: number) {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
|
||||
|
||||
// 使用 easeOutCubic 缓动函数
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3)
|
||||
|
||||
|
||||
displayStats.value[key] = Math.round(start + (target - start) * easeProgress)
|
||||
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update)
|
||||
} else {
|
||||
displayStats.value[key] = target
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
requestAnimationFrame(update)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function getGridColor() {
|
||||
function handleThemeChange() {
|
||||
// 初始化期间忽略主题变化
|
||||
if (isInitializing) return
|
||||
|
||||
|
||||
const newIsDark = document.documentElement.classList.contains('dark')
|
||||
if (newIsDark !== isDark.value) {
|
||||
isDark.value = newIsDark
|
||||
@@ -127,11 +127,11 @@ async function reloadCharts() {
|
||||
// 等待 Vue 更新 DOM
|
||||
await nextTick()
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
|
||||
// 检查容器是否存在再渲染
|
||||
const statsChart = document.querySelector("#stats-chart")
|
||||
const pieChartEl = document.querySelector("#pie-chart")
|
||||
|
||||
|
||||
if (statsChart && pieChartEl && statsChart.parentElement && pieChartEl.parentElement) {
|
||||
renderLineChart()
|
||||
renderPieChart()
|
||||
@@ -143,15 +143,15 @@ async function reloadCharts() {
|
||||
const renderLineChart = () => {
|
||||
const container = document.querySelector("#stats-chart")
|
||||
if (!container || !container.parentElement) return
|
||||
|
||||
|
||||
if (lineChart) {
|
||||
lineChart.destroy()
|
||||
lineChart = null
|
||||
}
|
||||
|
||||
|
||||
// 清空容器
|
||||
container.innerHTML = ''
|
||||
|
||||
|
||||
const textColor = getTextColor()
|
||||
const gridColor = getGridColor()
|
||||
const options = {
|
||||
@@ -282,18 +282,18 @@ const renderLineChart = () => {
|
||||
|
||||
const renderPieChart = () => {
|
||||
if (taskStats.value.length === 0) return
|
||||
|
||||
|
||||
const container = document.querySelector("#pie-chart")
|
||||
if (!container || !container.parentElement) return
|
||||
|
||||
|
||||
if (pieChart) {
|
||||
pieChart.destroy()
|
||||
pieChart = null
|
||||
}
|
||||
|
||||
|
||||
// 清空容器
|
||||
container.innerHTML = ''
|
||||
|
||||
|
||||
const totalCount = taskStats.value.reduce((sum, item) => sum + item.count, 0)
|
||||
const textColor = getTextColor()
|
||||
const options = {
|
||||
@@ -366,13 +366,13 @@ const renderPieChart = () => {
|
||||
dataLabels: {
|
||||
enabled: true,
|
||||
formatter: (val: number) => val.toFixed(1) + '%',
|
||||
style: {
|
||||
fontSize: '11px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
style: {
|
||||
fontSize: '11px',
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
colors: ['#ffffff']
|
||||
},
|
||||
dropShadow: {
|
||||
dropShadow: {
|
||||
enabled: true,
|
||||
top: 1,
|
||||
left: 1,
|
||||
@@ -395,7 +395,7 @@ const renderPieChart = () => {
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
|
||||
try {
|
||||
const [statsData, sendStatsData, taskStatsData] = await Promise.all([
|
||||
api.dashboard.stats(),
|
||||
@@ -405,13 +405,13 @@ onMounted(async () => {
|
||||
updateStats(statsData)
|
||||
sendStats.value = sendStatsData
|
||||
taskStats.value = taskStatsData
|
||||
|
||||
|
||||
// 渲染图表
|
||||
setTimeout(() => {
|
||||
renderLineChart()
|
||||
renderPieChart()
|
||||
chartsLoaded.value = true
|
||||
|
||||
|
||||
// 图表渲染完成后,延迟启动主题监听,避免初始化时的闪烁
|
||||
setTimeout(() => {
|
||||
isInitializing = false
|
||||
@@ -422,7 +422,7 @@ onMounted(async () => {
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
}, 500)
|
||||
}, 100)
|
||||
} catch {}
|
||||
} catch { }
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -448,7 +448,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 grid-cols-2 sm:grid-cols-3 lg:grid-cols-6">
|
||||
<Card v-for="item in statItems" :key="item.key" class="cursor-pointer hover:bg-accent/50 transition-colors" @click="navigateTo(item.route)">
|
||||
<Card v-for="item in statItems" :key="item.key" class="cursor-pointer hover:bg-accent/50 transition-colors"
|
||||
@click="navigateTo(item.route)">
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-xs sm:text-sm font-medium">{{ item.label }}</CardTitle>
|
||||
<component :is="item.icon" class="h-4 w-4 text-muted-foreground" />
|
||||
@@ -467,7 +468,8 @@ onUnmounted(() => {
|
||||
</CardHeader>
|
||||
<CardContent class="relative h-[300px]">
|
||||
<div id="stats-chart" class="w-full h-full"></div>
|
||||
<div v-if="!chartsLoaded" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||
<div v-if="!chartsLoaded"
|
||||
class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||
加载中...
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -480,10 +482,12 @@ onUnmounted(() => {
|
||||
</CardHeader>
|
||||
<CardContent class="relative h-[300px]">
|
||||
<div id="pie-chart" class="w-full h-full"></div>
|
||||
<div v-if="!chartsLoaded" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||
<div v-if="!chartsLoaded"
|
||||
class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||
加载中...
|
||||
</div>
|
||||
<div v-else-if="taskStats.length === 0" class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||
<div v-else-if="taskStats.length === 0"
|
||||
class="absolute inset-0 flex items-center justify-center text-muted-foreground text-sm bg-card">
|
||||
暂无数据
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import DirTreeSelect from '@/components/DirTreeSelect.vue'
|
||||
import { Plus, ChevronDown, X } from 'lucide-vue-next'
|
||||
import { api, type Task, type EnvVar, type Agent } from '@/api'
|
||||
@@ -132,8 +131,7 @@ function addEnv(id: number) {
|
||||
}
|
||||
|
||||
function removeEnv(id: number) {
|
||||
const idx = selectedEnvIds.value.indexOf(id)
|
||||
if (idx !== -1) selectedEnvIds.value.splice(idx, 1)
|
||||
selectedEnvIds.value = selectedEnvIds.value.filter(envId => envId !== id)
|
||||
}
|
||||
|
||||
async function save() {
|
||||
@@ -266,10 +264,10 @@ async function save() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="selectedEnvs.length > 0" class="flex flex-wrap gap-1">
|
||||
<Badge v-for="env in selectedEnvs" :key="env.id" variant="secondary" class="gap-0.5 pr-0.5 text-xs h-5">
|
||||
{{ env.name }}
|
||||
<X class="h-2.5 w-2.5 cursor-pointer hover:text-destructive" @click.stop="removeEnv(env.id)" />
|
||||
</Badge>
|
||||
<div v-for="env in selectedEnvs" :key="env.id" class="inline-flex items-center gap-0.5 px-2 py-0.5 text-xs h-5 rounded-full bg-secondary text-secondary-foreground">
|
||||
<span>{{ env.name }}</span>
|
||||
<X class="h-2.5 w-2.5 cursor-pointer hover:text-destructive" @click="removeEnv(env.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user