Initial commit: 网络验证平台
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user