feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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