feat:移除了弹窗,服务器添加sls
This commit is contained in:
57
apps/telemetry-dashboard/components/AppState.vue
Normal file
57
apps/telemetry-dashboard/components/AppState.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { Ban, LogIn, RefreshCw, ServerOff } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Button from '~/components/ui/Button.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
kind: 'error' | 'unauthenticated' | 'forbidden'
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ retry: [] }>()
|
||||
|
||||
const content = computed(() => {
|
||||
if (props.kind === 'unauthenticated') {
|
||||
return {
|
||||
icon: LogIn,
|
||||
title: '需要登录',
|
||||
detail: '请使用 Axolotl-Launcher 组织成员账号通过 Cloudflare Access 登录。',
|
||||
action: '前往登录',
|
||||
}
|
||||
}
|
||||
if (props.kind === 'forbidden') {
|
||||
return {
|
||||
icon: Ban,
|
||||
title: '无权访问',
|
||||
detail: '当前 GitHub 身份不符合 Cloudflare Access 组织策略。',
|
||||
action: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
icon: ServerOff,
|
||||
title: '遥测数据暂不可用',
|
||||
detail: '只读管理 API 未返回可用结果,请稍后重试。',
|
||||
action: '重新加载',
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="compact ? 'py-10' : 'min-h-[60vh]'"
|
||||
class="flex flex-col items-center justify-center px-4 text-center"
|
||||
:data-state="kind"
|
||||
>
|
||||
<div class="flex size-10 items-center justify-center rounded-lg bg-muted">
|
||||
<component :is="content.icon" class="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 class="mt-4 text-base font-semibold">{{ content.title }}</h2>
|
||||
<p class="mt-1 max-w-md text-sm text-muted-foreground">{{ content.detail }}</p>
|
||||
<Button v-if="content.action" class="mt-4" variant="outline" @click="emit('retry')">
|
||||
<RefreshCw v-if="kind === 'error'" class="size-4" />
|
||||
<LogIn v-else class="size-4" />
|
||||
{{ content.action }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
50
apps/telemetry-dashboard/components/MetricCard.vue
Normal file
50
apps/telemetry-dashboard/components/MetricCard.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import Card from '~/components/ui/Card.vue'
|
||||
import { formatNumber } from '~/utils/format'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
value: number
|
||||
detail: string
|
||||
icon: Component
|
||||
tone?: 'green' | 'cyan' | 'gold' | 'coral' | 'neutral'
|
||||
}>(),
|
||||
{ tone: 'neutral' },
|
||||
)
|
||||
|
||||
const tones = {
|
||||
green: 'bg-emerald-500/12 text-emerald-700 dark:text-emerald-300',
|
||||
cyan: 'bg-cyan-500/12 text-cyan-700 dark:text-cyan-300',
|
||||
gold: 'bg-amber-500/12 text-amber-700 dark:text-amber-300',
|
||||
coral: 'bg-rose-500/12 text-rose-700 dark:text-rose-300',
|
||||
neutral: 'bg-surface-3 text-muted-foreground',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="min-w-0 overflow-hidden">
|
||||
<div class="flex items-start justify-between gap-3 p-4 pb-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-xs font-medium text-muted-foreground">{{ title }}</p>
|
||||
<p class="mt-2 text-[28px] font-semibold tabular-nums leading-8">
|
||||
{{ formatNumber(value) }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex size-9 shrink-0 items-center justify-center rounded-md"
|
||||
:class="tones[props.tone]"
|
||||
>
|
||||
<component :is="icon" class="size-[18px]" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
class="truncate border-t border-surface-4 bg-surface-3/45 px-4 py-2 text-[11px] text-muted-foreground"
|
||||
:title="detail"
|
||||
>
|
||||
{{ detail }}
|
||||
</p>
|
||||
</Card>
|
||||
</template>
|
||||
13
apps/telemetry-dashboard/components/PageHeader.vue
Normal file
13
apps/telemetry-dashboard/components/PageHeader.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ title: string; description: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="mb-6 flex flex-wrap items-end justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-[22px] font-semibold leading-8">{{ title }}</h1>
|
||||
<p class="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{{ description }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
</header>
|
||||
</template>
|
||||
@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { DistributionItemDto } from '~/shared/types/telemetry'
|
||||
import { formatNumber } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{ items: DistributionItemDto[]; color: string }>()
|
||||
const maximum = computed(() => Math.max(1, ...props.items.map((item) => item.value)))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="items.length" class="h-56 space-y-3.5 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.label"
|
||||
class="group"
|
||||
:title="`${item.label}: ${formatNumber(item.value)}`"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span class="truncate font-medium">{{ item.label }}</span>
|
||||
<span class="shrink-0 tabular-nums text-muted-foreground">{{
|
||||
formatNumber(item.value)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="h-1.5 overflow-hidden rounded-sm bg-surface-3">
|
||||
<div
|
||||
class="h-full rounded-sm transition-[width] duration-300"
|
||||
:style="{ width: `${(item.value / maximum) * 100}%`, backgroundColor: color }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
|
||||
data-state="empty"
|
||||
>
|
||||
当前 UTC 时间范围内暂无数据
|
||||
</div>
|
||||
</template>
|
||||
87
apps/telemetry-dashboard/components/charts/TrendChart.vue
Normal file
87
apps/telemetry-dashboard/components/charts/TrendChart.vue
Normal file
@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { VisAxis, VisLine, VisXYContainer } from '@unovis/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { DailyPointDto } from '~/shared/types/telemetry'
|
||||
import { formatNumber, formatUtcDay } from '~/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
data: DailyPointDto[]
|
||||
series: Array<{
|
||||
key: 'activeInstallations' | 'newInstallations'
|
||||
label: string
|
||||
color: string
|
||||
}>
|
||||
}>()
|
||||
|
||||
const hovered = ref<number | null>(null)
|
||||
const available = computed(() =>
|
||||
props.data.some((point) => props.series.some((series) => Number(point[series.key]) > 0)),
|
||||
)
|
||||
const yAccessors = computed(() =>
|
||||
props.series.map((series) => (point: DailyPointDto) => point[series.key]),
|
||||
)
|
||||
const colors = computed(() => props.series.map((series) => series.color))
|
||||
|
||||
function move(event: PointerEvent): void {
|
||||
if (!props.data.length) return
|
||||
const box = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const ratio = Math.min(1, Math.max(0, (event.clientX - box.left) / box.width))
|
||||
hovered.value = Math.min(props.data.length - 1, Math.round(ratio * (props.data.length - 1)))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-64 min-w-0" @pointermove="move" @pointerleave="hovered = null">
|
||||
<div v-if="available" class="h-56 w-full">
|
||||
<VisXYContainer :data="data" :margin="{ left: 16, right: 12, top: 12, bottom: 28 }">
|
||||
<VisLine
|
||||
:x="(_point: DailyPointDto, index: number) => index"
|
||||
:y="yAccessors"
|
||||
:color="colors"
|
||||
:line-width="2"
|
||||
/>
|
||||
<VisAxis
|
||||
type="x"
|
||||
:x="(_point: DailyPointDto, index: number) => index"
|
||||
:tick-format="(value: number) => formatUtcDay(data[Math.round(value)]?.day || '')"
|
||||
:num-ticks="4"
|
||||
/>
|
||||
<VisAxis type="y" :num-ticks="4" :tick-format="(value: number) => formatNumber(value)" />
|
||||
</VisXYContainer>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-56 items-center justify-center text-sm text-muted-foreground"
|
||||
data-state="empty"
|
||||
>
|
||||
当前 UTC 时间范围内暂无数据
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground"
|
||||
aria-label="图表图例"
|
||||
>
|
||||
<span v-for="item in series" :key="item.key" class="inline-flex items-center gap-1.5">
|
||||
<span class="size-2 rounded-sm" :style="{ backgroundColor: item.color }"></span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="hovered !== null && available"
|
||||
class="pointer-events-none absolute right-2 top-2 z-10 min-w-40 rounded-md border bg-popover p-2 text-xs text-popover-foreground shadow-md"
|
||||
role="tooltip"
|
||||
>
|
||||
<p class="mb-1 font-medium">{{ data[hovered].day }}(UTC)</p>
|
||||
<p
|
||||
v-for="item in series"
|
||||
:key="item.key"
|
||||
class="flex justify-between gap-4 py-0.5 text-muted-foreground"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<span class="font-medium tabular-nums text-foreground">{{
|
||||
formatNumber(data[hovered][item.key])
|
||||
}}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
apps/telemetry-dashboard/components/system/StatusRow.vue
Normal file
34
apps/telemetry-dashboard/components/system/StatusRow.vue
Normal file
@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { CircleCheck, CircleMinus, TriangleAlert } from 'lucide-vue-next'
|
||||
|
||||
import Badge from '~/components/ui/Badge.vue'
|
||||
import type { ServiceCheckDto } from '~/shared/types/telemetry'
|
||||
|
||||
defineProps<{ name: string; check: ServiceCheckDto }>()
|
||||
|
||||
const icons = { available: CircleCheck, degraded: TriangleAlert, unavailable: CircleMinus }
|
||||
const variants = { available: 'success', degraded: 'warning', unavailable: 'secondary' } as const
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 border-b border-surface-4 px-4 py-3.5 transition-colors last:border-0 hover:bg-surface-3/45"
|
||||
>
|
||||
<component
|
||||
:is="icons[check.status]"
|
||||
class="size-4 shrink-0"
|
||||
:class="
|
||||
check.status === 'available'
|
||||
? 'text-emerald-600'
|
||||
: check.status === 'degraded'
|
||||
? 'text-amber-600'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
/>
|
||||
<div class="min-w-48 flex-1">
|
||||
<p class="text-sm font-medium">{{ name }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ check.detail }}</p>
|
||||
</div>
|
||||
<Badge :variant="variants[check.status]">{{ check.label }}</Badge>
|
||||
</div>
|
||||
</template>
|
||||
30
apps/telemetry-dashboard/components/ui/Alert.vue
Normal file
30
apps/telemetry-dashboard/components/ui/Alert.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import { AlertCircle, CircleCheck, Info } from 'lucide-vue-next'
|
||||
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ variant?: 'info' | 'success' | 'destructive'; title: string; class?: string }>(),
|
||||
{ variant: 'info', class: undefined },
|
||||
)
|
||||
|
||||
const icons = { info: Info, success: CircleCheck, destructive: AlertCircle }
|
||||
const styles = {
|
||||
info: 'border-border bg-muted/40',
|
||||
success: 'border-emerald-500/30 bg-emerald-500/10',
|
||||
destructive: 'border-red-500/30 bg-red-500/10',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="alert"
|
||||
:class="cn('flex gap-3 rounded-lg border p-3.5 text-sm', styles[variant], props.class)"
|
||||
>
|
||||
<component :is="icons[variant]" class="mt-0.5 size-4 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium">{{ title }}</p>
|
||||
<div class="mt-0.5 text-muted-foreground"><slot /></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
apps/telemetry-dashboard/components/ui/Badge.vue
Normal file
33
apps/telemetry-dashboard/components/ui/Badge.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'
|
||||
class?: string
|
||||
}>(),
|
||||
{ variant: 'secondary', class: undefined },
|
||||
)
|
||||
|
||||
const variants = {
|
||||
default: 'bg-primary text-primary-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground',
|
||||
success: 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-300',
|
||||
warning: 'bg-amber-500/15 text-amber-800 dark:text-amber-300',
|
||||
destructive: 'bg-red-500/15 text-red-700 dark:text-red-300',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex items-center rounded px-2 py-0.5 text-xs font-medium',
|
||||
variants[variant],
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
45
apps/telemetry-dashboard/components/ui/Button.vue
Normal file
45
apps/telemetry-dashboard/components/ui/Button.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { ButtonHTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: 'default' | 'secondary' | 'ghost' | 'outline' | 'destructive'
|
||||
size?: 'default' | 'sm' | 'icon'
|
||||
class?: ButtonHTMLAttributes['class']
|
||||
}>(),
|
||||
{ variant: 'default', size: 'default', class: undefined },
|
||||
)
|
||||
|
||||
const variants = {
|
||||
default: 'bg-primary text-primary-foreground shadow-sm hover:brightness-95 active:brightness-90',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/75',
|
||||
ghost: 'text-foreground hover:bg-surface-3 active:bg-surface-4',
|
||||
outline:
|
||||
'border-surface-4 bg-surface-2 text-foreground hover:border-surface-5 hover:bg-surface-3 active:bg-surface-4',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:brightness-95',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
default: 'h-9 px-3.5',
|
||||
sm: 'h-8 px-2.5 text-xs',
|
||||
icon: 'size-9 shrink-0 p-0',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:data-variant="variant"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium transition-[color,background-color,border-color,box-shadow,filter] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-auto disabled:cursor-not-allowed disabled:opacity-50',
|
||||
variants[props.variant],
|
||||
sizes[props.size],
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
20
apps/telemetry-dashboard/components/ui/Card.vue
Normal file
20
apps/telemetry-dashboard/components/ui/Card.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="
|
||||
cn(
|
||||
'rounded-lg border border-surface-4 bg-surface-2 text-card-foreground shadow-[0_1px_2px_rgba(15,23,42,0.04)]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
66
apps/telemetry-dashboard/components/ui/Command.vue
Normal file
66
apps/telemetry-dashboard/components/ui/Command.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { BarChart3, Search, Server } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const query = ref('')
|
||||
const router = useRouter()
|
||||
const items = [
|
||||
{ label: '数据总览', path: '/', icon: BarChart3 },
|
||||
{ label: '系统状态', path: '/system', icon: Server },
|
||||
]
|
||||
const filtered = computed(() =>
|
||||
items.filter((item) => item.label.toLowerCase().includes(query.value.toLowerCase())),
|
||||
)
|
||||
|
||||
async function select(path: string): Promise<void> {
|
||||
open.value = false
|
||||
query.value = ''
|
||||
await router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot v-model:open="open">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
|
||||
<DialogContent
|
||||
class="fixed left-1/2 top-[18vh] z-50 w-[calc(100vw-2rem)] max-w-lg -translate-x-1/2 overflow-hidden rounded-lg border border-surface-5 bg-popover shadow-2xl outline-none"
|
||||
>
|
||||
<DialogTitle class="sr-only">快速跳转</DialogTitle>
|
||||
<DialogDescription class="sr-only">搜索控制台页面</DialogDescription>
|
||||
<div class="flex items-center gap-2 border-b px-3">
|
||||
<Search class="size-4 text-muted-foreground" />
|
||||
<input
|
||||
v-model="query"
|
||||
autofocus
|
||||
placeholder="输入页面名称..."
|
||||
class="h-12 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-1.5">
|
||||
<p v-if="!filtered.length" class="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||
没有匹配的页面
|
||||
</p>
|
||||
<button
|
||||
v-for="item in filtered"
|
||||
:key="item.path"
|
||||
type="button"
|
||||
class="flex w-full cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-left text-sm hover:bg-surface-3 focus:bg-surface-3 focus:outline-none"
|
||||
@click="select(item.path)"
|
||||
>
|
||||
<component :is="item.icon" class="size-4 text-muted-foreground" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
79
apps/telemetry-dashboard/components/ui/DropdownMenu.vue
Normal file
79
apps/telemetry-dashboard/components/ui/DropdownMenu.vue
Normal file
@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
|
||||
import {
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRoot,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { AdminSessionDto } from '~/shared/types/telemetry'
|
||||
|
||||
import { type ThemeMode, useTheme } from '../../composables/use-theme'
|
||||
|
||||
defineProps<{ session: AdminSessionDto }>()
|
||||
|
||||
const { mode } = useTheme()
|
||||
const themes: Array<{ value: ThemeMode; label: string; icon: typeof Sun }> = [
|
||||
{ value: 'light', label: '浅色', icon: Sun },
|
||||
{ value: 'dark', label: '深色', icon: Moon },
|
||||
{ value: 'system', label: '跟随系统', icon: Monitor },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot>
|
||||
<DropdownMenuTrigger
|
||||
class="inline-flex h-9 min-w-0 cursor-pointer items-center gap-2 rounded-md border border-surface-4 bg-surface-2 px-2.5 text-sm outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded bg-primary/15 text-xs font-semibold text-primary"
|
||||
>
|
||||
{{ session.identity.name.slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span class="hidden max-w-32 truncate sm:block">{{ session.identity.name }}</span>
|
||||
<ChevronDown class="size-3.5 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
:side-offset="6"
|
||||
class="z-50 w-64 rounded-lg border border-surface-5 bg-popover p-1 text-popover-foreground shadow-xl outline-none"
|
||||
>
|
||||
<DropdownMenuLabel class="px-2 py-1.5">
|
||||
<p class="truncate text-sm font-medium">{{ session.identity.name }}</p>
|
||||
<p class="truncate text-xs font-normal text-muted-foreground">
|
||||
{{ session.identity.email || 'Cloudflare Access 身份' }}
|
||||
</p>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
||||
<DropdownMenuLabel class="px-2 py-1 text-xs text-muted-foreground"
|
||||
>界面主题</DropdownMenuLabel
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="theme in themes"
|
||||
:key="theme.value"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
|
||||
@click="mode = theme.value"
|
||||
>
|
||||
<component :is="theme.icon" class="size-4 text-muted-foreground" />
|
||||
<span class="flex-1">{{ theme.label }}</span>
|
||||
<Check v-if="mode === theme.value" class="size-4" />
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator class="my-1 h-px bg-border" />
|
||||
<DropdownMenuItem as-child>
|
||||
<a
|
||||
:href="session.logoutUrl"
|
||||
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm outline-none hover:bg-surface-3 focus:bg-surface-3"
|
||||
>
|
||||
<LogOut class="size-4 text-muted-foreground" />
|
||||
退出登录
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
</template>
|
||||
27
apps/telemetry-dashboard/components/ui/Select.vue
Normal file
27
apps/telemetry-dashboard/components/ui/Select.vue
Normal file
@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
options: Array<{ value: string; label: string }>
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="relative inline-flex min-w-0 items-center">
|
||||
<span class="sr-only">{{ label }}</span>
|
||||
<select
|
||||
:value="modelValue"
|
||||
class="h-9 max-w-full cursor-pointer appearance-none rounded-md border border-surface-4 bg-surface-2 py-0 pl-3 pr-8 text-sm text-foreground outline-none transition-colors hover:border-surface-5 hover:bg-surface-3 focus:ring-2 focus:ring-ring"
|
||||
@change="$emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="option in options" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown class="pointer-events-none absolute right-2 size-4 text-muted-foreground" />
|
||||
</label>
|
||||
</template>
|
||||
44
apps/telemetry-dashboard/components/ui/Sheet.vue
Normal file
44
apps/telemetry-dashboard/components/ui/Sheet.vue
Normal file
@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
|
||||
defineProps<{ open: boolean; title: string; description?: string }>()
|
||||
defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="open" @update:open="$emit('update:open', $event)">
|
||||
<DialogPortal>
|
||||
<DialogOverlay class="sheet-overlay fixed inset-0 z-40 bg-black/45 backdrop-blur-[1px]" />
|
||||
<DialogContent
|
||||
class="sheet-panel fixed inset-y-0 right-0 z-50 flex w-full max-w-xl flex-col border-l border-surface-5 bg-surface-1 shadow-2xl focus:outline-none"
|
||||
>
|
||||
<header class="flex shrink-0 items-start justify-between gap-4 border-b px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<DialogTitle class="text-base font-semibold">{{ title }}</DialogTitle>
|
||||
<DialogDescription v-if="description" class="mt-1 text-sm text-muted-foreground">
|
||||
{{ description }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground hover:bg-surface-3 hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
aria-label="关闭详情"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</DialogClose>
|
||||
</header>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<slot />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
9
apps/telemetry-dashboard/components/ui/Skeleton.vue
Normal file
9
apps/telemetry-dashboard/components/ui/Skeleton.vue
Normal file
@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '~/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('animate-pulse rounded-md bg-muted', props.class)" aria-hidden="true"></div>
|
||||
</template>
|
||||
30
apps/telemetry-dashboard/components/ui/Tabs.vue
Normal file
30
apps/telemetry-dashboard/components/ui/Tabs.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label: string
|
||||
items: Array<{ value: string; label: string }>
|
||||
}>()
|
||||
|
||||
defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="tablist"
|
||||
:aria-label="label"
|
||||
class="inline-flex h-9 items-center rounded-md border border-surface-4 bg-surface-3 p-1"
|
||||
>
|
||||
<button
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="modelValue === item.value"
|
||||
class="h-7 cursor-pointer rounded px-2.5 text-xs font-medium text-muted-foreground transition-[color,background-color,box-shadow] hover:text-foreground active:bg-surface-4"
|
||||
:class="modelValue === item.value && 'bg-surface-2 text-foreground shadow-sm'"
|
||||
@click="$emit('update:modelValue', item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
15
apps/telemetry-dashboard/components/ui/Tooltip.vue
Normal file
15
apps/telemetry-dashboard/components/ui/Tooltip.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ text: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="group relative inline-flex">
|
||||
<slot />
|
||||
<span
|
||||
role="tooltip"
|
||||
class="pointer-events-none absolute left-1/2 top-full z-50 mt-2 hidden w-max max-w-56 -translate-x-1/2 rounded-md bg-foreground px-2 py-1 text-xs text-background shadow-md group-focus-within:block group-hover:block"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
Reference in New Issue
Block a user