Initial commit: 网络验证平台

This commit is contained in:
Admin
2026-04-27 17:22:56 +08:00
commit afe67d704e
780 changed files with 88960 additions and 0 deletions
@@ -0,0 +1,41 @@
import {
AudioWaveform,
Command,
GalleryVerticalEnd,
} from 'lucide-vue-next'
import { useSidebar } from '@/composables/use-sidebar'
import type { SidebarData, Team, User } from '../types'
const user: User = {
name: 'shadcn',
email: 'm@example.com',
avatar: '/avatars/shadcn.jpg',
}
const teams: Team[] = [
{
name: 'Acme Inc',
logo: GalleryVerticalEnd,
plan: 'Enterprise',
},
{
name: 'Acme Corp.',
logo: AudioWaveform,
plan: 'Startup',
},
{
name: 'Evil Corp.',
logo: Command,
plan: 'Free',
},
]
const { navData } = useSidebar()
export const sidebarData: SidebarData = {
user,
teams,
navMain: navData.value!,
}
@@ -0,0 +1,24 @@
<script lang="ts" setup>
import { sidebarData } from './data/sidebar-data'
import NavFooter from './nav-footer.vue'
import NavTeam from './nav-team.vue'
import TeamSwitcher from './team-switcher.vue'
</script>
<template>
<UiSidebar collapsible="icon" class="z-50">
<UiSidebarHeader>
<TeamSwitcher :teams="sidebarData.teams" />
</UiSidebarHeader>
<UiSidebarContent>
<NavTeam :nav-main="sidebarData.navMain" />
</UiSidebarContent>
<UiSidebarFooter>
<NavFooter :user="sidebarData.user" />
</UiSidebarFooter>
<UiSidebarRail />
</UiSidebar>
</template>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import {
BadgeCheck,
Bell,
ChevronsUpDown,
CreditCard,
LogOut,
Sparkles,
UserRoundCog,
} from 'lucide-vue-next'
import { useSidebar } from '@/components/ui/sidebar'
import type { User } from './types'
const { user } = defineProps<
{ user: User }
>()
const { logout } = useAuth()
const { isMobile, open } = useSidebar()
</script>
<template>
<UiSidebarMenu>
<UiSidebarMenuItem>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiSidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<UiAvatar class="size-8 rounded-lg">
<UiAvatarImage :src="user.avatar" :alt="user.name" />
<UiAvatarFallback class="rounded-lg">
CN
</UiAvatarFallback>
</UiAvatar>
<div class="grid flex-1 text-sm leading-tight text-left">
<span class="font-semibold truncate">{{ user.name }}</span>
<span class="text-xs truncate">{{ user.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</UiSidebarMenuButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent
class="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
:side="(isMobile || open) ? 'bottom' : 'right'"
align="start"
:side-offset="4"
>
<UiDropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UiAvatar class="size-8 rounded-lg">
<UiAvatarImage :src="user.avatar" :alt="user.name" />
<UiAvatarFallback class="rounded-lg">
CN
</UiAvatarFallback>
</UiAvatar>
<div class="grid flex-1 text-sm leading-tight text-left">
<span class="font-semibold truncate">{{ user.name }}</span>
<span class="text-xs truncate">{{ user.email }}</span>
</div>
</div>
</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuGroup>
<UiDropdownMenuItem @click="$router.push('/billing/')">
<Sparkles />
Upgrade to Pro
</UiDropdownMenuItem>
</UiDropdownMenuGroup>
<UiDropdownMenuSeparator />
<UiDropdownMenuGroup>
<UiDropdownMenuItem @click="$router.push('/billing?type=billing')">
<CreditCard />
Billing
</UiDropdownMenuItem>
</UiDropdownMenuGroup>
<UiDropdownMenuSeparator />
<UiDropdownMenuGroup>
<UiDropdownMenuItem @click="$router.push('/settings/')">
<UserRoundCog />
Profile
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="$router.push('/settings/account')">
<BadgeCheck />
Account
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="$router.push('/settings/notifications')">
<Bell />
Notifications
</UiDropdownMenuItem>
</UiDropdownMenuGroup>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem @click="logout">
<LogOut />
{{ $t('logout') }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</UiSidebarMenuItem>
</UiSidebarMenu>
</template>
@@ -0,0 +1,91 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { teamAddValidator } from './validators/team.validator'
const emits = defineEmits(['close'])
const teamAddFormSchema = toTypedSchema(teamAddValidator)
const { handleSubmit } = useForm({
validationSchema: teamAddFormSchema,
initialValues: {},
})
const onSubmit = handleSubmit((values) => {
toast('You submitted the following values:', {
position: 'top-center',
description: h('pre', { class: 'mt-2 w-[340px] rounded-md bg-slate-950 p-4' }, h('code', { class: 'text-white' }, JSON.stringify(values, null, 2))),
})
emits('close')
})
</script>
<template>
<div>
<UiDialogHeader>
<UiDialogTitle>
Add New Team
</UiDialogTitle>
<UiDialogDescription>
Add a new team by your self.
</UiDialogDescription>
</UiDialogHeader>
<form class="space-y-4" @submit="onSubmit">
<FormField v-slot="{ componentField }" name="name">
<FormItem>
<FormLabel class="text-base">
Name
</FormLabel>
<FormControl>
<UiInput v-bind="componentField" />
</FormControl>
<FormDescription>
Set the name for the team.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="slug">
<FormItem>
<FormLabel class="text-base">
Slug
</FormLabel>
<FormControl>
<UiInput v-bind="componentField" />
</FormControl>
<FormDescription>
Set the slug for the team.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<FormField v-slot="{ componentField }" name="logo">
<FormItem>
<FormLabel class="text-base">
Logo
</FormLabel>
<FormControl>
<UiInput v-bind="componentField" />
</FormControl>
<FormDescription>
Set the logo of the team.
</FormDescription>
<FormMessage />
</FormItem>
</FormField>
<div class="flex justify-start mt-4">
<UiButton type="submit">
Add team
</UiButton>
</div>
</form>
</div>
</template>
@@ -0,0 +1,136 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import {
ChevronRight,
} from 'lucide-vue-next'
import { useRoute } from 'vue-router'
import { useSidebar } from '@/components/ui/sidebar'
import type { NavGroup, NavItem } from './types'
const { navMain } = defineProps<{
navMain: NavGroup[]
}>()
const route = useRoute()
const { state, isMobile } = useSidebar()
function isCollapsed(menu: NavItem): boolean {
const pathname = route.path
navMain.forEach((group) => {
group.items.forEach((item) => {
if (item.url === pathname) {
return true
}
})
})
return !!menu.items?.some(item => item.url === pathname)
}
function isActive(menu: NavItem): boolean {
const pathname = route.path
if (menu.url) {
return pathname === menu.url
}
return !!menu.items?.some(item => item.url === pathname)
}
</script>
<template>
<UiSidebarGroup v-for="group in navMain" :key="group.title">
<UiSidebarGroupLabel v-if="state !== 'collapsed'">{{ group.title }}</UiSidebarGroupLabel>
<UiSidebarMenu>
<template v-for="menu in group.items" :key="menu.title">
<UiSidebarMenuItem v-if="!menu.items">
<UiTooltip v-if="state === 'collapsed' && !isMobile">
<UiTooltipTrigger as-child>
<UiSidebarMenuButton as-child :is-active="isActive(menu)">
<router-link :to="menu.url">
<component :is="menu.icon" v-if="typeof menu.icon === 'function'" class="size-4" />
<Icon v-else-if="menu.icon" :icon="menu.icon" />
<span>{{ menu.title }}</span>
</router-link>
</UiSidebarMenuButton>
</UiTooltipTrigger>
<UiTooltipContent side="right">
{{ menu.title }}
</UiTooltipContent>
</UiTooltip>
<UiSidebarMenuButton v-else as-child :is-active="isActive(menu)">
<router-link :to="menu.url">
<component :is="menu.icon" v-if="typeof menu.icon === 'function'" class="size-4" />
<Icon v-else-if="menu.icon" :icon="menu.icon" />
<span>{{ menu.title }}</span>
</router-link>
</UiSidebarMenuButton>
</UiSidebarMenuItem>
<UiSidebarMenuItem v-else>
<UiCollapsible
v-if="state !== 'collapsed' || isMobile"
as-child
:default-open="isCollapsed(menu)"
class="group/collapsible"
>
<UiSidebarMenuItem>
<UiCollapsibleTrigger as-child>
<UiSidebarMenuButton>
<component :is="menu.icon" v-if="typeof menu.icon === 'function'" class="size-4" />
<Icon v-else-if="menu.icon" :icon="menu.icon" />
<span>{{ menu.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</UiSidebarMenuButton>
</UiCollapsibleTrigger>
</UiSidebarMenuItem>
<UiCollapsibleContent>
<UiSidebarMenuSub>
<UiSidebarMenuSubItem v-for="subItem in menu.items" :key="subItem.title">
<UiSidebarMenuSubButton as-child :is-active="isActive(subItem as NavItem)">
<router-link :to="subItem?.url || '/'">
<component :is="subItem.icon" v-if="typeof subItem.icon === 'function'" class="size-4" />
<Icon v-else-if="subItem.icon" :icon="subItem.icon" />
<span>{{ subItem.title }}</span>
</router-link>
</UiSidebarMenuSubButton>
</UiSidebarMenuSubItem>
</UiSidebarMenuSub>
</UiCollapsibleContent>
</UiCollapsible>
<UiTooltip v-else>
<UiTooltipTrigger as-child>
<UiSidebarMenuButton>
<component :is="menu.icon" v-if="typeof menu.icon === 'function'" class="size-4" />
<Icon v-else-if="menu.icon" :icon="menu.icon" />
<span class="sr-only">{{ menu.title }}</span>
</UiSidebarMenuButton>
</UiTooltipTrigger>
<UiTooltipContent side="right">
{{ menu.title }}
</UiTooltipContent>
</UiTooltip>
<UiDropdownMenu v-if="state === 'collapsed' && !isMobile">
<UiDropdownMenuTrigger as-child>
<span />
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" side="right">
<UiDropdownMenuLabel>{{ menu.title }}</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem v-for="subItem in menu.items" :key="subItem.title" as-child>
<router-link :to="subItem?.url || '/'">
<component :is="subItem.icon" v-if="typeof subItem.icon === 'function'" class="size-4" />
<Icon v-else-if="subItem.icon" :icon="subItem.icon" />
<span>{{ subItem.title }}</span>
</router-link>
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</UiSidebarMenuItem>
</template>
</UiSidebarMenu>
</UiSidebarGroup>
</template>
@@ -0,0 +1,31 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { ref } from 'vue'
import type { Team } from './types'
const { teams } = defineProps<{
teams: Team[]
}>()
const activeTeam = ref<Team>(teams[0])
</script>
<template>
<UiSidebarMenu>
<UiSidebarMenuItem>
<UiSidebarMenuButton size="lg" class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<div
class="flex items-center justify-center rounded-lg aspect-square size-8 bg-sidebar-primary text-sidebar-primary-foreground"
>
<component :is="activeTeam.logo" v-if="typeof activeTeam.logo === 'function'" class="size-4" />
<Icon v-else :icon="activeTeam.logo" class="size-4" />
</div>
<div class="grid flex-1 text-sm leading-tight">
<span class="font-semibold truncate">{{ activeTeam.name }}</span>
<span class="text-xs truncate text-muted-foreground">{{ activeTeam.plan }}</span>
</div>
</UiSidebarMenuButton>
</UiSidebarMenuItem>
</UiSidebarMenu>
</template>
@@ -0,0 +1,43 @@
import type { LucideProps } from 'lucide-vue-next'
import type { FunctionalComponent } from 'vue'
type NavIcon = FunctionalComponent<LucideProps, Record<any, any>, any, Record<any, any>>
interface BaseNavItem {
title: string
icon?: NavIcon
}
export type NavItem
= | BaseNavItem & {
items: (BaseNavItem & { url?: string })[]
url?: never
isActive?: boolean
} | BaseNavItem & {
url: string
items?: never
}
export interface NavGroup {
title: string
items: NavItem[]
}
export interface User {
name: string
avatar: string
email: string
role?: string
}
export interface Team {
name: string
logo: NavIcon
plan: string
}
export interface SidebarData {
user: User
teams: Team[]
navMain: NavGroup[]
}
@@ -0,0 +1,17 @@
import { z } from 'zod'
export const teamAddValidator = z.object({
name: z
.string()
.min(1, { error: 'Group name is required' })
.max(50, { error: 'Group name must be less than 50 characters' }),
slug: z
.string()
.min(1, { error: 'Group name is required' })
.max(50, { error: 'Group name must be less than 50 characters' }),
logo: z
.string()
.optional(),
})
export type TeamAddValidator = z.infer<typeof teamAddValidator>
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { useColorMode } from '@vueuse/core'
import { Moon, Sun, SunMoon } from 'lucide-vue-next'
import CommandItemHasIcon from './command-item-has-icon.vue'
defineEmits<{
(e: 'click'): void
}>()
const mode = useColorMode()
</script>
<template>
<UiCommandGroup heading="Theme">
<UiCommandItem value="light" @click="mode = 'light', $emit('click')">
<CommandItemHasIcon name="Light" :icon="Sun" />
</UiCommandItem>
<UiCommandItem value="dark" @click="mode = 'dark', $emit('click')">
<CommandItemHasIcon name="Dark" :icon="Moon" />
</UiCommandItem>
<UiCommandItem value="system" @click="mode = 'auto', $emit('click')">
<CommandItemHasIcon name="System" :icon="SunMoon" />
</UiCommandItem>
</UiCommandGroup>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Milestone } from 'lucide-vue-next'
const { icon } = defineProps<{
name: string
icon?: Component
}>()
</script>
<template>
<div class="flex items-center gap-2">
<component :is="icon" v-if="icon" class="size-4" />
<Milestone v-else class="size-4" />
{{ name }}
</div>
</template>
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { useSidebar } from '@/composables/use-sidebar'
import type { NavGroup, NavItem } from '../app-sidebar/types'
import CommandItemHasIcon from './command-item-has-icon.vue'
const emit = defineEmits<{
(e: 'click'): void
}>()
const { navData, otherPages } = useSidebar()
function getFlatNavItems(navData: NavGroup[]): NavItem[] {
const flatItems: NavItem[] = []
navData.forEach((group) => {
group.items.forEach((item) => {
if (item.items) {
flatItems.push(...getFlatNavItems([item as unknown as NavGroup]))
}
else {
flatItems.push(item)
}
})
})
return flatItems
}
const commands = getFlatNavItems([...navData.value!, ...otherPages.value!])
const router = useRouter()
const route = useRoute()
function commandItemClick(url: string) {
emit('click')
if (route.fullPath !== url) {
router.push(url)
}
}
</script>
<template>
<UiCommandGroup heading="Pages">
<UiCommandItem
v-for="command in commands"
:key="command.title"
:value="command.title"
@click="commandItemClick(command.url!)"
>
<CommandItemHasIcon :name="command.title" :icon="command.icon" />
</UiCommandItem>
</UiCommandGroup>
</template>
@@ -0,0 +1,66 @@
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { MenuIcon, SearchIcon } from 'lucide-vue-next'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
import CommandChangeTheme from './command-change-theme.vue'
import CommandToPage from './command-to-page.vue'
const open = ref(false)
useEventListener('keydown', (event: KeyboardEvent) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault()
handleOpenChange()
}
})
function handleOpenChange() {
open.value = !open.value
}
const firstKey = computed(() => navigator?.userAgent.includes('Mac OS') ? '⌘' : 'Ctrl')
</script>
<template>
<div>
<div
class="text-sm items-center justify-between text-muted-foreground border border-border bg-muted/5 px-4 py-2 rounded-md md:min-w-[220px] cursor-pointer hidden md:flex"
@click="handleOpenChange"
>
<div class="flex items-center gap-2">
<SearchIcon class="size-4" />
<span class="text-xs font-semibold text-muted-foreground">{{ $t('homePage.searchKeyWords') }}</span>
</div>
<UiKbd>{{ firstKey }} + k</UiKbd>
</div>
<UiButton variant="outline" size="icon" class="md:hidden" @click="handleOpenChange">
<SearchIcon />
</UiButton>
<UiCommandDialog v-model:open="open">
<UiCommandInput placeholder="Type a command or search..." />
<UiCommandList>
<UiCommandEmpty>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<MenuIcon />
</EmptyMedia>
<EmptyTitle>No menu found.</EmptyTitle>
<EmptyDescription>
Try searching for a command or check the spelling.
</EmptyDescription>
</EmptyHeader>
</Empty>
</UiCommandEmpty>
<CommandToPage @click="handleOpenChange" />
<UiCommandSeparator />
<CommandChangeTheme @click="handleOpenChange" />
</UiCommandList>
</UiCommandDialog>
</div>
</template>
@@ -0,0 +1,71 @@
<script lang='ts' setup>
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
interface ConfirmDialogProps {
isLoading?: boolean
disabled?: boolean
cancelButtonText?: string
confirmButtonText?: string
destructive?: boolean
}
const {
isLoading = false,
disabled = false,
destructive = false,
cancelButtonText = 'Cancel',
confirmButtonText = 'Continue',
} = defineProps<ConfirmDialogProps>()
const emits = defineEmits<{
(e: 'confirm'): void
}>()
const openModel = defineModel<boolean>('open', {
default: false,
})
function handleConfirm() {
emits('confirm')
openModel.value = false
}
</script>
<template>
<AlertDialog :open="openModel">
<AlertDialogContent>
<AlertDialogHeader class="text-start">
<AlertDialogTitle>
<slot name="title" />
</AlertDialogTitle>
<AlertDialogDescription as-child>
<slot name="description" />
</AlertDialogDescription>
</AlertDialogHeader>
<slot />
<AlertDialogFooter>
<AlertDialogCancel :disabled="isLoading" @click="openModel = false">
{{ cancelButtonText }}
</AlertDialogCancel>
<UiButton
:variant="destructive ? 'destructive' : 'default'"
:disabled="disabled || isLoading"
@click="handleConfirm"
>
{{ confirmButtonText }}
</UiButton>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { Check, ChevronsUpDown } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { cn } from '@/lib/utils'
const modelValue = defineModel<string>()
const open = ref(false)
const countries = [
{ code: 'CN', name: '中国', flag: '🇨🇳' },
{ code: 'US', name: '美国', flag: '🇺🇸' },
{ code: 'JP', name: '日本', flag: '🇯🇵' },
{ code: 'KR', name: '韩国', flag: '🇰🇷' },
{ code: 'GB', name: '英国', flag: '🇬🇧' },
{ code: 'DE', name: '德国', flag: '🇩🇪' },
{ code: 'FR', name: '法国', flag: '🇫🇷' },
{ code: 'IT', name: '意大利', flag: '🇮🇹' },
{ code: 'ES', name: '西班牙', flag: '🇪🇸' },
{ code: 'RU', name: '俄罗斯', flag: '🇷🇺' },
{ code: 'CA', name: '加拿大', flag: '🇨🇦' },
{ code: 'AU', name: '澳大利亚', flag: '🇦🇺' },
{ code: 'BR', name: '巴西', flag: '🇧🇷' },
{ code: 'IN', name: '印度', flag: '🇮🇳' },
{ code: 'MX', name: '墨西哥', flag: '🇲🇽' },
{ code: 'NL', name: '荷兰', flag: '🇳🇱' },
{ code: 'SG', name: '新加坡', flag: '🇸🇬' },
{ code: 'HK', name: '中国香港', flag: '🇭🇰' },
{ code: 'TW', name: '中国台湾', flag: '🇹🇼' },
{ code: 'TH', name: '泰国', flag: '🇹🇭' },
{ code: 'VN', name: '越南', flag: '🇻🇳' },
{ code: 'MY', name: '马来西亚', flag: '🇲🇾' },
{ code: 'ID', name: '印度尼西亚', flag: '🇮🇩' },
{ code: 'PH', name: '菲律宾', flag: '🇵🇭' },
{ code: 'AE', name: '阿联酋', flag: '🇦🇪' },
{ code: 'SA', name: '沙特阿拉伯', flag: '🇸🇦' },
{ code: 'TR', name: '土耳其', flag: '🇹🇷' },
{ code: 'PL', name: '波兰', flag: '🇵🇱' },
{ code: 'SE', name: '瑞典', flag: '🇸🇪' },
{ code: 'CH', name: '瑞士', flag: '🇨🇭' },
{ code: 'AT', name: '奥地利', flag: '🇦🇹' },
{ code: 'BE', name: '比利时', flag: '🇧🇪' },
{ code: 'DK', name: '丹麦', flag: '🇩🇰' },
{ code: 'FI', name: '芬兰', flag: '🇫🇮' },
{ code: 'NO', name: '挪威', flag: '🇳🇴' },
{ code: 'IE', name: '爱尔兰', flag: '🇮🇪' },
{ code: 'PT', name: '葡萄牙', flag: '🇵🇹' },
{ code: 'CZ', name: '捷克', flag: '🇨🇿' },
{ code: 'RO', name: '罗马尼亚', flag: '🇷🇴' },
{ code: 'HU', name: '匈牙利', flag: '🇭🇺' },
{ code: 'IL', name: '以色列', flag: '🇮🇱' },
{ code: 'ZA', name: '南非', flag: '🇿🇦' },
{ code: 'EG', name: '埃及', flag: '🇪🇬' },
{ code: 'NG', name: '尼日利亚', flag: '🇳🇬' },
{ code: 'AR', name: '阿根廷', flag: '🇦🇷' },
{ code: 'CL', name: '智利', flag: '🇨🇱' },
{ code: 'CO', name: '哥伦比亚', flag: '🇨🇴' },
{ code: 'PE', name: '秘鲁', flag: '🇵🇪' },
{ code: 'NZ', name: '新西兰', flag: '🇳🇿' },
]
const selectedCountry = computed(() => {
return countries.find(c => c.code === modelValue.value)
})
function filterFunction(list: typeof countries, term: string) {
return list.filter(country =>
country.name.toLowerCase().includes(term.toLowerCase())
|| country.code.toLowerCase().includes(term.toLowerCase()),
)
}
function selectCountry(code: string) {
modelValue.value = code
open.value = false
}
</script>
<template>
<UiPopover v-model:open="open">
<UiPopoverTrigger as-child>
<button
type="button"
role="combobox"
:aria-expanded="open"
:aria-label="selectedCountry ? selectedCountry.name : '选择国家或地区'"
class="flex h-9 w-full items-center justify-between rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
<span v-if="selectedCountry" class="flex items-center gap-2 truncate">
<span>{{ selectedCountry.flag }}</span>
<span>{{ selectedCountry.name }}</span>
<span class="text-muted-foreground text-xs">({{ selectedCountry.code }})</span>
</span>
<span v-else class="text-muted-foreground">选择国家或地区</span>
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</UiPopoverTrigger>
<UiPopoverContent class="w-[300px] p-0" align="start">
<UiCommand :filter-function="filterFunction as any">
<UiCommandInput placeholder="搜索国家或地区..." />
<UiCommandList>
<UiCommandEmpty>
未找到匹配的国家或地区
</UiCommandEmpty>
<UiCommandGroup>
<UiCommandItem
v-for="country in countries"
:key="country.code"
:value="country"
class="cursor-pointer"
@select="selectCountry(country.code)"
>
<div
:class="cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
modelValue === country.code
? 'bg-primary text-primary-foreground'
: 'opacity-50',
)"
>
<Check
v-if="modelValue === country.code"
class="h-3 w-3"
/>
</div>
<span class="flex items-center gap-2">
<span>{{ country.flag }}</span>
<span>{{ country.name }}</span>
<span class="text-muted-foreground text-xs">({{ country.code }})</span>
</span>
</UiCommandItem>
</UiCommandGroup>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>
+34
View File
@@ -0,0 +1,34 @@
<script setup lang="ts">
defineProps<{
code: number
subtitle: string
error: string
}>()
</script>
<template>
<div class="max-w-2xl mx-auto text-center">
<h1 class="font-bold text-8xl">
{{ code }}
</h1>
<h2 class="mt-4 text-2xl font-bold">
{{ subtitle }}
</h2>
<p class="text-stone-400">
{{ error }}
</p>
<footer class="mt-8">
<slot>
<div class="flex justify-center gap-2">
<UiButton variant="outline" @click="$router.back()">
Go Back
</UiButton>
<UiButton @click="$router.push('/')">
Back to Home
</UiButton>
</div>
</slot>
</footer>
</div>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import { CONTENT_LAYOUTS } from '@/constants/themes'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const { setContentLayout } = themeStore
const { contentLayout } = storeToRefs(themeStore)
</script>
<template>
<div class="space-y-1.5 pt-6">
<UiLabel for="radius" class="text-xs">
Content Layout
</UiLabel>
<div class="grid grid-cols-2 gap-2 py-1.5">
<UiButton
v-for="layout in CONTENT_LAYOUTS" :key="layout.label"
variant="outline"
class="justify-center h-8 px-3"
:class="contentLayout === layout.value ? 'border-foreground border-2' : ''"
@click="setContentLayout(layout.value)"
>
<component :is="layout.icon" />
{{ layout.label }}
</UiButton>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import { THEME_PRIMARY_COLORS, THEMES } from '@/constants/themes'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const { setTheme } = themeStore
const { theme: t } = storeToRefs(themeStore)
watchEffect(() => {
document.documentElement.classList.remove(...THEMES.map(theme => `theme-${theme}`))
document.documentElement.classList.add(`theme-${t.value}`)
})
</script>
<template>
<div class="space-y-1.5 pt-6">
<UiLabel for="radius" class="text-xs">
Color
</UiLabel>
<div class="grid grid-cols-2 gap-2 py-1.5">
<UiButton
v-for="theme in THEME_PRIMARY_COLORS" :key="theme.theme"
variant="outline"
class="justify-center h-8 px-3"
:class="t === theme.theme ? 'border-foreground border-2' : ''"
@click="setTheme(theme.theme)"
>
<span
:style="{
'--theme-primary': theme.primaryColor,
}"
class="size-2 rounded-full bg-(--theme-primary)"
/>
<span class="text-xs">{{ theme.theme[0].toUpperCase() }}{{ theme.theme.slice(1) }}</span>
</UiButton>
</div>
</div>
</template>
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import { storeToRefs } from 'pinia'
import { RADIUS } from '@/constants/themes'
import { useThemeStore } from '@/stores/theme'
const themeStore = useThemeStore()
const { setRadius } = themeStore
const { radius } = storeToRefs(themeStore)
watchEffect(() => {
document.documentElement.style.setProperty('--radius', `${radius.value}rem`)
})
</script>
<template>
<div class="space-y-1.5 pt-6">
<UiLabel for="radius" class="text-xs">
Radius
</UiLabel>
<div class="grid grid-cols-5 gap-2 py-1.5">
<UiButton
v-for="rayon in RADIUS" :key="rayon"
variant="outline"
class="justify-center h-8 px-3"
:class="rayon === radius ? 'border-foreground border-2' : ''"
@click="setRadius(rayon)"
>
<span class="text-xs">{{ rayon }}</span>
</UiButton>
</div>
</div>
</template>
@@ -0,0 +1,14 @@
<script lang="ts" setup>
</script>
<template>
<div class="grid space-y-1">
<h1 class="font-semibold text-md text-foreground">
Customize
</h1>
<p class="text-xs text-muted-foreground">
Pick a style and color for your components.
</p>
</div>
</template>
@@ -0,0 +1,34 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { Button } from '@/components/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import ContentLayout from './content-layout.vue'
import CustomColor from './custom-color.vue'
import CustomRadius from './custom-radius.vue'
import CustomThemeTitle from './custom-theme-title.vue'
import ToggleColorMode from './toggle-color-mode.vue'
</script>
<template>
<Popover>
<PopoverTrigger>
<Button variant="ghost" size="icon" class="h-9 w-9">
<Icon icon="lucide:settings" class="h-5 w-5" />
<span class="sr-only">设置</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end">
<CustomThemeTitle />
<CustomColor />
<CustomRadius />
<ToggleColorMode />
<ContentLayout />
</PopoverContent>
</Popover>
</template>
@@ -0,0 +1,42 @@
<script lang="ts" setup>
import type { BasicColorSchema } from '@vueuse/core'
import type { Component } from 'vue'
import { useColorMode } from '@vueuse/core'
import { Moon, Sun, SunMoon } from 'lucide-vue-next'
const mode = useColorMode()
const colorModes: {
colorMode: BasicColorSchema
icon: Component
}[] = [
{ colorMode: 'light', icon: Sun },
{ colorMode: 'dark', icon: Moon },
{ colorMode: 'auto', icon: SunMoon },
]
function setColorMode(colorMode: BasicColorSchema) {
mode.value = colorMode
}
</script>
<template>
<div class="space-y-1.5 pt-6">
<UiLabel for="radius" class="text-xs">
Color Mode
</UiLabel>
<div class="grid grid-cols-3 gap-2 py-1.5">
<UiButton
v-for="item in colorModes" :key="item.colorMode"
variant="outline"
class="justify-center items-center h-8 px-3"
:class="item.colorMode === mode ? 'border-foreground border-2' : ''"
@click="setColorMode(item.colorMode)"
>
<component :is="item.icon" />
<span class="text-xs">{{ item.colorMode }}</span>
</UiButton>
</div>
</div>
</template>
@@ -0,0 +1,90 @@
<script lang='ts' setup generic="T">
import type { Table as VueTable } from '@tanstack/vue-table'
import { XIcon } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface BulkActionsProps<T> {
table: VueTable<T>
entityName?: string
}
const { table, entityName: _entityName } = defineProps<BulkActionsProps<T>>()
const selectedRows = computed(() => table.getSelectedRowModel().rows)
const selectedCount = computed(() => selectedRows.value.length || 0)
function handleClearSelection() {
table.resetRowSelection()
}
</script>
<template>
<TooltipProvider>
<Transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-200 ease-in"
enter-from-class="opacity-0 -translate-y-2"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-if="selectedCount"
:class="cn(
'mb-4 p-3 rounded-lg border',
'bg-muted/50',
'flex flex-wrap items-center gap-2',
)"
>
<div class="flex items-center gap-2">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-7"
aria-label="Clear selection"
@click="handleClearSelection"
>
<XIcon class="size-4" />
<span class="sr-only">Clear selection</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>取消选择</p>
</TooltipContent>
</Tooltip>
<Separator
class="h-5"
orientation="vertical"
aria-hidden="true"
/>
<div class="flex items-center gap-1 text-sm">
<UiBadge
class="min-w-8 rounded-lg"
:aria-label="`${selectedCount} selected`"
>
{{ selectedCount }}
</UiBadge>
<span>项已选中</span>
</div>
</div>
<Separator
class="h-5 hidden sm:block"
orientation="vertical"
aria-hidden="true"
/>
<div class="flex flex-wrap items-center gap-2">
<slot />
</div>
</div>
</Transition>
</TooltipProvider>
</template>
@@ -0,0 +1,93 @@
<script setup lang="ts" generic="T">
import type { Column } from '@tanstack/vue-table'
import { ArrowDownIcon, ArrowLeftIcon, ArrowRightIcon, ArrowUpIcon, ChevronsUpDownIcon, EyeOffIcon, PinIcon, PinOffIcon } from 'lucide-vue-next'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
interface DataTableColumnHeaderProps {
column: Column<T, any>
title: string
}
const props = defineProps<DataTableColumnHeaderProps>()
const canPinned = computed(() => props.column.getCanPin())
const canSorted = computed(() => props.column.getCanSort())
</script>
<script lang="ts">
export default {
inheritAttrs: false,
}
</script>
<template>
<div v-if="canSorted || canPinned" :class="cn('flex items-center space-x-2', $attrs.class ?? '')">
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="ghost"
size="sm"
class="-ml-3 h-8 data-[state=open]:bg-accent"
>
<template v-if="canPinned">
<PinIcon v-if="props.column.getIsPinned()" class="ml-2 size-4 text-primary" />
</template>
<span>{{ title }}</span>
<template v-if="canSorted">
<ArrowDownIcon v-if="props.column.getIsSorted() === 'desc'" class="ml-2 size-4" />
<ArrowUpIcon v-else-if="props.column.getIsSorted() === 'asc'" class="ml-2 size-4" />
<ChevronsUpDownIcon v-else class="ml-2 size-4" />
</template>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start">
<template v-if="canSorted">
<UiDropdownMenuItem @click="props.column.toggleSorting(false)">
<ArrowUpIcon class="mr-2 size-4 text-muted-foreground/70" />
Asc
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.toggleSorting(true)">
<ArrowDownIcon class="mr-2 size-4 text-muted-foreground/70" />
Desc
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.clearSorting()">
<ChevronsUpDownIcon class="mr-2 size-4 text-muted-foreground/70" />
Clear Sorting
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
</template>
<UiDropdownMenuItem @click="props.column.toggleVisibility(false)">
<EyeOffIcon class="mr-2 size-4 text-muted-foreground/70" />
Hide
</UiDropdownMenuItem>
<template v-if="canPinned">
<UiDropdownMenuSeparator />
<UiDropdownMenuItem @click="props.column.pin('left')">
<ArrowLeftIcon class="mr-2 size-4 text-muted-foreground/70" />
Pin Left
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.pin('right')">
<ArrowRightIcon class="mr-2 size-4 text-muted-foreground/70" />
Pin Right
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="props.column.pin(false)">
<PinOffIcon class="mr-2 size-4 text-muted-foreground/70" />
Unpin
</UiDropdownMenuItem>
</template>
</UiDropdownMenuContent>
</UiDropdownMenu>
</div>
<div v-else :class="$attrs?.class ?? ''">
{{ title }}
</div>
</template>
@@ -0,0 +1,116 @@
<script setup lang="ts" generic="T">
import type { Column, Table as VueTable } from '@tanstack/vue-table'
import type { CSSProperties } from 'vue'
import { FlexRender } from '@tanstack/vue-table'
import { ChevronRight } from 'lucide-vue-next'
import DataTableLoading from '@/components/data-table/table-loading.vue'
import DataTablePagination from '@/components/data-table/table-pagination.vue'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import type { DataTableProps } from './types'
import NoResultFound from '../no-result-found.vue'
const props = defineProps<DataTableProps<T> & {
table: VueTable<T>
}>()
defineEmits<{
refresh: []
}>()
defineExpose({
table: props.table,
})
function getCommonPinningStyles(column: Column<T>): CSSProperties {
const isPinned = column.getIsPinned()
return {
left: isPinned === 'left' ? `${column.getStart('left')}px` : undefined,
right: isPinned === 'right' ? `${column.getAfter('right')}px` : undefined,
position: isPinned ? 'sticky' : 'relative',
width: `${column.getSize()}px`,
zIndex: isPinned ? 1 : 0,
}
}
</script>
<template>
<div class="space-y-4">
<slot name="toolbar" :selected-count="table.getSelectedRowModel().rows.length" />
<slot name="filters" />
<div class="border rounded-md">
<Table>
<TableHeader>
<TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id">
<TableHead
v-for="header in headerGroup.headers"
:key="header.id"
:style="getCommonPinningStyles(header.column)"
:class="{ 'bg-background': header.column.getIsPinned() }"
>
<FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" />
</TableHead>
</TableRow>
</TableHeader>
<TableBody v-if="!loading">
<template v-if="table.getRowModel().rows?.length">
<template v-for="row in table.getRowModel().rows" :key="row.id">
<TableRow
:data-state="row.getIsSelected() && 'selected'"
:class="{ 'bg-muted/50': row.getIsExpanded() }"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:style="getCommonPinningStyles(cell.column)"
:class="{ 'bg-background': cell.column.getIsPinned() }"
>
<div class="flex items-center">
<template v-if="cell.column.id === 'select'">
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
<template v-if="row.getCanExpand()">
<Button
variant="ghost"
size="icon"
class="ml-2 h-6 w-6"
@click="row.toggleExpanded()"
>
<ChevronRight
class="h-4 w-4 transition-transform"
:class="{ 'rotate-90': row.getIsExpanded() }"
/>
</Button>
</template>
<template v-else>
<div class="w-10" />
</template>
</template>
<template v-else>
<FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" />
</template>
</div>
</TableCell>
</TableRow>
</template>
</template>
<TableRow v-else>
<TableCell
:colspan="columns.length"
class="h-24 text-center"
>
<NoResultFound />
</TableCell>
</TableRow>
</TableBody>
</Table>
<DataTableLoading v-if="loading" />
</div>
<DataTablePagination v-if="!loading" :table="table" :server-pagination="serverPagination" />
</div>
</template>
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { CalendarIcon } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import DateTimePicker from '@/components/ui/date-picker/DateTimePicker.vue'
interface Props {
startModelValue: string
endModelValue: string
title?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '',
})
const emit = defineEmits<{
'update:startModelValue': [value: string]
'update:endModelValue': [value: string]
}>()
const { t } = useI18n()
const displayValue = computed(() => {
if (props.startModelValue && props.endModelValue) {
return `${props.startModelValue.replace('T', ' ')} ~ ${props.endModelValue.replace('T', ' ')}`
}
if (props.startModelValue) {
return `${props.startModelValue.replace('T', ' ')} ~ `
}
if (props.endModelValue) {
return `~ ${props.endModelValue.replace('T', ' ')}`
}
return ''
})
const buttonTitle = computed(() => props.title || t('developer.cards.timeRange'))
function setQuickRange(range: 'today' | 'week' | 'month') {
const now = new Date()
const today = now.toISOString().split('T')[0]
const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0]
switch (range) {
case 'today': {
emit('update:startModelValue', `${today}T00:00`)
emit('update:endModelValue', `${today}T23:59`)
break
}
case 'week': {
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
emit('update:startModelValue', `${weekAgo.toISOString().split('T')[0]}T00:00`)
emit('update:endModelValue', `${today}T23:59`)
break
}
case 'month': {
emit('update:startModelValue', `${firstDayOfMonth}T00:00`)
emit('update:endModelValue', `${today}T23:59`)
break
}
}
}
function clearFilter() {
emit('update:startModelValue', '')
emit('update:endModelValue', '')
}
</script>
<template>
<UiPopover>
<UiPopoverTrigger as-child>
<UiButton variant="outline" size="sm" class="h-8 border-dashed w-auto">
<CalendarIcon class="size-4 mr-2" />
{{ buttonTitle }}
<template v-if="displayValue">
<UiSeparator orientation="vertical" class="h-4 mx-2" />
<span class="text-xs">{{ displayValue }}</span>
</template>
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-auto p-4" align="start">
<div class="space-y-3">
<div class="text-sm font-medium">
{{ t('developer.cards.selectTimeRange') }}
</div>
<div class="flex items-center gap-2">
<DateTimePicker
:model-value="startModelValue"
:placeholder="t('developer.cards.startTime')"
class="w-[180px]"
@update:model-value="emit('update:startModelValue', $event)"
/>
<span class="text-muted-foreground text-sm">~</span>
<DateTimePicker
:model-value="endModelValue"
:placeholder="t('developer.cards.endTime')"
class="w-[180px]"
@update:model-value="emit('update:endModelValue', $event)"
/>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1">
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="setQuickRange('today')">
{{ t('developer.cards.today') }}
</UiButton>
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="setQuickRange('week')">
{{ t('developer.cards.last7Days') }}
</UiButton>
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="setQuickRange('month')">
{{ t('developer.cards.thisMonth') }}
</UiButton>
</div>
<UiButton variant="ghost" size="sm" class="h-7 text-xs" @click="clearFilter">
{{ t('developer.cards.reset') }}
</UiButton>
</div>
</div>
</UiPopoverContent>
</UiPopover>
</template>
@@ -0,0 +1,121 @@
<script setup lang="ts" generic="T">
import type { Column } from '@tanstack/vue-table'
import { Check, CirclePlus } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import type { FacetedFilterOption } from './types'
interface DataTableFacetedFilter {
column?: Column<T, any>
title?: string
options: FacetedFilterOption[]
}
const props = defineProps<DataTableFacetedFilter>()
const facets = computed(() => props.column?.getFacetedUniqueValues())
const selectedValues = computed(() => new Set(props.column?.getFilterValue() as string[]))
const filterFunction = (list: DataTableFacetedFilter['options'], term: string) => list.filter(i => i.label.toLowerCase()?.includes(term))
</script>
<template>
<UiPopover>
<UiPopoverTrigger as-child>
<UiButton variant="outline" size="sm" class="h-8 border-dashed">
<CirclePlus class="size-4 mr-2" />
{{ title }}
<template v-if="selectedValues.size > 0">
<UiSeparator orientation="vertical" class="h-4 mx-2" />
<UiBadge
variant="secondary"
class="px-1 font-normal rounded-sm lg:hidden"
>
{{ selectedValues.size }}
</UiBadge>
<div class="hidden space-x-1 lg:flex">
<UiBadge
v-if="selectedValues.size > 2"
variant="secondary"
class="px-1 font-normal rounded-sm"
>
{{ selectedValues.size }} selected
</UiBadge>
<template v-else>
<UiBadge
v-for="option in options
.filter((option) => selectedValues.has(option.value))"
:key="option.value"
variant="secondary"
class="px-1 font-normal rounded-sm"
>
{{ option.label }}
</UiBadge>
</template>
</div>
</template>
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-[200px] p-0" align="start">
<UiCommand
:filter-function="filterFunction as unknown as any"
>
<UiCommandInput :placeholder="title" />
<UiCommandList>
<UiCommandEmpty>No results found.</UiCommandEmpty>
<UiCommandGroup>
<UiCommandItem
v-for="option in options"
:key="option.value"
:value="option"
@select="(_e) => {
const isSelected = selectedValues.has(option.value)
if (isSelected) {
selectedValues.delete(option.value)
}
else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined,
)
}"
>
<div
:class="cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedValues.has(option.value)
? 'bg-primary'
: 'opacity-50 [&_svg]:invisible',
)"
>
<Check :class="cn('h-4 w-4', selectedValues.has(option.value) ? 'text-primary-foreground' : '')" />
</div>
<component :is="option.icon" v-if="option.icon" class="size-4 mr-2 text-muted-foreground" />
<span>{{ option.label }}</span>
<span v-if="facets?.get(option.value)" class="flex items-center justify-center size-4 ml-auto font-mono text-xs">
{{ facets.get(option.value) }}
</span>
</UiCommandItem>
</UiCommandGroup>
<template v-if="selectedValues.size > 0">
<UiCommandSeparator />
<UiCommandGroup>
<UiCommandItem
:value="{ label: 'Clear filters' }"
class="justify-center text-center"
@select="column?.setFilterValue(undefined)"
>
Clear filters
</UiCommandItem>
</UiCommandGroup>
</template>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { CircleIcon } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
defineProps<{
checked: boolean
}>()
const emit = defineEmits<{
click: [event: MouseEvent]
}>()
</script>
<template>
<button
type="button"
role="radio"
:aria-checked="checked"
:class="
cn(
'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
'hover:border-ring cursor-pointer',
)
"
@click="emit('click', $event)"
>
<span
v-if="checked"
class="relative flex items-center justify-center"
>
<CircleIcon class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</span>
</button>
</template>
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { Check, CirclePlus } from 'lucide-vue-next'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@/lib/utils'
interface FilterOption {
value: string
label: string
icon?: any
count?: number
}
interface Props {
title: string
options: FilterOption[]
modelValue: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { t } = useI18n()
const selectedValue = computed(() => props.modelValue)
const filterFunction = (list: FilterOption[], term: string) => list.filter(i => i.label.toLowerCase()?.includes(term))
</script>
<template>
<UiPopover>
<UiPopoverTrigger as-child>
<UiButton variant="outline" size="sm" class="h-8 border-dashed">
<CirclePlus class="size-4 mr-2" />
{{ title }}
<template v-if="selectedValue">
<UiSeparator orientation="vertical" class="h-4 mx-2" />
<UiBadge
variant="secondary"
class="px-1 font-normal rounded-sm"
>
{{ options.find(o => o.value === selectedValue)?.label }}
</UiBadge>
</template>
</UiButton>
</UiPopoverTrigger>
<UiPopoverContent class="w-[200px] p-0" align="start">
<UiCommand :filter-function="filterFunction as unknown as any">
<UiCommandInput :placeholder="title" />
<UiCommandList>
<UiCommandEmpty>{{ t('common.noResults') }}</UiCommandEmpty>
<UiCommandGroup>
<UiCommandItem
v-for="option in options"
:key="option.value"
:value="option"
@select="() => {
emit('update:modelValue', option.value === selectedValue ? '' : option.value)
}"
>
<div
:class="cn(
'mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary',
selectedValue === option.value
? 'bg-primary'
: 'opacity-50 [&_svg]:invisible',
)"
>
<Check :class="cn('h-4 w-4', selectedValue === option.value ? 'text-primary-foreground' : '')" />
</div>
<component :is="option.icon" v-if="option.icon" class="size-4 mr-2 text-muted-foreground" />
<span>{{ option.label }}</span>
<span v-if="option.count" class="flex items-center justify-center size-4 ml-auto font-mono text-xs">
{{ option.count }}
</span>
</UiCommandItem>
</UiCommandGroup>
<template v-if="selectedValue">
<UiCommandSeparator />
<UiCommandGroup>
<UiCommandItem
:value="{ label: 'Clear filters' }"
class="justify-center text-center"
@select="emit('update:modelValue', '')"
>
{{ t('common.clearFilters') }}
</UiCommandItem>
</UiCommandGroup>
</template>
</UiCommandList>
</UiCommand>
</UiPopoverContent>
</UiPopover>
</template>
@@ -0,0 +1,49 @@
import type { ColumnDef } from '@tanstack/vue-table'
import { h } from 'vue'
import Checkbox from '@/components/ui/checkbox/Checkbox.vue'
import RadioCell from './radio-cell.vue'
const FIXED_WIDTH_COLUMN = {
size: 32,
minSize: 32,
maxSize: 32,
enableResizing: false,
} as const
export const SelectColumn: ColumnDef<any> = {
id: 'select',
...FIXED_WIDTH_COLUMN,
header: ({ table }) => h(Checkbox, {
'modelValue': table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && 'indeterminate'),
'onUpdate:modelValue': value => table.toggleAllPageRowsSelected(!!value),
'ariaLabel': 'Select all',
}),
cell: ({ row }) => h(Checkbox, {
'modelValue': row.getIsSelected(),
'onUpdate:modelValue': value => row.toggleSelected(!!value),
'ariaLabel': 'Select row',
}),
enableSorting: false,
enableHiding: false,
}
export const RadioSelectColumn: ColumnDef<any> = {
id: 'radio-select',
...FIXED_WIDTH_COLUMN,
header: () => null,
cell: ({ row, table }) => h(RadioCell, {
checked: row.getIsSelected(),
onClick: (event: MouseEvent) => {
event.stopPropagation()
// cancel selection of all rows
table.toggleAllRowsSelected(false)
// select the current row
row.toggleSelected(true)
},
}),
enableSorting: false,
enableHiding: false,
}
@@ -0,0 +1,8 @@
<script lang="ts" setup>
</script>
<template>
<div class="h-120 w-full flex items-center justify-center">
<UiSpinner class="size-10" />
</div>
</template>
@@ -0,0 +1,222 @@
<script setup lang="ts" generic="T">
import type { Table } from '@tanstack/vue-table'
import {
ChevronLeftIcon,
ChevronRightIcon,
ChevronsLeft,
ChevronsRight,
} from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { PAGE_SIZES } from '@/constants/pagination'
import type { ServerPagination } from './types'
interface DataTablePaginationProps {
table: Table<T>
serverPagination?: ServerPagination
}
const props = defineProps<DataTablePaginationProps>()
const { t } = useI18n()
const isServerPagination = computed(() => !!props.serverPagination)
const currentPage = computed(() => {
if (isServerPagination.value && props.serverPagination) {
return props.serverPagination.page
}
return props.table.getState().pagination.pageIndex + 1
})
const currentPageSize = computed(() => {
if (isServerPagination.value && props.serverPagination) {
return props.serverPagination.pageSize
}
return props.table.getState().pagination.pageSize
})
const totalPages = computed(() => {
if (isServerPagination.value && props.serverPagination) {
return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize)
}
return props.table.getPageCount()
})
const canPreviousPage = computed(() => {
if (isServerPagination.value) {
return currentPage.value > 1
}
return props.table.getCanPreviousPage()
})
const canNextPage = computed(() => {
if (isServerPagination.value) {
return currentPage.value < totalPages.value
}
return props.table.getCanNextPage()
})
const selectedCount = computed(() => props.table.getSelectedRowModel().rows.length)
const jumpPage = ref<string>('')
const isEditingPage = ref(false)
function handlePageSizeChange(value: any) {
if (!value)
return
const newPageSize = Number(value)
if (isServerPagination.value && props.serverPagination?.onPageSizeChange) {
props.serverPagination.onPageSizeChange(newPageSize)
}
else {
props.table.setPageSize(newPageSize)
}
}
function goToFirstPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(1)
}
else {
props.table.setPageIndex(0)
}
}
function goToPreviousPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(currentPage.value - 1)
}
else {
props.table.previousPage()
}
}
function goToNextPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(currentPage.value + 1)
}
else {
props.table.nextPage()
}
}
function goToLastPage() {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(totalPages.value)
}
else {
props.table.setPageIndex(props.table.getPageCount() - 1)
}
}
function startEditPage() {
jumpPage.value = String(currentPage.value)
isEditingPage.value = true
}
function handleJumpPage() {
const page = Number.parseInt(jumpPage.value, 10)
if (!Number.isNaN(page) && page >= 1 && page <= totalPages.value) {
if (isServerPagination.value && props.serverPagination?.onPageChange) {
props.serverPagination.onPageChange(page)
}
else {
props.table.setPageIndex(page - 1)
}
}
isEditingPage.value = false
jumpPage.value = ''
}
</script>
<template>
<div class="flex items-center justify-between px-2">
<div class="flex items-center text-sm text-muted-foreground">
<Transition
enter-active-class="transition-all duration-200 ease-out"
leave-active-class="transition-all duration-200 ease-in"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<span v-if="selectedCount">
{{ selectedCount }} {{ t('common.itemsSelected') }}
</span>
</Transition>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">{{ t('common.rowsPerPage') }}</span>
<UiSelect
:model-value="`${currentPageSize}`"
@update:model-value="handlePageSizeChange"
>
<UiSelectTrigger size="sm" class="h-7 w-[65px] px-2">
<UiSelectValue :placeholder="`${currentPageSize}`" />
</UiSelectTrigger>
<UiSelectContent side="top">
<UiSelectItem v-for="pageSize in PAGE_SIZES" :key="pageSize" :value="`${pageSize}`">
{{ pageSize }}
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</div>
<div class="flex items-center gap-1">
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canPreviousPage"
@click="goToFirstPage"
>
<ChevronsLeft class="size-4" />
</UiButton>
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canPreviousPage"
@click="goToPreviousPage"
>
<ChevronLeftIcon class="size-4" />
</UiButton>
<div class="flex items-center px-2">
<template v-if="isEditingPage">
<UiInput
v-model="jumpPage"
inputmode="numeric"
pattern="[0-9]*"
class="h-7 w-14 text-center text-sm [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
@blur="handleJumpPage"
@keydown.enter="handleJumpPage"
@keydown.escape="isEditingPage = false"
/>
<span class="ml-1 text-sm">/ {{ totalPages }}</span>
</template>
<button
v-else
class="min-w-[60px] rounded px-2 py-1 text-sm font-medium hover:bg-accent"
@click="startEditPage"
>
{{ currentPage }} / {{ totalPages }}
</button>
</div>
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canNextPage"
@click="goToNextPage"
>
<ChevronRightIcon class="size-4" />
</UiButton>
<UiButton
variant="outline"
class="size-8 p-0"
:disabled="!canNextPage"
@click="goToLastPage"
>
<ChevronsRight class="size-4" />
</UiButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,22 @@
import type { ColumnDef } from '@tanstack/vue-table'
export interface FacetedFilterOption {
label: string
value: string
icon?: Component
}
export interface ServerPagination {
page: number
pageSize: number
total: number
onPageChange: (page: number) => void
onPageSizeChange: (pageSize: number) => void
}
export interface DataTableProps<T> {
loading?: boolean
columns: ColumnDef<T, any>[]
data: T[]
serverPagination?: ServerPagination
}
@@ -0,0 +1,119 @@
import type { ColumnFiltersState, ColumnPinningState, PaginationState, SortingState, TableOptionsWithReactiveData, VisibilityState } from '@tanstack/vue-table'
import { computed, ref } from 'vue'
import { getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
import { DEFAULT_PAGE_SIZE } from '@/constants/pagination'
import { valueUpdater } from '@/lib/utils'
import type { DataTableProps } from './types'
interface ExtendedDataTableProps<T> extends DataTableProps<T> {
getSubRows?: (row: T) => T[]
}
export function generateVueTable<T>(props: ExtendedDataTableProps<T>, columns: DataTableProps<T>['columns']) {
const sorting = ref<SortingState>([])
const columnFilters = ref<ColumnFiltersState>([])
const columnVisibility = ref<VisibilityState>({})
const columnPinning = ref<ColumnPinningState>({ left: [], right: [] })
const rowSelection = ref({})
const globalFilter = ref<string>('')
const expanded = ref({})
const pagination = ref<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_PAGE_SIZE,
})
const useServerPagination = !!props.serverPagination
const pageIndex = computed(() => {
if (useServerPagination && props.serverPagination) {
return props.serverPagination.page - 1
}
return 0
})
const pageSize = computed(() => {
if (useServerPagination && props.serverPagination) {
return props.serverPagination.pageSize
}
return DEFAULT_PAGE_SIZE
})
const pageCount = computed(() => {
if (useServerPagination && props.serverPagination) {
return Math.ceil(props.serverPagination.total / props.serverPagination.pageSize)
}
return -1
})
const tableConfig: TableOptionsWithReactiveData<T> = {
get data() { return props.data },
get columns() { return columns },
state: {
get sorting() { return sorting.value },
get columnFilters() { return columnFilters.value },
get columnVisibility() { return columnVisibility.value },
get columnPinning() { return columnPinning.value },
get rowSelection() { return rowSelection.value },
get globalFilter() { return globalFilter.value },
get expanded() { return expanded.value },
get pagination() {
if (useServerPagination) {
return {
pageIndex: pageIndex.value,
pageSize: pageSize.value,
}
}
return pagination.value
},
},
enableRowSelection: true,
onSortingChange: updaterOrValue => valueUpdater(updaterOrValue, sorting),
onColumnFiltersChange: updaterOrValue => valueUpdater(updaterOrValue, columnFilters),
onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility),
onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning),
onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection),
onGlobalFilterChange: updaterOrValue => valueUpdater(updaterOrValue, globalFilter),
onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded),
onPaginationChange: updaterOrValue => valueUpdater(updaterOrValue, pagination),
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getExpandedRowModel: getExpandedRowModel(),
globalFilterFn: (row, _columnId, filterValue) => {
const search = String(filterValue).toLowerCase()
const values = Object.values(row.original as Record<string, unknown>)
return values.some((value) => {
if (value == null)
return false
if (typeof value === 'object') {
return Object.values(value as Record<string, unknown>).some(
v => v != null && String(v).toLowerCase().includes(search),
)
}
return String(value).toLowerCase().includes(search)
})
},
}
if (props.getSubRows) {
tableConfig.getSubRows = props.getSubRows
}
if (useServerPagination) {
tableConfig.pageCount = pageCount.value
tableConfig.manualPagination = true
}
else {
tableConfig.getPaginationRowModel = getPaginationRowModel()
}
const table = useVueTable<T>(tableConfig)
return table
}
@@ -0,0 +1,72 @@
<script setup lang="ts" generic="T">
import type { Table } from '@tanstack/vue-table'
import { RefreshCcw, Settings2 } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
interface DataTableViewOptionsProps {
table: Table<T>
columnLabels?: Record<string, string>
}
const props = defineProps<DataTableViewOptionsProps>()
const { t } = useI18n()
const columns = computed(() => props.table.getAllColumns()
.filter(
column =>
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
))
function resetColumnVisible() {
columns.value.forEach(column => column.toggleVisibility(true))
}
function getColumnLabel(columnId: string): string {
if (props.columnLabels && props.columnLabels[columnId]) {
try {
return t(props.columnLabels[columnId])
}
catch {
return columnId
}
}
return columnId
}
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton
variant="outline"
size="sm"
class="hidden h-8 ml-auto lg:flex"
>
<Settings2 class="size-4 mr-2" />
{{ t('common.columnsView') }}
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end" class="w-[150px]">
<UiDropdownMenuLabel>{{ t('common.toggleColumns') }}</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuCheckboxItem
v-for="column in columns"
:key="column.id"
:model-value="column.getIsVisible()"
@update:model-value="(value:boolean) => column.toggleVisibility(!!value)"
>
{{ getColumnLabel(column.id) }}
</UiDropdownMenuCheckboxItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem
@click="resetColumnVisible"
>
<RefreshCcw class="mr-2 h-4 w-4" />
{{ t('common.reset') }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
@@ -0,0 +1,155 @@
<script lang="ts" setup>
import { Boxes, Code, Database, DollarSign, FileLock, Gauge, GitBranch, Hash, Key, Megaphone, MessageSquare, Network, Plug, ScrollText, Shield, Users, Variable } from 'lucide-vue-next'
import { onMounted, reactive } from 'vue'
import NavTeam from '@/components/app-sidebar/nav-team.vue'
import TeamSwitcher from '@/components/app-sidebar/team-switcher.vue'
import NavFooter from '@/components/developer-sidebar/nav-footer.vue'
const user = reactive({
name: '开发者',
email: 'developer@example.com',
avatar: '/avatars/developer.jpg',
role: 'developer',
})
onMounted(() => {
const storedUser = localStorage.getItem('user')
if (storedUser) {
try {
const parsedUser = JSON.parse(storedUser)
user.name = parsedUser.username || user.name
user.email = parsedUser.email || user.email
user.avatar = parsedUser.avatar || user.avatar
user.role = parsedUser.role || user.role
}
catch (e) {
console.error('Failed to parse user from localStorage', e)
}
}
})
const teams = [
{
name: '开发者后台',
logo: Code,
plan: 'Developer',
},
]
const navMain = [
{
title: '主要功能',
items: [
{
title: '控制台',
url: '/developer',
icon: Gauge,
},
{
title: '应用管理',
url: '/developer/applications',
icon: Boxes,
},
{
title: '卡类管理',
url: '/developer/card-types',
icon: Hash,
},
{
title: '卡密管理',
url: '/developer/cards',
icon: Key,
},
{
title: '公告管理',
url: '/developer/announcements',
icon: Megaphone,
},
{
title: '版本管理',
url: '/developer/versions',
icon: GitBranch,
},
{
title: '用户管理',
url: '/developer/users',
icon: Users,
},
{
title: '代理管理',
url: '/developer/agents',
icon: Network,
},
],
},
{
title: '业务管理',
items: [
{
title: '财务管理',
url: '/developer/finance',
icon: DollarSign,
},
{
title: '日志记录',
url: '/developer/logs',
icon: ScrollText,
},
{
title: '工单系统',
url: '/developer/tickets',
icon: MessageSquare,
},
],
},
{
title: '高级功能',
items: [
{
title: '云端常量',
url: '/developer/cloud-constants',
icon: FileLock,
},
{
title: '云端变量',
url: '/developer/cloud-variables',
icon: Variable,
},
{
title: '云端函数',
url: '/developer/cloud-function',
icon: Code,
},
{
title: '风控管理',
url: '/developer/risk-control',
icon: Shield,
},
{
title: '扩展配置',
url: '/developer/extension',
icon: Plug,
},
],
},
]
</script>
<template>
<UiSidebar collapsible="icon" class="z-50">
<UiSidebarHeader>
<TeamSwitcher :teams="teams" />
</UiSidebarHeader>
<UiSidebarContent>
<NavTeam :nav-main="navMain" />
</UiSidebarContent>
<UiSidebarFooter>
<NavFooter :user="user" />
</UiSidebarFooter>
<UiSidebarRail />
</UiSidebar>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { ChevronsUpDown, Database, LogOut, UserRoundCog, Zap } from 'lucide-vue-next'
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import type { User } from '@/components/app-sidebar/types'
import { useSidebar } from '@/components/ui/sidebar'
const router = useRouter()
const props = defineProps<{ user: User }>()
const { isMobile, open } = useSidebar()
const subscription = ref({
plan: '基础版',
status: '正常',
apiQuota: 10000,
apiUsed: 0,
storageQuota: 100 * 1024 * 1024,
storageUsed: 0,
})
function formatStorage(bytes: number) {
if (!bytes || bytes === 0)
return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${Math.round(bytes / k ** i * 100) / 100} ${sizes[i]}`
}
function formatNumber(num: number) {
return (num || 0).toLocaleString()
}
function handleLogout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
}
</script>
<template>
<div class="space-y-2">
<UiCard v-if="open" class="border-0 bg-muted/50">
<UiCardContent class="p-3 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-2">
<Icon icon="lucide:crown" class="size-4 text-primary" />
<span class="text-xs font-medium">{{ subscription.plan }}</span>
</div>
<UiBadge
:variant="subscription.status === '正常' ? 'default' : 'secondary'"
:class="subscription.status === '正常' ? 'bg-green-500/10 text-green-500' : ''"
class="text-[10px]"
>
{{ subscription.status }}
</UiBadge>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-[10px]">
<span class="text-muted-foreground flex items-center space-x-1">
<Zap class="size-3" />
<span>API</span>
</span>
<span class="font-medium">{{ formatNumber(subscription.apiUsed) }} / {{ formatNumber(subscription.apiQuota) }}</span>
</div>
<div class="w-full bg-background rounded-full h-1">
<div
class="bg-primary h-1 rounded-full transition-all duration-300"
:style="{ width: `${(subscription.apiUsed / subscription.apiQuota) * 100}%` }"
/>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between text-[10px]">
<span class="text-muted-foreground flex items-center space-x-1">
<Database class="size-3" />
<span>存储</span>
</span>
<span class="font-medium">{{ formatStorage(subscription.storageUsed) }} / {{ formatStorage(subscription.storageQuota) }}</span>
</div>
<div class="w-full bg-background rounded-full h-1">
<div
class="bg-primary h-1 rounded-full transition-all duration-300"
:style="{ width: `${Math.min((subscription.storageUsed / subscription.storageQuota) * 100, 100)}%` }"
/>
</div>
</div>
</UiCardContent>
</UiCard>
<UiSidebarMenu>
<UiSidebarMenuItem>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiSidebarMenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<UiAvatar class="size-8 rounded-lg">
<UiAvatarImage :src="props.user.avatar" :alt="props.user.name" />
<UiAvatarFallback class="rounded-lg">
{{ props.user.name?.charAt(0)?.toUpperCase() }}
</UiAvatarFallback>
</UiAvatar>
<div class="grid flex-1 text-sm leading-tight text-left">
<span class="font-semibold truncate">{{ props.user.name }}</span>
<span class="text-xs truncate">{{ props.user.email }}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</UiSidebarMenuButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent
class="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
:side="(isMobile || open) ? 'bottom' : 'right'"
align="start"
:side-offset="4"
>
<UiDropdownMenuLabel class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UiAvatar class="size-8 rounded-lg">
<UiAvatarImage :src="props.user.avatar" :alt="props.user.name" />
<UiAvatarFallback class="rounded-lg">
{{ props.user.name?.charAt(0)?.toUpperCase() }}
</UiAvatarFallback>
</UiAvatar>
<div class="grid flex-1 text-sm leading-tight text-left">
<span class="font-semibold truncate">{{ props.user.name }}</span>
<span class="text-xs truncate">{{ props.user.email }}</span>
</div>
</div>
</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuGroup>
<UiDropdownMenuItem @click="router.push('/developer/profile')">
<UserRoundCog class="mr-2 h-4 w-4" />
个人中心
</UiDropdownMenuItem>
<UiDropdownMenuItem @click="router.push('/developer/tickets')">
<Icon icon="lucide:ticket" class="mr-2 h-4 w-4" />
工单系统
</UiDropdownMenuItem>
</UiDropdownMenuGroup>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem class="text-red-500" @click="handleLogout">
<LogOut class="mr-2 h-4 w-4" />
退出登录
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</UiSidebarMenuItem>
</UiSidebarMenu>
</div>
</template>
@@ -0,0 +1,27 @@
<script lang="ts" setup>
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb'
import { cn } from '@/lib/utils'
import type { LayoutHeaderProps } from './types'
defineProps<LayoutHeaderProps>()
</script>
<template>
<header
:class="cn(
'flex flex-col md:flex-row gap-2 justify-end py-2',
sticky ? 'sticky top-0 z-40 bg-background' : '',
)"
>
<aside class="flex items-center gap-2 flex-wrap">
<slot name="actions" />
</aside>
</header>
</template>
@@ -0,0 +1,20 @@
<script lang="ts" setup>
import { useSlots } from 'vue'
import type { LayoutHeaderProps } from './types'
const props = defineProps<LayoutHeaderProps>()
const slots = useSlots()
</script>
<template>
<div>
<div v-if="slots.actions" class="flex justify-end py-2">
<slot name="actions" />
</div>
<div class="py-4">
<slot />
</div>
</div>
</template>
@@ -0,0 +1,6 @@
export { default as BasicHeader } from './basic-header.vue'
export { default as BasicPage } from './basic-page.vue'
export { default as TwoColAside } from './two-col-aside.vue'
export { default as TwoColLayout } from './two-col.vue'
export type * from './types'
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ChevronsUpDownIcon } from 'lucide-vue-next'
import type { TwoColAsideNavItem } from './types'
const props = defineProps<{
nav: TwoColAsideNavItem[]
}>()
const route = useRoute()
const currentPath = computed(() => route.path)
const activeClass = 'text-primary font-semibold bg-primary/5'
const currentLink = computed(() => props.nav.find(link => link.url === currentPath.value))
</script>
<template>
<nav class="flex flex-col gap-2">
<router-link
v-for="link in props.nav" :key="link.url"
:to="link.url"
class="items-center hidden px-2 py-1 rounded-md lg:flex hover:bg-primary/5"
:class="link.url === currentPath ? activeClass : ''"
>
<component :is="link.icon" class="size-4 mr-1" />
<span>{{ link.title }}</span>
</router-link>
<UiDropdownMenu class="lg:hidden">
<UiDropdownMenuTrigger as-child>
<UiButton variant="outline" class="w-48 lg:hidden">
<component :is="currentLink?.icon" class="size-4 mr-1" />
<span>{{ currentLink?.title }}</span>
<ChevronsUpDownIcon class="size-4 ml-auto" />
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent class="w-48" align="start">
<UiDropdownMenuItem
v-for="link in props.nav" :key="link.url"
@click="$router.push(link.url)"
>
<component :is="link.icon" class="size-4 mr-1" />
{{ link.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</nav>
</template>
@@ -0,0 +1,19 @@
<script lang='ts' setup>
import { cn } from '@/lib/utils'
</script>
<template>
<div
:class="cn(
`grid grid-cols-1 lg:grid-cols-[200px_1fr] gap-4 w-full`,
)"
>
<aside>
<slot name="aside" />
</aside>
<section>
<slot name="default" />
</section>
</div>
</template>
@@ -0,0 +1,19 @@
import type { Component } from 'vue'
export interface BreadcrumbItem {
title: string
href?: string
}
export interface LayoutHeaderProps {
title?: string
description?: string
sticky?: boolean
breadcrumbs?: BreadcrumbItem[]
}
export interface TwoColAsideNavItem {
title: string
url: string
icon?: Component
}
@@ -0,0 +1,184 @@
<script lang="ts" setup>
import { cn } from '@/lib/utils'
interface FlickeringGridProps {
squareSize?: number
gridGap?: number
flickerChance?: number
color?: string
width?: number
height?: number
class?: string
maxOpacity?: number
}
const props = withDefaults(defineProps<FlickeringGridProps>(), {
squareSize: 4,
gridGap: 6,
flickerChance: 0.3,
color: 'rgb(0, 0, 0)',
maxOpacity: 0.3,
})
const { squareSize, gridGap, flickerChance, color, maxOpacity, width, height } = toRefs(props)
const containerRef = useTemplateRef<HTMLDivElement>('containerRef')
const canvasRef = useTemplateRef<HTMLCanvasElement>('canvasRef')
const context = ref<CanvasRenderingContext2D>()
const isInView = ref(false)
const canvasSize = ref({ width: 0, height: 0 })
const computedColor = computed(() => {
if (!context.value)
return 'rgba(255, 0, 0,'
const hex = color.value.replace(/^#/, '')
const bigint = Number.parseInt(hex, 16)
const r = (bigint >> 16) & 255
const g = (bigint >> 8) & 255
const b = bigint & 255
return `rgba(${r}, ${g}, ${b},`
})
function setupCanvas(
canvas: HTMLCanvasElement,
width: number,
height: number,
): {
cols: number
rows: number
squares: Float32Array
dpr: number
} {
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
const cols = Math.floor(width / (squareSize.value + gridGap.value))
const rows = Math.floor(height / (squareSize.value + gridGap.value))
const squares = new Float32Array(cols * rows)
for (let i = 0; i < squares.length; i++) {
squares[i] = Math.random() * maxOpacity.value
}
return { cols, rows, squares, dpr }
}
function updateSquares(squares: Float32Array, deltaTime: number) {
for (let i = 0; i < squares.length; i++) {
if (Math.random() < flickerChance.value * deltaTime) {
squares[i] = Math.random() * maxOpacity.value
}
}
}
function drawGrid(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
cols: number,
rows: number,
squares: Float32Array,
dpr: number,
) {
ctx.clearRect(0, 0, width, height)
ctx.fillStyle = 'transparent'
ctx.fillRect(0, 0, width, height)
for (let i = 0; i < cols; i++) {
for (let j = 0; j < rows; j++) {
const opacity = squares[i * rows + j]
ctx.fillStyle = `${computedColor.value}${opacity})`
ctx.fillRect(
i * (squareSize.value + gridGap.value) * dpr,
j * (squareSize.value + gridGap.value) * dpr,
squareSize.value * dpr,
squareSize.value * dpr,
)
}
}
}
const gridParams = ref<ReturnType<typeof setupCanvas>>()
function updateCanvasSize() {
const newWidth = width.value || containerRef.value!.clientWidth
const newHeight = height.value || containerRef.value!.clientHeight
canvasSize.value = { width: newWidth, height: newHeight }
gridParams.value = setupCanvas(canvasRef.value!, newWidth, newHeight)
}
let animationFrameId: number | undefined
let resizeObserver: ResizeObserver | undefined
let intersectionObserver: IntersectionObserver | undefined
let lastTime = 0
function animate(time: number) {
if (!isInView.value)
return
const deltaTime = (time - lastTime) / 1000
lastTime = time
updateSquares(gridParams.value!.squares, deltaTime)
drawGrid(
context.value!,
canvasRef.value!.width,
canvasRef.value!.height,
gridParams.value!.cols,
gridParams.value!.rows,
gridParams.value!.squares,
gridParams.value!.dpr,
)
animationFrameId = requestAnimationFrame(animate)
}
onMounted(() => {
if (!canvasRef.value || !containerRef.value)
return
context.value = canvasRef.value.getContext('2d')!
if (!context.value)
return
updateCanvasSize()
resizeObserver = new ResizeObserver(() => {
updateCanvasSize()
})
intersectionObserver = new IntersectionObserver(
([entry]) => {
isInView.value = entry.isIntersecting
animationFrameId = requestAnimationFrame(animate)
},
{ threshold: 0 },
)
resizeObserver.observe(containerRef.value)
intersectionObserver.observe(canvasRef.value)
})
onBeforeUnmount(() => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
}
resizeObserver?.disconnect()
intersectionObserver?.disconnect()
})
</script>
<template>
<div
ref="containerRef"
:class="cn('w-full h-full', props.class)"
>
<canvas
ref="canvasRef"
class="pointer-events-none"
:width="canvasSize.width"
:height="canvasSize.height"
/>
</div>
</template>
@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import { animate } from 'motion-v'
import { cn } from '@/lib/utils'
interface Props {
blur?: number
inactiveZone?: number
proximity?: number
spread?: number
variant?: 'default' | 'white'
glow?: boolean
class?: HTMLAttributes['class']
disabled?: boolean
movementDuration?: number
borderWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
blur: 0,
inactiveZone: 0.7,
proximity: 0,
spread: 20,
variant: 'default',
glow: false,
movementDuration: 2,
borderWidth: 1,
disabled: true,
})
const containerRef = useTemplateRef('containerRef')
const lastPosition = ref({
x: 0,
y: 0,
})
const animationFrame = ref(0)
const containerStyles = computed(() => {
return {
'--blur': `${props.blur}px`,
'--spread': props.spread,
'--start': '0',
'--active': '0',
'--glowingeffect-border-width': `${props.borderWidth}px`,
'--repeating-conic-gradient-times': '5',
'--gradient':
props.variant === 'white'
? `repeating-conic-gradient(
from 236.84deg at 50% 50%,
var(--black),
var(--black) calc(25% / var(--repeating-conic-gradient-times))
)`
: `radial-gradient(circle, #dd7bbb 10%, #dd7bbb00 20%),
radial-gradient(circle at 40% 40%, #d79f1e 5%, #d79f1e00 15%),
radial-gradient(circle at 60% 60%, #5a922c 10%, #5a922c00 20%),
radial-gradient(circle at 40% 60%, #4c7894 10%, #4c789400 20%),
repeating-conic-gradient(
from 236.84deg at 50% 50%,
#dd7bbb 0%,
#d79f1e calc(25% / var(--repeating-conic-gradient-times)),
#5a922c calc(50% / var(--repeating-conic-gradient-times)),
#4c7894 calc(75% / var(--repeating-conic-gradient-times)),
#dd7bbb calc(100% / var(--repeating-conic-gradient-times))
)`,
}
})
onMounted(() => {
if (props.disabled)
return
window.addEventListener('scroll', handleScroll, { passive: true })
document.body.addEventListener('pointermove', handlePointerMove, {
passive: true,
})
})
onUnmounted(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
}
window.removeEventListener('scroll', handleScroll)
document.body.removeEventListener('pointermove', handlePointerMove)
})
function handlePointerMove(e: PointerEvent) {
handleMove(e)
}
function handleScroll() {
handleMove()
}
function handleMove(e?: MouseEvent | PointerEvent | { x: number, y: number }) {
if (!containerRef.value)
return
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value)
}
animationFrame.value = requestAnimationFrame(() => {
const element = containerRef.value
if (!element)
return
const { left, top, width, height } = element.getBoundingClientRect()
const mouseX = e?.x ?? lastPosition.value.x
const mouseY = e?.y ?? lastPosition.value.y
if (e) {
lastPosition.value = { x: mouseX, y: mouseY }
}
const center = [left + width * 0.5, top + height * 0.5]
const distanceFromCenter = Math.hypot(mouseX - center[0], mouseY - center[1])
const inactiveRadius = 0.5 * Math.min(width, height) * props.inactiveZone
if (distanceFromCenter < inactiveRadius) {
element.style.setProperty('--active', '0')
return
}
const isActive
= mouseX > left - props.proximity
&& mouseX < left + width + props.proximity
&& mouseY > top - props.proximity
&& mouseY < top + height + props.proximity
element.style.setProperty('--active', isActive ? '1' : '0')
if (!isActive)
return
const currentAngle = Number.parseFloat(element.style.getPropertyValue('--start')) || 0
const targetAngle = (180 * Math.atan2(mouseY - center[1], mouseX - center[0])) / Math.PI + 90
const angleDiff = ((targetAngle - currentAngle + 180) % 360) - 180
const newAngle = currentAngle + angleDiff
animate(currentAngle, newAngle, {
duration: props.movementDuration,
ease: [0.16, 1, 0.3, 1],
onUpdate: (value) => {
element.style.setProperty('--start', String(value))
},
})
})
}
</script>
<template>
<div
:class="
cn(
'pointer-events-none absolute -inset-px hidden rounded-[inherit] border opacity-0 transition-opacity',
glow && 'opacity-100',
variant === 'white' && 'border-white',
disabled && 'block!',
)
"
/>
<div
ref="containerRef"
:style="containerStyles"
:class="
cn(
'pointer-events-none absolute inset-0 rounded-[inherit] opacity-100 transition-opacity',
glow && 'opacity-100',
blur > 0 && 'blur-(--blur)',
props.class,
disabled && 'hidden!',
)
"
>
<div
:class="
cn(
'glow',
'rounded-[inherit]',
`after:content-[''] after:rounded-[inherit] after:absolute after:inset-[calc(-1*var(--glowingeffect-border-width))]`,
'after:[border:var(--glowingeffect-border-width)_solid_transparent]',
'after:[background:var(--gradient)] after:bg-fixed',
'after:opacity-(--active) after:transition-opacity after:duration-300',
'after:[mask-clip:padding-box,border-box]',
'after:mask-intersect',
'after:mask-[linear-gradient(#0000,#0000),conic-gradient(from_calc((var(--start)-var(--spread))*1deg),#00000000_0deg,#fff,#00000000_calc(var(--spread)*2deg))]',
)
"
/>
</div>
</template>
@@ -0,0 +1,76 @@
<script lang="ts" setup>
import { cn } from '@/lib/utils'
withDefaults(
defineProps<{
class?: string
reverse?: boolean
pauseOnHover?: boolean
vertical?: boolean
repeat?: number
}>(),
{
pauseOnHover: false,
vertical: false,
repeat: 4,
},
)
</script>
<template>
<div
:class="
cn(
'group flex overflow-hidden p-2 [--duration:40s] [--gap:1rem] gap-(--gap)',
vertical ? 'flex-col' : 'flex-row',
$props.class,
)
"
>
<div
v-for="index in repeat"
:key="index"
:class="
cn(
'flex shrink-0 justify-around gap-(--gap)',
vertical ? 'animate-marquee-vertical flex-col' : 'animate-marquee flex-row',
pauseOnHover ? 'group-hover:paused' : '',
)
"
:style="{
animationDirection: reverse ? 'reverse' : 'normal',
}"
>
<slot />
</div>
</div>
</template>
<style scoped>
.animate-marquee {
animation: marquee var(--duration) linear infinite;
animation-direction: reverse;
}
.animate-marquee-vertical {
animation: marquee-vertical var(--duration) linear infinite;
}
@keyframes marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(-100% - var(--gap)));
}
}
@keyframes marquee-vertical {
from {
transform: translateY(0);
}
to {
transform: translateY(calc(-100% - var(--gap)));
}
}
</style>
@@ -0,0 +1,31 @@
<script lang="ts" setup>
interface Props {
img: string
name: string
username: string
body: string
}
defineProps<Props>()
</script>
<template>
<figure
class="relative w-64 cursor-pointer overflow-hidden rounded-xl border border-gray-950/10 bg-gray-950/1 p-4 hover:bg-gray-950/5 dark:border-gray-50/10 dark:bg-gray-50/10 dark:hover:bg-gray-50/15"
>
<div class="flex flex-row items-center gap-2">
<img :src="img" class="rounded-full" width="32" height="32" alt="">
<div class="flex flex-col">
<span class="text-sm font-medium dark:text-white">
{{ name }}
</span>
<p class="text-xs font-medium dark:text-white/40">
{{ username }}
</p>
</div>
</div>
<blockquote class="mt-2 text-sm">
{{ body }}
</blockquote>
</figure>
</template>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
interface Props {
size?: number
class?: string
opacity?: number
animationDelay?: number
borderStyle?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 210,
opacity: 0.24,
})
</script>
<template>
<div :class="cn('absolute shadow-xl', 'animate-ripple-circle', props.class)" />
</template>
<style scoped>
.animate-ripple-circle {
animation: ripple-effect var(--duration, 2s) ease-in-out calc(var(--i, 0) * 0.2s) infinite;
border-width: 1px;
top: 50%;
left: 50%;
width: v-bind('`${props.size}px`');
height: v-bind('`${props.size}px`');
animation-delay: v-bind('`${props.animationDelay}ms`');
opacity: v-bind('props.opacity');
transform: translate(-50%, -50%) scale(1);
border-style: v-bind('props.borderStyle');
}
@keyframes ripple-effect {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
}
50% {
transform: translate(-50%, -50%) scale(0.9);
}
}
</style>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import RippleCircle from './circle.vue'
interface Props {
baseCircleSize?: number
baseCircleOpacity?: number
spaceBetweenCircle?: number
circleOpacityDowngradeRatio?: number
circleClass?: string
waveSpeed?: number
numberOfCircles?: number
}
withDefaults(defineProps<Props>(), {
baseCircleSize: 210,
baseCircleOpacity: 0.24,
circleOpacityDowngradeRatio: 0.03,
waveSpeed: 80,
spaceBetweenCircle: 70,
numberOfCircles: 7,
})
</script>
<template>
<div class="absolute inset-0">
<RippleCircle
v-for="index in numberOfCircles"
:key="index"
:opacity="baseCircleOpacity - index * circleOpacityDowngradeRatio"
:size="baseCircleSize + index * spaceBetweenCircle"
:animation-delay="index * waveSpeed"
:border-style="index === numberOfCircles - 1 ? 'dashed' : 'solid'"
:class="circleClass"
/>
</div>
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import RippleCircle from './circle.vue'
interface Props {
baseCircleSize?: number
baseCircleOpacity?: number
spaceBetweenCircle?: number
circleOpacityDowngradeRatio?: number
circleClass?: string
waveSpeed?: number
numberOfCircles?: number
}
withDefaults(defineProps<Props>(), {
baseCircleSize: 210,
baseCircleOpacity: 0.24,
circleOpacityDowngradeRatio: 0.03,
waveSpeed: 80,
spaceBetweenCircle: 70,
numberOfCircles: 7,
})
</script>
<template>
<div class="absolute inset-0">
<RippleCircle
v-for="index in numberOfCircles"
:key="index"
:opacity="baseCircleOpacity - index * circleOpacityDowngradeRatio"
:size="baseCircleSize + index * spaceBetweenCircle"
:animation-delay="index * waveSpeed"
:border-style="index === numberOfCircles - 1 ? 'dashed' : 'solid'"
:class="circleClass"
/>
</div>
</template>
@@ -0,0 +1,60 @@
<script setup lang="ts">
import type { AcceptableValue } from 'reka-ui'
import { Icon } from '@iconify/vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type { Language } from '@/plugins/i18n'
import { appLocale, DEFAULT_LOCALE, SUPPORTED_LOCALES } from '@/plugins/i18n'
const { locale } = useI18n()
function setDefaultLanguage() {
locale.value = DEFAULT_LOCALE
appLocale.value = DEFAULT_LOCALE
}
function handleLocaleChange(val: AcceptableValue) {
if (typeof val !== 'string' || !SUPPORTED_LOCALES.has(val as Language)) {
setDefaultLanguage()
return
}
locale.value = val as Language
appLocale.value = val as Language
}
const currentFlag = computed(() => {
return locale.value === 'zh' ? 'flag:cn-4x3' : 'flag:us-4x3'
})
</script>
<template>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton variant="ghost" size="icon" class="h-9 w-9">
<Icon :icon="currentFlag" class="h-5 w-5" />
<span class="sr-only">切换语言</span>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end">
<UiDropdownMenuLabel>{{ $t('changeLanguage') }}</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuRadioGroup
v-model="locale"
@update:model-value="handleLocaleChange"
>
<UiDropdownMenuRadioItem value="en">
<Icon icon="flag:us-4x3" />
<span class="ml-2">English</span>
</UiDropdownMenuRadioItem>
<UiDropdownMenuRadioItem value="zh">
<Icon icon="flag:cn-4x3" />
<span class="ml-2">中文</span>
</UiDropdownMenuRadioItem>
</UiDropdownMenuRadioGroup>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
+3
View File
@@ -0,0 +1,3 @@
<template>
<UiSpinner class="w-24 h-24 animate-spin" />
</template>
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useColorMode } from '@vueuse/core'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const mode = useColorMode()
const links = computed(() => [
{
name: t('nav.docs'),
url: '/docs',
},
{
name: t('nav.pricing'),
url: '/pricing',
},
{
name: t('footer.aboutUs'),
url: '#',
},
{
name: t('footer.contactUs'),
url: '#',
},
])
</script>
<template>
<footer class="border-t bg-muted/30 py-8">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<div class="flex items-center space-x-2 mb-4">
<UiAvatar class="h-8 w-8">
<UiAvatarImage :src="`${mode === 'dark' ? '/logo.svg' : '/logo-black.svg'}`" alt="Logo" />
</UiAvatar>
<span class="font-bold">{{ t('footer.brand') }}</span>
</div>
<p class="text-sm text-muted-foreground">
{{ t('footer.description') }}
</p>
</div>
<div>
<h3 class="font-semibold mb-4">
{{ t('footer.quickLinks') }}
</h3>
<ul class="space-y-2">
<li v-for="link in links" :key="link.name">
<router-link
:to="link.url"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{{ link.name }}
</router-link>
</li>
</ul>
</div>
<div>
<h3 class="font-semibold mb-4">
{{ t('footer.contactUs') }}
</h3>
<ul class="space-y-2 text-sm text-muted-foreground">
<li class="flex items-center">
<Icon icon="lucide:mail" class="h-4 w-4 mr-2" />
admin@weishouquan.com
</li>
<li class="flex items-center">
<Icon icon="lucide:send" class="h-4 w-4 mr-2" />
weishouquan
</li>
</ul>
</div>
</div>
<div class="mt-8 pt-8 border-t flex flex-col items-center">
<p class="text-sm text-muted-foreground text-center">
© {{ new Date().getFullYear() }} {{ t('footer.brand') }}
</p>
<div class="flex items-center space-x-4 mt-4">
<router-link
to="/privacy"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{{ t('footer.privacy') }}
</router-link>
<router-link
to="/terms"
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{{ t('footer.terms') }}
</router-link>
</div>
</div>
</div>
</footer>
</template>
@@ -0,0 +1,643 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue'
import { useColorMode } from '@vueuse/core'
import { computed, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import ThemePopover from '@/components/custom-theme/theme-popover.vue'
import LanguageChange from '@/components/language-change.vue'
const { t } = useI18n()
const mode = useColorMode()
const route = useRoute()
const router = useRouter()
const isLoggedIn = ref(false)
const currentUser = ref<any>(null)
const mobileMenuOpen = ref(false)
function checkLoginStatus() {
isLoggedIn.value = !!localStorage.getItem('token')
}
const siteSettings = ref({
siteName: window.__SITE_CONFIG__?.siteName || '',
logoLight: window.__SITE_CONFIG__?.logoLight || '',
logoDark: window.__SITE_CONFIG__?.logoDark || '',
favicon: window.__SITE_CONFIG__?.favicon || '',
})
const siteName = computed(() => siteSettings.value.siteName)
const userAvatar = computed(() => {
const avatar = currentUser.value?.avatar
if (!avatar)
return ''
if (avatar.startsWith('http'))
return avatar
return `${import.meta.env.VITE_API_BASE || 'http://localhost:8080'}${avatar}`
})
function getAssetUrl(path: string) {
if (!path)
return ''
if (path.startsWith('http'))
return path
return `${import.meta.env.VITE_API_BASE || 'http://localhost:8080'}${path}`
}
const logoSrc = computed(() => {
if (mode.value === 'dark') {
if (siteSettings.value.logoDark) {
return getAssetUrl(siteSettings.value.logoDark)
}
return '/logo.svg'
}
else {
if (siteSettings.value.logoLight) {
return getAssetUrl(siteSettings.value.logoLight)
}
return '/logo-black.svg'
}
})
const developerMenus = computed(() => ({
main: {
title: t('nav.mainFeatures'),
items: [
{ title: t('nav.console'), url: '/developer', icon: 'lucide:gauge' },
{ title: t('nav.applications'), url: '/developer/applications', icon: 'lucide:boxes' },
{ title: t('nav.cardTypes'), url: '/developer/card-types', icon: 'lucide:hash' },
{ title: t('nav.cards'), url: '/developer/cards', icon: 'lucide:key' },
{ title: t('nav.announcements'), url: '/developer/announcements', icon: 'lucide:megaphone' },
{ title: t('nav.versions'), url: '/developer/versions', icon: 'lucide:git-branch' },
{ title: t('nav.users'), url: '/developer/users', icon: 'lucide:users' },
{ title: t('nav.devices'), url: '/developer/devices', icon: 'lucide:monitor' },
{ title: t('nav.sessions'), url: '/developer/sessions', icon: 'lucide:wifi' },
{ title: t('nav.agentApps'), url: '/developer/agent-apps', icon: 'lucide:share-2' },
],
},
business: {
title: t('nav.businessManagement'),
items: [
{ title: t('nav.finance'), url: '/developer/finance', icon: 'lucide:dollar-sign' },
{ title: t('nav.logs'), url: '/developer/logs', icon: 'lucide:scroll-text' },
{ title: t('nav.tickets'), url: '/developer/tickets', icon: 'lucide:message-square' },
],
},
advanced: {
title: t('nav.advancedFeatures'),
items: [
{ title: t('nav.cloudConstants'), url: '/developer/cloud-constants', icon: 'lucide:file-lock' },
{ title: t('nav.cloudVariables'), url: '/developer/cloud-variables', icon: 'lucide:variable' },
{ title: t('nav.cloudFunction'), url: '/developer/cloud-function', icon: 'lucide:code' },
{ title: t('nav.riskControl'), url: '/developer/risk-control', icon: 'lucide:shield' },
{ title: t('nav.extension'), url: '/developer/extension', icon: 'lucide:plug' },
],
},
}))
const adminMenus = computed(() => ({
main: {
title: t('nav.mainFeatures'),
items: [
{ title: t('nav.console'), url: '/admin', icon: 'lucide:gauge' },
{ title: t('nav.users'), url: '/admin/users', icon: 'lucide:users' },
{ title: t('nav.orders'), url: '/admin/orders', icon: 'lucide:dollar-sign' },
{ title: t('nav.statistics'), url: '/admin/statistics', icon: 'lucide:bar-chart-3' },
],
},
content: {
title: t('nav.contentManagement'),
items: [
{ title: t('nav.docCategories'), url: '/admin/doc-categories', icon: 'lucide:folder-open' },
{ title: t('nav.docList'), url: '/admin/docs', icon: 'lucide:file-text' },
{ title: t('nav.packages'), url: '/admin/packages', icon: 'lucide:package' },
],
},
system: {
title: t('nav.systemManagement'),
items: [
{ title: t('nav.tickets'), url: '/admin/tickets', icon: 'lucide:message-square' },
{ title: t('nav.logs'), url: '/admin/logs', icon: 'lucide:scroll-text' },
{ title: t('nav.basicSettings'), url: '/admin/settings/basic', icon: 'lucide:settings' },
{ title: t('nav.emailSettings'), url: '/admin/settings/email', icon: 'lucide:mail' },
{ title: t('nav.smsSettings'), url: '/admin/settings/sms', icon: 'lucide:message-square' },
{ title: t('nav.paymentSettings'), url: '/admin/settings/payment', icon: 'lucide:credit-card' },
],
},
}))
const publicMenus = computed(() => [
{ title: t('nav.home'), url: '/', icon: 'lucide:home' },
{ title: t('nav.docs'), url: '/docs', icon: 'lucide:book-open' },
{ title: t('nav.pricing'), url: '/pricing', icon: 'lucide:credit-card' },
])
function isMenuActive(items: { url: string }[]) {
return items.some(item => route.path === item.url)
}
function isActive(url: string) {
return route.path === url
}
function navigateTo(url: string) {
router.push(url)
mobileMenuOpen.value = false
}
function updateFavicon() {
if (siteSettings.value.favicon) {
const link: HTMLLinkElement = document.querySelector('link[rel*=\'icon\']') || document.createElement('link')
link.type = 'image/x-icon'
link.rel = 'shortcut icon'
link.href = getAssetUrl(siteSettings.value.favicon)
document.getElementsByTagName('head')[0].appendChild(link)
}
}
function handleLogout() {
localStorage.removeItem('token')
localStorage.removeItem('user')
currentUser.value = null
isLoggedIn.value = false
mobileMenuOpen.value = false
router.push('/')
}
onMounted(() => {
checkLoginStatus()
const userStr = localStorage.getItem('user')
if (userStr) {
try {
currentUser.value = JSON.parse(userStr)
}
catch (error) {
console.error('Failed to parse user data:', error)
}
}
if (siteSettings.value.favicon) {
updateFavicon()
}
if (siteSettings.value.siteName) {
document.title = siteSettings.value.siteName
}
})
watch(mode, () => {
updateFavicon()
})
watch(route, () => {
mobileMenuOpen.value = false
})
</script>
<template>
<header class="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div class="mx-auto max-w-6xl flex h-14 items-center px-4 sm:px-6 lg:px-8">
<div class="mr-4 hidden md:flex items-center">
<router-link to="/" class="mr-4 flex items-center space-x-2">
<UiAvatar class="h-7 w-7">
<UiAvatarImage :src="logoSrc" alt="Logo" />
</UiAvatar>
<span class="hidden font-bold sm:inline-block">{{ siteName }}</span>
</router-link>
<nav class="flex items-center space-x-1 text-sm font-medium">
<router-link
to="/"
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg"
:class="route.name === 'Home' ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ t('nav.home') }}
</router-link>
<router-link
to="/docs"
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg"
:class="route.name === 'Docs' ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ t('nav.docs') }}
</router-link>
<router-link
to="/pricing"
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg"
:class="route.name === 'Pricing' ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ t('nav.pricing') }}
</router-link>
<template v-if="isLoggedIn && currentUser?.role === 'developer'">
<div class="w-px h-4 bg-border mx-1" />
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(developerMenus.main.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ developerMenus.main.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in developerMenus.main.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
>
<Icon :icon="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(developerMenus.business.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ developerMenus.business.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in developerMenus.business.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
>
<Icon :icon="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(developerMenus.advanced.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ developerMenus.advanced.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in developerMenus.advanced.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
>
<Icon :icon="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
<template v-if="isLoggedIn && currentUser?.role === 'admin'">
<div class="w-px h-4 bg-border mx-1" />
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(adminMenus.main.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ adminMenus.main.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in adminMenus.main.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
>
<Icon :icon="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(adminMenus.content.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ adminMenus.content.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in adminMenus.content.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
>
<Icon :icon="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<button
class="transition-colors hover:text-foreground/80 px-3 py-2 rounded-lg flex items-center gap-1"
:class="isMenuActive(adminMenus.system.items) ? 'text-foreground bg-accent' : 'text-foreground/60'"
>
{{ adminMenus.system.title }}
<Icon icon="lucide:chevron-down" class="h-4 w-4" />
</button>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="start" class="w-40">
<UiDropdownMenuItem
v-for="item in adminMenus.system.items"
:key="item.url"
:class="route.path === item.url ? 'bg-accent' : ''"
@click="router.push(item.url)"
>
<Icon :icon="item.icon" class="mr-2 h-4 w-4" />
{{ item.title }}
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
</nav>
</div>
<button
class="md:hidden mr-2 flex items-center justify-center size-9 rounded-md hover:bg-accent"
@click="mobileMenuOpen = true"
>
<Icon icon="lucide:menu" class="size-5" />
</button>
<router-link to="/" class="mr-6 flex items-center space-x-2 md:hidden">
<UiAvatar class="h-7 w-7">
<UiAvatarImage :src="logoSrc" alt="Logo" />
</UiAvatar>
<span class="font-bold">{{ siteName }}</span>
</router-link>
<div class="flex flex-1 items-center justify-end space-x-2">
<LanguageChange />
<UiButton
variant="ghost"
size="icon"
class="h-9 w-9"
@click="mode = mode === 'dark' ? 'light' : 'dark'"
>
<Icon
icon="lucide:sun"
class="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<Icon
icon="lucide:moon"
class="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<span class="sr-only">切换主题</span>
</UiButton>
<ThemePopover />
<template v-if="isLoggedIn">
<UiDropdownMenu>
<UiDropdownMenuTrigger as-child>
<UiButton variant="ghost" class="relative h-9 w-9 rounded-full">
<UiAvatar class="h-9 w-9">
<UiAvatarImage :src="userAvatar" alt="用户头像" />
<UiAvatarFallback>{{ currentUser?.username?.charAt(0).toUpperCase() || 'U' }}</UiAvatarFallback>
</UiAvatar>
</UiButton>
</UiDropdownMenuTrigger>
<UiDropdownMenuContent align="end" class="w-48">
<UiDropdownMenuLabel>
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">
{{ currentUser?.username || '用户' }}
</p>
<p class="text-xs leading-none text-muted-foreground">
{{ currentUser?.email || '' }}
</p>
</div>
</UiDropdownMenuLabel>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem @click="router.push('/profile')">
<Icon icon="lucide:user" class="mr-2 h-4 w-4" />
<span>{{ t('nav.profile') }}</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem v-if="currentUser?.role === 'developer'" @click="router.push('/developer')">
<Icon icon="lucide:layout-dashboard" class="mr-2 h-4 w-4" />
<span>{{ t('nav.developerDashboard') }}</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem v-if="currentUser?.role === 'agent'" @click="router.push('/agent/dashboard')">
<Icon icon="lucide:layout-dashboard" class="mr-2 h-4 w-4" />
<span>{{ t('nav.agentDashboard') }}</span>
</UiDropdownMenuItem>
<UiDropdownMenuItem v-if="currentUser?.role === 'admin'" @click="router.push('/admin')">
<Icon icon="lucide:shield" class="mr-2 h-4 w-4" />
<span>{{ t('nav.adminDashboard') }}</span>
</UiDropdownMenuItem>
<UiDropdownMenuSeparator />
<UiDropdownMenuItem @click="handleLogout">
<Icon icon="lucide:log-out" class="mr-2 h-4 w-4" />
<span>{{ t('nav.logout') }}</span>
</UiDropdownMenuItem>
</UiDropdownMenuContent>
</UiDropdownMenu>
</template>
<template v-else>
<UiButton variant="ghost" size="sm" as-child>
<router-link to="/login">
{{ t('nav.login') }}
</router-link>
</UiButton>
<UiButton size="sm" as-child class="hidden sm:flex">
<router-link to="/register">
{{ t('nav.register') }}
</router-link>
</UiButton>
</template>
</div>
</div>
<UiSheet v-model:open="mobileMenuOpen">
<UiSheetContent side="left" class="w-72 overflow-y-auto">
<div class="flex flex-col h-full">
<div class="flex items-center justify-between mb-6">
<div class="flex items-center space-x-2">
<UiAvatar class="h-7 w-7">
<UiAvatarImage :src="logoSrc" alt="Logo" />
</UiAvatar>
<span class="font-bold">{{ siteName }}</span>
</div>
</div>
<nav class="flex-1 space-y-6">
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ t('nav.navigation') }}
</p>
<button
v-for="item in publicMenus"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
<template v-if="isLoggedIn && currentUser?.role === 'developer'">
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ developerMenus.main.title }}
</p>
<button
v-for="item in developerMenus.main.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ developerMenus.business.title }}
</p>
<button
v-for="item in developerMenus.business.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ developerMenus.advanced.title }}
</p>
<button
v-for="item in developerMenus.advanced.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
</template>
<template v-if="isLoggedIn && currentUser?.role === 'admin'">
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ adminMenus.main.title }}
</p>
<button
v-for="item in adminMenus.main.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ adminMenus.content.title }}
</p>
<button
v-for="item in adminMenus.content.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground px-3 mb-2">
{{ adminMenus.system.title }}
</p>
<button
v-for="item in adminMenus.system.items"
:key="item.url"
class="w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors"
:class="isActive(item.url) ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent hover:text-foreground'"
@click="navigateTo(item.url)"
>
<Icon :icon="item.icon" class="size-4" />
{{ item.title }}
</button>
</div>
</template>
</nav>
<div v-if="!isLoggedIn" class="pt-4 border-t mt-4 space-y-2">
<UiButton variant="outline" class="w-full" as-child @click="mobileMenuOpen = false">
<router-link to="/login">
{{ t('nav.login') }}
</router-link>
</UiButton>
<UiButton class="w-full" as-child @click="mobileMenuOpen = false">
<router-link to="/register">
{{ t('nav.register') }}
</router-link>
</UiButton>
</div>
<div v-else class="pt-4 border-t mt-4">
<div class="flex items-center gap-3 px-3 py-2">
<UiAvatar class="h-9 w-9">
<UiAvatarImage :src="userAvatar" alt="用户头像" />
<UiAvatarFallback>{{ currentUser?.username?.charAt(0).toUpperCase() || 'U' }}</UiAvatarFallback>
</UiAvatar>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">
{{ currentUser?.username || '用户' }}
</p>
<p class="text-xs text-muted-foreground truncate">
{{ currentUser?.email || '' }}
</p>
</div>
</div>
<UiButton variant="outline" class="w-full mt-2" @click="handleLogout">
<Icon icon="lucide:log-out" class="mr-2 h-4 w-4" />
{{ t('nav.logout') }}
</UiButton>
</div>
</div>
</UiSheetContent>
</UiSheet>
</header>
</template>
@@ -0,0 +1,22 @@
<script lang='ts' setup>
import { FolderOpenIcon } from 'lucide-vue-next'
import { useI18n } from 'vue-i18n'
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
const { t } = useI18n()
</script>
<template>
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<FolderOpenIcon />
</EmptyMedia>
<EmptyTitle>{{ t('common.noResults') }}</EmptyTitle>
<EmptyDescription>
{{ t('common.noResultsDesc') }}
</EmptyDescription>
</EmptyHeader>
</Empty>
</template>
@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { DialogRootProps } from 'reka-ui'
import type { DrawerRootProps } from 'vaul-vue'
import { useForwardPropsEmits } from 'reka-ui'
import { useModal } from './use-modal'
type Props = DrawerRootProps | DialogRootProps
const props = defineProps<Props>()
const emits = defineEmits<{
'update:open': [value: boolean]
}>()
const forwarded = useForwardPropsEmits(props, emits)
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Root"
v-bind="forwarded"
>
<slot />
</component>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DialogCloseProps } from 'reka-ui'
import type { DrawerCloseProps } from 'vaul-vue'
import { useModal } from './use-modal'
type Props = DrawerCloseProps | DialogCloseProps
const props = defineProps<Props>()
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Close"
v-bind="props"
>
<slot />
</component>
</template>
@@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { DialogContentEmits, DialogContentProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { useModal } from './use-modal'
const props = defineProps<DialogContentProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<DialogContentEmits>()
const { Modal, contentClass } = useModal()
const forwarded = useForwardPropsEmits(props, emits)
const mergedClass = computed(() => cn(contentClass.value, props.class))
</script>
<template>
<component
:is="Modal.Content"
v-bind="forwarded"
:class="mergedClass"
>
<slot />
</component>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { DialogDescriptionProps } from 'reka-ui'
import type { DrawerDescriptionProps } from 'vaul-vue'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { useModal } from './use-modal'
type Props = (DrawerDescriptionProps | DialogDescriptionProps) & { class?: HTMLAttributes['class'] }
const props = defineProps<Props>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Description"
v-bind="forwardedProps"
:class="props.class"
>
<slot />
</component>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { useModal } from './use-modal'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Footer"
v-bind="props"
>
<slot />
</component>
</template>
@@ -0,0 +1,22 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { useModal } from './use-modal'
interface Props {
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Header"
v-bind="props"
>
<slot />
</component>
</template>
@@ -0,0 +1,30 @@
<script lang="ts" setup>
import type { DialogTitleProps } from 'reka-ui'
import type { DrawerTitleProps } from 'vaul-vue'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { useForwardProps } from 'reka-ui'
import { useModal } from './use-modal'
type Props = (DialogTitleProps | DrawerTitleProps) & { class?: HTMLAttributes['class'] }
const props = defineProps<Props>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Title"
v-bind="forwardedProps"
:class="props.class"
>
<slot />
</component>
</template>
@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { DialogTriggerProps } from 'reka-ui'
import type { DrawerTriggerProps } from 'vaul-vue'
import { useModal } from './use-modal'
type Props = DialogTriggerProps | DrawerTriggerProps
const props = defineProps<Props>()
const { Modal } = useModal()
</script>
<template>
<component
:is="Modal.Trigger"
v-bind="props"
>
<slot />
</component>
</template>
@@ -0,0 +1,10 @@
export { default as Modal } from './Modal.vue'
export { default as ModalClose } from './ModalClose.vue'
export { default as ModalContent } from './ModalContent.vue'
export { default as ModalDescription } from './ModalDescription.vue'
export { default as ModalFooter } from './ModalFooter.vue'
export { default as ModalHeader } from './ModalHeader.vue'
export { default as ModalTitle } from './ModalTitle.vue'
export { default as ModalTrigger } from './ModalTrigger.vue'
export * from './use-modal'
@@ -0,0 +1,31 @@
import { createSharedComposable, useMediaQuery } from '@vueuse/core'
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/ui/drawer'
const useSharedModal = createSharedComposable(() => {
const isDesktop = useMediaQuery('(min-width: 768px)')
const Modal = computed(() => ({
Root: isDesktop.value ? Dialog : Drawer,
Trigger: isDesktop.value ? DialogTrigger : DrawerTrigger,
Content: isDesktop.value ? DialogContent : DrawerContent,
Header: isDesktop.value ? DialogHeader : DrawerHeader,
Title: isDesktop.value ? DialogTitle : DrawerTitle,
Description: isDesktop.value ? DialogDescription : DrawerDescription,
Footer: isDesktop.value ? DialogFooter : DrawerFooter,
Close: isDesktop.value ? DialogClose : DrawerClose,
}))
const contentClass = computed(() => (isDesktop.value ? '' : 'px-2 pb-8 *:px-4'))
return {
isDesktop,
Modal,
contentClass,
}
})
export function useModal() {
return useSharedModal()
}
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
const router = useRouter()
function handleSignIn() {
router.push('/auth/login')
}
</script>
<template>
<Button variant="outline" @click="handleSignIn">
{{ $t('auth.signIn') }}
</Button>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
import { Button } from '@/components/ui/button'
const router = useRouter()
function handleSignUp() {
router.push('/auth/register')
}
</script>
<template>
<Button variant="default" @click="handleSignUp">
{{ $t('auth.signUp') }}
</Button>
</template>
@@ -0,0 +1,38 @@
<script setup lang="ts">
import { ArrowDownAZ, ArrowDownZA, SlidersHorizontal } from 'lucide-vue-next'
import type { TSort } from './types'
const emits = defineEmits<{
(e: 'update:sort', payload: TSort): void
}>()
const sort = defineModel<TSort>({ default: 'asc' })
watch(sort, (newValue) => {
emits('update:sort', newValue!)
})
</script>
<template>
<UiSelect v-model:model-value="sort">
<UiSelectTrigger class="w-16">
<UiSelectValue>
<SlidersHorizontal :size="16" />
</UiSelectValue>
</UiSelectTrigger>
<UiSelectContent align="end">
<UiSelectItem value="asc">
<div class="flex items-center gap-4">
<ArrowDownAZ :size="16" />
<span>Ascending</span>
</div>
</UiSelectItem>
<UiSelectItem value="desc">
<div class="flex items-center gap-4">
<ArrowDownZA :size="16" />
<span>Descending</span>
</div>
</UiSelectItem>
</UiSelectContent>
</UiSelect>
</template>
@@ -0,0 +1 @@
export type TSort = 'asc' | 'desc'
@@ -0,0 +1,72 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { useClipboard } from '@vueuse/core'
import { Copy, CopyCheck } from 'lucide-vue-next'
import type { ButtonVariants } from '@/components/ui/button'
import { Button } from '@/components/ui/button'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import { copyVariants } from '.'
interface Props {
content: string
size?: 'sm' | 'default'
variant?: ButtonVariants['variant']
class?: HTMLAttributes['class']
copyTooltipText?: string
copiedTooltipText?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 'default',
variant: 'outline',
copyTooltipText: 'Copy',
copiedTooltipText: 'Copied',
})
const iconSize = computed(() => {
return props.size === 'sm' ? 'sm' : 'default'
})
const size = computed(() => {
return props.size === 'sm' ? 'sm' : 'icon'
})
const source = computed(() => props.content)
const { copy, copied, isSupported } = useClipboard({ source })
</script>
<template>
<span v-if="isSupported">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
:variant="props.variant"
:size="size"
:class="cn(props.class)"
@click="copy(source)"
>
<Copy v-if="!copied" :class="cn(copyVariants({ iconSize }))" />
<CopyCheck v-else :class="cn(copyVariants({ iconSize }))" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p v-if="!copied">{{ props.copyTooltipText }}: {{ props.content }}</p>
<p v-else>{{ props.copiedTooltipText }}: {{ props.content }}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
<span v-else>Your browser does not support Clipboard API</span>
</template>
@@ -0,0 +1,22 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Copy } from './Copy.vue'
export const copyVariants = cva(
'',
{
variants: {
iconSize: {
default: 'size-4',
sm: 'size-3',
},
},
defaultVariants: {
iconSize: 'default',
},
},
)
export type CopyVariants = VariantProps<typeof copyVariants>
@@ -0,0 +1,39 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { InlineTipVariants } from '.'
import { inlineTipVariants } from '.'
interface Props {
label: string
variant?: InlineTipVariants['variant']
class?: HTMLAttributes['class']
}
const props = withDefaults(defineProps<Props>(), {
variant: 'info',
})
</script>
<template>
<div
:class="cn(
'bg-secondary text-secondary-foreground text-sm inline-grid grid-cols-[4px_1fr] items-start gap-3 rounded-md border p-3',
props.class,
)"
>
<div
:class="cn(
'h-full w-1 rounded-full',
inlineTipVariants({ variant: props.variant }))"
/>
<div class="text-muted-foreground">
<strong class="text-sm font-semibold text-foreground mr-2">{{ props.label }}:</strong>
<slot />
</div>
</div>
</template>
@@ -0,0 +1,24 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as InlineTip } from './InlineTip.vue'
export const inlineTipVariants = cva(
'',
{
variants: {
variant: {
info: 'bg-stone-400 dark:bg-stone-600',
warning: 'bg-yellow-400 dark:bg-yellow-600',
success: 'bg-green-400 dark:bg-green-600',
error: 'bg-rose-400 dark:bg-rose-600',
},
},
defaultVariants: {
variant: 'info',
},
},
)
export type InlineTipVariants = VariantProps<typeof inlineTipVariants>
@@ -0,0 +1,33 @@
<script lang="ts" setup>
import type { PrimitiveProps } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
import type { StatusVariants } from '.'
import { statusVariants } from '.'
interface Props extends PrimitiveProps {
color?: StatusVariants['color']
rounded?: StatusVariants['rounded']
class?: HTMLAttributes['class']
}
const props = defineProps<Props>()
</script>
<template>
<span
:class="cn(
'mr-1',
statusVariants({ color, rounded }),
props.class,
)"
>
<span
:class="cn(statusVariants({ color, rounded }), 'absolute inline-flex h-full w-full animate-ping opacity-75')"
/>
<span :class="cn(statusVariants({ color, rounded }), props.class, 'relative inline-flex')" />
</span>
</template>
@@ -0,0 +1,26 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import type { BadgeVariants } from '@/components/ui/badge'
import { badgeVariants } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import type { StatusVariants } from '.'
import Status from './Status.vue'
const props = defineProps<{
variant?: BadgeVariants['variant']
rounded?: StatusVariants['rounded']
class?: HTMLAttributes['class']
color?: StatusVariants['color']
}>()
</script>
<template>
<div :class="cn(badgeVariants({ variant }), props.class)">
<Status :color="props.color" :rounded="props.rounded" />
<slot />
</div>
</template>
@@ -0,0 +1,35 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export { default as Status } from './Status.vue'
export { default as StatusBadge } from './StatusBadge.vue'
export const statusVariants = cva(
'relative flex size-2',
{
variants: {
rounded: {
default: 'rounded-full',
xs: 'rounded-xs',
},
color: {
green: 'bg-green-500',
red: 'bg-rose-500',
blue: 'bg-blue-500',
orange: 'bg-orange-500',
purple: 'bg-purple-500',
gray: 'bg-gray-300',
},
size: {
},
},
defaultVariants: {
color: 'green',
rounded: 'default',
},
},
)
export type StatusVariants = VariantProps<typeof statusVariants>
+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue'
import { useColorMode } from '@vueuse/core'
const mode = useColorMode()
</script>
<template>
<UiButton
variant="ghost"
size="icon"
class="h-9 w-9"
@click="mode = mode === 'dark' ? 'light' : 'dark'"
>
<Icon
icon="lucide:sun"
class="h-5 w-5 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0"
/>
<Icon
icon="lucide:moon"
class="absolute h-5 w-5 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100"
/>
<span class="sr-only">切换主题</span>
</UiButton>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AccordionRootEmits, AccordionRootProps } from "reka-ui"
import {
AccordionRoot,
useForwardPropsEmits,
} from "reka-ui"
const props = defineProps<AccordionRootProps>()
const emits = defineEmits<AccordionRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AccordionRoot v-slot="slotProps" data-slot="accordion" v-bind="forwarded">
<slot v-bind="slotProps" />
</AccordionRoot>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AccordionContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionContent } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionContentProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionContent
data-slot="accordion-content"
v-bind="delegatedProps"
class="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
>
<div :class="cn('pt-0 pb-4', props.class)">
<slot />
</div>
</AccordionContent>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { AccordionItemProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AccordionItem, useForwardProps } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionItemProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<AccordionItem
v-slot="slotProps"
data-slot="accordion-item"
v-bind="forwardedProps"
:class="cn('border-b last:border-b-0', props.class)"
>
<slot v-bind="slotProps" />
</AccordionItem>
</template>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { AccordionTriggerProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { ChevronDown } from "lucide-vue-next"
import {
AccordionHeader,
AccordionTrigger,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AccordionTriggerProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
data-slot="accordion-trigger"
v-bind="delegatedProps"
:class="
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>
@@ -0,0 +1,4 @@
export { default as Accordion } from "./Accordion.vue"
export { default as AccordionContent } from "./AccordionContent.vue"
export { default as AccordionItem } from "./AccordionItem.vue"
export { default as AccordionTrigger } from "./AccordionTrigger.vue"
@@ -0,0 +1,15 @@
<script setup lang="ts">
import type { AlertDialogEmits, AlertDialogProps } from "reka-ui"
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<AlertDialogProps>()
const emits = defineEmits<AlertDialogEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<AlertDialogRoot v-slot="slotProps" data-slot="alert-dialog" v-bind="forwarded">
<slot v-bind="slotProps" />
</AlertDialogRoot>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { AlertDialogActionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogAction } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogActionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogAction v-bind="delegatedProps" :class="cn(buttonVariants(), props.class)">
<slot />
</AlertDialogAction>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { AlertDialogCancelProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogCancel } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<AlertDialogCancelProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="cn(
buttonVariants({ variant: 'outline' }),
'mt-2 sm:mt-0',
props.class,
)"
>
<slot />
</AlertDialogCancel>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { AlertDialogContentEmits, AlertDialogContentProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui"
import { cn } from "@/lib/utils"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<AlertDialogContentProps & { class?: HTMLAttributes["class"] }>()
const emits = defineEmits<AlertDialogContentEmits>()
const delegatedProps = reactiveOmit(props, "class")
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>
@@ -0,0 +1,23 @@
<script setup lang="ts">
import type { AlertDialogDescriptionProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import {
AlertDialogDescription,
} from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogDescription
data-slot="alert-dialog-description"
v-bind="delegatedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</AlertDialogDescription>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-dialog-footer"
:class="
cn(
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-dialog-header"
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AlertDialogTitleProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AlertDialogTitle } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AlertDialogTitleProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AlertDialogTitle
data-slot="alert-dialog-title"
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>
@@ -0,0 +1,12 @@
<script setup lang="ts">
import type { AlertDialogTriggerProps } from "reka-ui"
import { AlertDialogTrigger } from "reka-ui"
const props = defineProps<AlertDialogTriggerProps>()
</script>
<template>
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
<slot />
</AlertDialogTrigger>
</template>
@@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue"
export { default as AlertDialogAction } from "./AlertDialogAction.vue"
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue"
export { default as AlertDialogContent } from "./AlertDialogContent.vue"
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue"
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue"
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue"
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue"
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue"
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import type { AlertVariants } from "."
import { cn } from "@/lib/utils"
import { alertVariants } from "."
const props = defineProps<{
class?: HTMLAttributes["class"]
variant?: AlertVariants["variant"]
}>()
</script>
<template>
<div
data-slot="alert"
:class="cn(alertVariants({ variant }), props.class)"
role="alert"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-description"
:class="cn('text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed', props.class)"
>
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<div
data-slot="alert-title"
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
>
<slot />
</div>
</template>
+24
View File
@@ -0,0 +1,24 @@
import type { VariantProps } from "class-variance-authority"
import { cva } from "class-variance-authority"
export { default as Alert } from "./Alert.vue"
export { default as AlertDescription } from "./AlertDescription.vue"
export { default as AlertTitle } from "./AlertTitle.vue"
export const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
},
)
export type AlertVariants = VariantProps<typeof alertVariants>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AspectRatioProps } from "reka-ui"
import { AspectRatio } from "reka-ui"
const props = defineProps<AspectRatioProps>()
</script>
<template>
<AspectRatio
v-slot="slotProps"
data-slot="aspect-ratio"
v-bind="props"
>
<slot v-bind="slotProps" />
</AspectRatio>
</template>
@@ -0,0 +1 @@
export { default as AspectRatio } from "./AspectRatio.vue"
@@ -0,0 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { AvatarRoot } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<{
class?: HTMLAttributes["class"]
}>()
</script>
<template>
<AvatarRoot
data-slot="avatar"
:class="cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', props.class)"
>
<slot />
</AvatarRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import type { AvatarFallbackProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import { reactiveOmit } from "@vueuse/core"
import { AvatarFallback } from "reka-ui"
import { cn } from "@/lib/utils"
const props = defineProps<AvatarFallbackProps & { class?: HTMLAttributes["class"] }>()
const delegatedProps = reactiveOmit(props, "class")
</script>
<template>
<AvatarFallback
data-slot="avatar-fallback"
v-bind="delegatedProps"
:class="cn('bg-muted flex size-full items-center justify-center rounded-full', props.class)"
>
<slot />
</AvatarFallback>
</template>
@@ -0,0 +1,16 @@
<script setup lang="ts">
import type { AvatarImageProps } from "reka-ui"
import { AvatarImage } from "reka-ui"
const props = defineProps<AvatarImageProps>()
</script>
<template>
<AvatarImage
data-slot="avatar-image"
v-bind="props"
class="aspect-square size-full"
>
<slot />
</AvatarImage>
</template>

Some files were not shown because too many files have changed in this diff Show More