feat: 实现前端卡密管理界面
- 卡密列表展示与分页功能 - 单个/批量创建卡密 - 卡密删除与批量删除 - 卡密导出功能 (file-saver) - 启用/禁用状态切换 - 状态判断 (有效/已使用/已失效) - Toast 通知系统 (vue-sonner) - 登录页面错误提示优化 - 后端登录错误消息中文化
This commit is contained in:
23
frontend/src/App.vue
Normal file
23
frontend/src/App.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||
import PublicLayout from '@/layouts/PublicLayout.vue'
|
||||
import { Toaster } from 'vue-sonner'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const layout = computed(() => {
|
||||
if (route.meta.layout === 'admin') {
|
||||
return AdminLayout
|
||||
}
|
||||
return PublicLayout
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component :is="layout">
|
||||
<router-view />
|
||||
</component>
|
||||
<Toaster position="top-center" rich-colors />
|
||||
</template>
|
||||
58
frontend/src/api/accounts.ts
Normal file
58
frontend/src/api/accounts.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import request from './request'
|
||||
|
||||
export interface Account {
|
||||
id: number
|
||||
team_account_id: string
|
||||
name: string
|
||||
is_active: boolean
|
||||
seats_in_use: number
|
||||
seats_entitled: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AccountsResponse {
|
||||
success: boolean
|
||||
data?: Account[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AccountResponse {
|
||||
success: boolean
|
||||
data?: Account
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CreateAccountRequest {
|
||||
team_account_id: string
|
||||
auth_token: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export function getAccounts(params?: PaginationParams) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.page) searchParams.set('page', String(params.page))
|
||||
if (params?.page_size) searchParams.set('page_size', String(params.page_size))
|
||||
const query = searchParams.toString()
|
||||
return request.get<AccountsResponse>(`/api/accounts${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function createAccount(data: CreateAccountRequest) {
|
||||
return request.post<AccountResponse>('/api/accounts/create', data)
|
||||
}
|
||||
|
||||
export function refreshAccount(id: number) {
|
||||
return request.post<AccountResponse>(`/api/accounts/refresh?id=${id}`)
|
||||
}
|
||||
|
||||
export function deleteAccount(id: number) {
|
||||
return request.delete(`/api/accounts/delete?id=${id}`)
|
||||
}
|
||||
29
frontend/src/api/auth.ts
Normal file
29
frontend/src/api/auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import request from './request'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
success: boolean
|
||||
token?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
success: boolean
|
||||
user?: {
|
||||
id: number
|
||||
username: string
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export function login(data: LoginRequest) {
|
||||
return request.post<LoginResponse>('/api/login', data)
|
||||
}
|
||||
|
||||
export function getProfile() {
|
||||
return request.get<ProfileResponse>('/api/profile')
|
||||
}
|
||||
73
frontend/src/api/cardkeys.ts
Normal file
73
frontend/src/api/cardkeys.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import request from './request'
|
||||
|
||||
export interface CardKey {
|
||||
id: number
|
||||
key: string
|
||||
max_uses: number
|
||||
used_count: number
|
||||
validity_type: string
|
||||
expires_at: string
|
||||
is_active: boolean
|
||||
created_by_id: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface CardKeysResponse {
|
||||
success: boolean
|
||||
keys?: CardKey[]
|
||||
total?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CardKeyResponse {
|
||||
success: boolean
|
||||
data?: CardKey
|
||||
keys?: CardKey[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface CreateCardKeyRequest {
|
||||
validity_days?: number
|
||||
max_uses?: number
|
||||
}
|
||||
|
||||
export interface BatchCreateCardKeyRequest {
|
||||
count: number
|
||||
validity_days?: number
|
||||
max_uses?: number
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export function getCardKeys(params?: PaginationParams) {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params?.page) searchParams.set('page', String(params.page))
|
||||
if (params?.page_size) searchParams.set('page_size', String(params.page_size))
|
||||
const query = searchParams.toString()
|
||||
return request.get<CardKeysResponse>(`/api/cardkeys${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
export function createCardKey(data: CreateCardKeyRequest) {
|
||||
return request.post<CardKeyResponse>('/api/cardkeys', data)
|
||||
}
|
||||
|
||||
export function batchCreateCardKeys(data: BatchCreateCardKeyRequest) {
|
||||
return request.post<CardKeyResponse>('/api/cardkeys/batch', data)
|
||||
}
|
||||
|
||||
export function deleteCardKey(id: number) {
|
||||
return request.delete<CardKeyResponse>(`/api/cardkeys/delete?id=${id}`)
|
||||
}
|
||||
|
||||
export function batchDeleteCardKeys(ids: number[]) {
|
||||
return request.delete<CardKeyResponse>('/api/cardkeys/batch', { data: { ids } })
|
||||
}
|
||||
|
||||
export function toggleCardKeyActive(id: number, is_active: boolean) {
|
||||
return request.post<CardKeyResponse>('/api/cardkeys/toggle', { id, is_active })
|
||||
}
|
||||
39
frontend/src/api/invite.ts
Normal file
39
frontend/src/api/invite.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import request from './request'
|
||||
|
||||
export interface InviteByCardRequest {
|
||||
email: string
|
||||
card_key: string
|
||||
}
|
||||
|
||||
export interface Invitation {
|
||||
id: number
|
||||
email: string
|
||||
account_id: number
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface InvitationsResponse {
|
||||
success: boolean
|
||||
invitations?: Invitation[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface DeleteInviteRequest {
|
||||
email: string
|
||||
account_id: number
|
||||
}
|
||||
|
||||
// Public endpoint - no auth required
|
||||
export function inviteByCard(data: InviteByCardRequest) {
|
||||
return request.post('/api/invite/card', data)
|
||||
}
|
||||
|
||||
// Admin endpoints - auth required
|
||||
export function listInvitations(accountId: number) {
|
||||
return request.get<InvitationsResponse>(`/api/invite?account_id=${accountId}`)
|
||||
}
|
||||
|
||||
export function deleteInvite(data: DeleteInviteRequest) {
|
||||
return request.delete('/api/invite', { data })
|
||||
}
|
||||
42
frontend/src/api/request.ts
Normal file
42
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import axios, { type InternalAxiosRequestConfig, type AxiosResponse, type AxiosError } from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import router from '@/router'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_BASE_URL || '',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add Authorization header
|
||||
request.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const authStore = useAuthStore()
|
||||
if (authStore.token) {
|
||||
config.headers.Authorization = `Bearer ${authStore.token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor - handle 401/403
|
||||
request.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
return response
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401 || error.response?.status === 403) {
|
||||
const authStore = useAuthStore()
|
||||
authStore.logout()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default request
|
||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
15
frontend/src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@@ -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>
|
||||
21
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
21
frontend/src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@@ -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>
|
||||
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
9
frontend/src/components/ui/alert-dialog/index.ts
Normal file
@@ -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"
|
||||
21
frontend/src/components/ui/alert/Alert.vue
Normal file
21
frontend/src/components/ui/alert/Alert.vue
Normal file
@@ -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>
|
||||
17
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
17
frontend/src/components/ui/alert/AlertDescription.vue
Normal file
@@ -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>
|
||||
17
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
17
frontend/src/components/ui/alert/AlertTitle.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
:class="cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
24
frontend/src/components/ui/alert/index.ts
Normal file
24
frontend/src/components/ui/alert/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Alert } from "./Alert.vue"
|
||||
export { default as AlertDescription } from "./AlertDescription.vue"
|
||||
export { default as AlertTitle } from "./AlertTitle.vue"
|
||||
|
||||
export const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type AlertVariants = VariantProps<typeof alertVariants>
|
||||
26
frontend/src/components/ui/badge/Badge.vue
Normal file
26
frontend/src/components/ui/badge/Badge.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { BadgeVariants } from "."
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { badgeVariants } from "."
|
||||
|
||||
const props = defineProps<PrimitiveProps & {
|
||||
variant?: BadgeVariants["variant"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
26
frontend/src/components/ui/badge/index.ts
Normal file
26
frontend/src/components/ui/badge/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Badge } from "./Badge.vue"
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
29
frontend/src/components/ui/button/Button.vue
Normal file
29
frontend/src/components/ui/button/Button.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { PrimitiveProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from "."
|
||||
import { Primitive } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "."
|
||||
|
||||
interface Props extends PrimitiveProps {
|
||||
variant?: ButtonVariants["variant"]
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
as: "button",
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
38
frontend/src/components/ui/button/index.ts
Normal file
38
frontend/src/components/ui/button/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { VariantProps } from "class-variance-authority"
|
||||
import { cva } from "class-variance-authority"
|
||||
|
||||
export { default as Button } from "./Button.vue"
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
"default": "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
"sm": "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
"lg": "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
"icon": "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
)
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
22
frontend/src/components/ui/card/Card.vue
Normal file
22
frontend/src/components/ui/card/Card.vue
Normal file
@@ -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="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardAction.vue
Normal file
17
frontend/src/components/ui/card/CardAction.vue
Normal file
@@ -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="card-action"
|
||||
:class="cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardContent.vue
Normal file
17
frontend/src/components/ui/card/CardContent.vue
Normal file
@@ -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="card-content"
|
||||
:class="cn('px-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
17
frontend/src/components/ui/card/CardDescription.vue
Normal file
@@ -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>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
17
frontend/src/components/ui/card/CardFooter.vue
Normal file
@@ -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="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
17
frontend/src/components/ui/card/CardHeader.vue
Normal file
@@ -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="card-header"
|
||||
:class="cn('@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
17
frontend/src/components/ui/card/CardTitle.vue
Normal file
@@ -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>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
7
frontend/src/components/ui/card/index.ts
Normal file
7
frontend/src/components/ui/card/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { default as Card } from "./Card.vue"
|
||||
export { default as CardAction } from "./CardAction.vue"
|
||||
export { default as CardContent } from "./CardContent.vue"
|
||||
export { default as CardDescription } from "./CardDescription.vue"
|
||||
export { default as CardFooter } from "./CardFooter.vue"
|
||||
export { default as CardHeader } from "./CardHeader.vue"
|
||||
export { default as CardTitle } from "./CardTitle.vue"
|
||||
35
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
35
frontend/src/components/ui/checkbox/Checkbox.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { CheckboxRootEmits, CheckboxRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<CheckboxRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn('peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class)"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<slot v-bind="slotProps">
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
||||
1
frontend/src/components/ui/checkbox/index.ts
Normal file
1
frontend/src/components/ui/checkbox/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue"
|
||||
19
frontend/src/components/ui/dialog/Dialog.vue
Normal file
19
frontend/src/components/ui/dialog/Dialog.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogRootEmits, DialogRootProps } from "reka-ui"
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogRootProps>()
|
||||
const emits = defineEmits<DialogRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="dialog"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</DialogRoot>
|
||||
</template>
|
||||
15
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
15
frontend/src/components/ui/dialog/DialogClose.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogCloseProps } from "reka-ui"
|
||||
import { DialogClose } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogCloseProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose
|
||||
data-slot="dialog-close"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
||||
53
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
53
frontend/src/components/ui/dialog/DialogContent.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import DialogOverlay from "./DialogOverlay.vue"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<DialogContentProps & { class?: HTMLAttributes["class"], showCloseButton?: boolean }>(), {
|
||||
showCloseButton: true,
|
||||
})
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="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 />
|
||||
|
||||
<DialogClose
|
||||
v-if="showCloseButton"
|
||||
data-slot="dialog-close"
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
23
frontend/src/components/ui/dialog/DialogDescription.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogDescriptionProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogDescription, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogDescriptionProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
||||
15
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
15
frontend/src/components/ui/dialog/DialogFooter.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<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="dialog-footer"
|
||||
:class="cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
17
frontend/src/components/ui/dialog/DialogHeader.vue
Normal file
@@ -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="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
21
frontend/src/components/ui/dialog/DialogOverlay.vue
Normal file
21
frontend/src/components/ui/dialog/DialogOverlay.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogOverlayProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogOverlay } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogOverlayProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('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', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
||||
59
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
59
frontend/src/components/ui/dialog/DialogScrollContent.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogContentEmits, DialogContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { X } from "lucide-vue-next"
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<DialogContentProps & { class?: HTMLAttributes["class"] }>()
|
||||
const emits = defineEmits<DialogContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
@pointer-down-outside="(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target as HTMLElement;
|
||||
if (originalEvent.offsetX > target.clientWidth || originalEvent.offsetY > target.clientHeight) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
||||
23
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
23
frontend/src/components/ui/dialog/DialogTitle.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTitleProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { DialogTitle, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<DialogTitleProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
||||
15
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
15
frontend/src/components/ui/dialog/DialogTrigger.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { DialogTriggerProps } from "reka-ui"
|
||||
import { DialogTrigger } from "reka-ui"
|
||||
|
||||
const props = defineProps<DialogTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger
|
||||
data-slot="dialog-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
||||
10
frontend/src/components/ui/dialog/index.ts
Normal file
10
frontend/src/components/ui/dialog/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from "./Dialog.vue"
|
||||
export { default as DialogClose } from "./DialogClose.vue"
|
||||
export { default as DialogContent } from "./DialogContent.vue"
|
||||
export { default as DialogDescription } from "./DialogDescription.vue"
|
||||
export { default as DialogFooter } from "./DialogFooter.vue"
|
||||
export { default as DialogHeader } from "./DialogHeader.vue"
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue"
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue"
|
||||
export { default as DialogTitle } from "./DialogTitle.vue"
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue"
|
||||
33
frontend/src/components/ui/input/Input.vue
Normal file
33
frontend/src/components/ui/input/Input.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { useVModel } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<{
|
||||
defaultValue?: string | number
|
||||
modelValue?: string | number
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: "update:modelValue", payload: string | number): void
|
||||
}>()
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
</template>
|
||||
1
frontend/src/components/ui/input/index.ts
Normal file
1
frontend/src/components/ui/input/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue"
|
||||
26
frontend/src/components/ui/label/Label.vue
Normal file
26
frontend/src/components/ui/label/Label.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { LabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Label } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<LabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
1
frontend/src/components/ui/label/index.ts
Normal file
1
frontend/src/components/ui/label/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue"
|
||||
26
frontend/src/components/ui/pagination/Pagination.vue
Normal file
26
frontend/src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationRootEmits, PaginationRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { PaginationRoot, useForwardPropsEmits } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<PaginationRootProps & {
|
||||
class?: HTMLAttributes["class"]
|
||||
}>()
|
||||
const emits = defineEmits<PaginationRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination"
|
||||
v-bind="forwarded"
|
||||
:class="cn('mx-auto flex w-full justify-center', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
22
frontend/src/components/ui/pagination/PaginationContent.vue
Normal file
22
frontend/src/components/ui/pagination/PaginationContent.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationListProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { PaginationList } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<PaginationListProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationList
|
||||
v-slot="slotProps"
|
||||
data-slot="pagination-content"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('flex flex-row items-center gap-1', props.class)"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PaginationList>
|
||||
</template>
|
||||
25
frontend/src/components/ui/pagination/PaginationEllipsis.vue
Normal file
25
frontend/src/components/ui/pagination/PaginationEllipsis.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationEllipsisProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { MoreHorizontal } from "lucide-vue-next"
|
||||
import { PaginationEllipsis } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<PaginationEllipsisProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationEllipsis
|
||||
data-slot="pagination-ellipsis"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('flex size-9 items-center justify-center', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<MoreHorizontal class="size-4" />
|
||||
<span class="sr-only">More pages</span>
|
||||
</slot>
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
33
frontend/src/components/ui/pagination/PaginationFirst.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationFirst.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationFirstProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronLeftIcon } from "lucide-vue-next"
|
||||
import { PaginationFirst, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationFirstProps & {
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
size: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationFirst
|
||||
data-slot="pagination-first"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
<span class="hidden sm:block">First</span>
|
||||
</slot>
|
||||
</PaginationFirst>
|
||||
</template>
|
||||
34
frontend/src/components/ui/pagination/PaginationItem.vue
Normal file
34
frontend/src/components/ui/pagination/PaginationItem.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationListItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { PaginationListItem } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationListItemProps & {
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
isActive?: boolean
|
||||
}>(), {
|
||||
size: "icon",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size", "isActive")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationListItem
|
||||
data-slot="pagination-item"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size,
|
||||
}),
|
||||
props.class)"
|
||||
>
|
||||
<slot />
|
||||
</PaginationListItem>
|
||||
</template>
|
||||
33
frontend/src/components/ui/pagination/PaginationLast.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationLast.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationLastProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRightIcon } from "lucide-vue-next"
|
||||
import { PaginationLast, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationLastProps & {
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
size: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationLast
|
||||
data-slot="pagination-last"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Last</span>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</PaginationLast>
|
||||
</template>
|
||||
33
frontend/src/components/ui/pagination/PaginationNext.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationNext.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationNextProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronRightIcon } from "lucide-vue-next"
|
||||
import { PaginationNext, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationNextProps & {
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
size: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationNext
|
||||
data-slot="pagination-next"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<span class="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
</slot>
|
||||
</PaginationNext>
|
||||
</template>
|
||||
33
frontend/src/components/ui/pagination/PaginationPrevious.vue
Normal file
33
frontend/src/components/ui/pagination/PaginationPrevious.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { PaginationPrevProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import type { ButtonVariants } from '@/components/ui/button'
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronLeftIcon } from "lucide-vue-next"
|
||||
import { PaginationPrev, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
const props = withDefaults(defineProps<PaginationPrevProps & {
|
||||
size?: ButtonVariants["size"]
|
||||
class?: HTMLAttributes["class"]
|
||||
}>(), {
|
||||
size: "default",
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwarded = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PaginationPrev
|
||||
data-slot="pagination-previous"
|
||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'gap-1 px-2.5 sm:pr-2.5', props.class)"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot>
|
||||
<ChevronLeftIcon />
|
||||
<span class="hidden sm:block">Previous</span>
|
||||
</slot>
|
||||
</PaginationPrev>
|
||||
</template>
|
||||
8
frontend/src/components/ui/pagination/index.ts
Normal file
8
frontend/src/components/ui/pagination/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as Pagination } from "./Pagination.vue"
|
||||
export { default as PaginationContent } from "./PaginationContent.vue"
|
||||
export { default as PaginationEllipsis } from "./PaginationEllipsis.vue"
|
||||
export { default as PaginationFirst } from "./PaginationFirst.vue"
|
||||
export { default as PaginationItem } from "./PaginationItem.vue"
|
||||
export { default as PaginationLast } from "./PaginationLast.vue"
|
||||
export { default as PaginationNext } from "./PaginationNext.vue"
|
||||
export { default as PaginationPrevious } from "./PaginationPrevious.vue"
|
||||
19
frontend/src/components/ui/select/Select.vue
Normal file
19
frontend/src/components/ui/select/Select.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectRootEmits, SelectRootProps } from "reka-ui"
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectRootProps>()
|
||||
const emits = defineEmits<SelectRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="select"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</SelectRoot>
|
||||
</template>
|
||||
51
frontend/src/components/ui/select/SelectContent.vue
Normal file
51
frontend/src/components/ui/select/SelectContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectContentEmits, SelectContentProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from "."
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectContentProps & { class?: HTMLAttributes["class"] }>(),
|
||||
{
|
||||
position: "popper",
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<SelectContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="cn(
|
||||
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper'
|
||||
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport :class="cn('p-1', position === 'popper' && 'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1')">
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
||||
15
frontend/src/components/ui/select/SelectGroup.vue
Normal file
15
frontend/src/components/ui/select/SelectGroup.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectGroupProps } from "reka-ui"
|
||||
import { SelectGroup } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectGroupProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup
|
||||
data-slot="select-group"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
||||
44
frontend/src/components/ui/select/SelectItem.vue
Normal file
44
frontend/src/components/ui/select/SelectItem.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { Check } from "lucide-vue-next"
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectItemProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
data-slot="select-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<slot name="indicator-icon">
|
||||
<Check class="size-4" />
|
||||
</slot>
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</template>
|
||||
15
frontend/src/components/ui/select/SelectItemText.vue
Normal file
15
frontend/src/components/ui/select/SelectItemText.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectItemTextProps } from "reka-ui"
|
||||
import { SelectItemText } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectItemTextProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText
|
||||
data-slot="select-item-text"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
||||
17
frontend/src/components/ui/select/SelectLabel.vue
Normal file
17
frontend/src/components/ui/select/SelectLabel.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectLabelProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { SelectLabel } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectLabelProps & { class?: HTMLAttributes["class"] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
data-slot="select-label"
|
||||
:class="cn('text-muted-foreground px-2 py-1.5 text-xs', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
||||
26
frontend/src/components/ui/select/SelectScrollDownButton.vue
Normal file
26
frontend/src/components/ui/select/SelectScrollDownButton.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollDownButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectScrollDownButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDown class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
||||
26
frontend/src/components/ui/select/SelectScrollUpButton.vue
Normal file
26
frontend/src/components/ui/select/SelectScrollUpButton.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectScrollUpButtonProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronUp } from "lucide-vue-next"
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectScrollUpButtonProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('flex cursor-default items-center justify-center py-1', props.class)"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUp class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
||||
19
frontend/src/components/ui/select/SelectSeparator.vue
Normal file
19
frontend/src/components/ui/select/SelectSeparator.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectSeparatorProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { SelectSeparator } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SelectSeparatorProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
data-slot="select-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
||||
33
frontend/src/components/ui/select/SelectTrigger.vue
Normal file
33
frontend/src/components/ui/select/SelectTrigger.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectTriggerProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { ChevronDown } from "lucide-vue-next"
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<SelectTriggerProps & { class?: HTMLAttributes["class"], size?: "sm" | "default" }>(),
|
||||
{ size: "default" },
|
||||
)
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size")
|
||||
const forwardedProps = useForwardProps(delegatedProps)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn(
|
||||
'border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
||||
15
frontend/src/components/ui/select/SelectValue.vue
Normal file
15
frontend/src/components/ui/select/SelectValue.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { SelectValueProps } from "reka-ui"
|
||||
import { SelectValue } from "reka-ui"
|
||||
|
||||
const props = defineProps<SelectValueProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue
|
||||
data-slot="select-value"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
||||
11
frontend/src/components/ui/select/index.ts
Normal file
11
frontend/src/components/ui/select/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { default as Select } from "./Select.vue"
|
||||
export { default as SelectContent } from "./SelectContent.vue"
|
||||
export { default as SelectGroup } from "./SelectGroup.vue"
|
||||
export { default as SelectItem } from "./SelectItem.vue"
|
||||
export { default as SelectItemText } from "./SelectItemText.vue"
|
||||
export { default as SelectLabel } from "./SelectLabel.vue"
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue"
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue"
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue"
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue"
|
||||
export { default as SelectValue } from "./SelectValue.vue"
|
||||
17
frontend/src/components/ui/skeleton/Skeleton.vue
Normal file
17
frontend/src/components/ui/skeleton/Skeleton.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SkeletonProps {
|
||||
class?: HTMLAttributes["class"]
|
||||
}
|
||||
|
||||
const props = defineProps<SkeletonProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
:class="cn('animate-pulse rounded-md bg-primary/10', props.class)"
|
||||
/>
|
||||
</template>
|
||||
1
frontend/src/components/ui/skeleton/index.ts
Normal file
1
frontend/src/components/ui/skeleton/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Skeleton } from "./Skeleton.vue"
|
||||
42
frontend/src/components/ui/sonner/Sonner.vue
Normal file
42
frontend/src/components/ui/sonner/Sonner.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts" setup>
|
||||
import type { ToasterProps } from "vue-sonner"
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, XIcon } from "lucide-vue-next"
|
||||
import { Toaster as Sonner } from "vue-sonner"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<ToasterProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sonner
|
||||
:class="cn('toaster group', props.class)"
|
||||
:style="{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
'--border-radius': 'var(--radius)',
|
||||
}"
|
||||
v-bind="props"
|
||||
>
|
||||
<template #success-icon>
|
||||
<CircleCheckIcon class="size-4" />
|
||||
</template>
|
||||
<template #info-icon>
|
||||
<InfoIcon class="size-4" />
|
||||
</template>
|
||||
<template #warning-icon>
|
||||
<TriangleAlertIcon class="size-4" />
|
||||
</template>
|
||||
<template #error-icon>
|
||||
<OctagonXIcon class="size-4" />
|
||||
</template>
|
||||
<template #loading-icon>
|
||||
<div>
|
||||
<Loader2Icon class="size-4 animate-spin" />
|
||||
</div>
|
||||
</template>
|
||||
<template #close-icon>
|
||||
<XIcon class="size-4" />
|
||||
</template>
|
||||
</Sonner>
|
||||
</template>
|
||||
1
frontend/src/components/ui/sonner/index.ts
Normal file
1
frontend/src/components/ui/sonner/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./Sonner.vue"
|
||||
38
frontend/src/components/ui/switch/Switch.vue
Normal file
38
frontend/src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import type { SwitchRootEmits, SwitchRootProps } from "reka-ui"
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import {
|
||||
SwitchRoot,
|
||||
SwitchThumb,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const props = defineProps<SwitchRootProps & { class?: HTMLAttributes["class"] }>()
|
||||
|
||||
const emits = defineEmits<SwitchRootEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="switch"
|
||||
v-bind="forwarded"
|
||||
:class="cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)"
|
||||
>
|
||||
<SwitchThumb
|
||||
data-slot="switch-thumb"
|
||||
:class="cn('bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0')"
|
||||
>
|
||||
<slot name="thumb" v-bind="slotProps" />
|
||||
</SwitchThumb>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
1
frontend/src/components/ui/switch/index.ts
Normal file
1
frontend/src/components/ui/switch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Switch } from "./Switch.vue"
|
||||
16
frontend/src/components/ui/table/Table.vue
Normal file
16
frontend/src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="table-container" class="relative w-full overflow-auto">
|
||||
<table data-slot="table" :class="cn('w-full caption-bottom text-sm', props.class)">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/components/ui/table/TableBody.vue
Normal file
17
frontend/src/components/ui/table/TableBody.vue
Normal file
@@ -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>
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
17
frontend/src/components/ui/table/TableCaption.vue
Normal file
17
frontend/src/components/ui/table/TableCaption.vue
Normal file
@@ -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>
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
||||
22
frontend/src/components/ui/table/TableCell.vue
Normal file
22
frontend/src/components/ui/table/TableCell.vue
Normal file
@@ -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>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
34
frontend/src/components/ui/table/TableEmpty.vue
Normal file
34
frontend/src/components/ui/table/TableEmpty.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from "vue"
|
||||
import { reactiveOmit } from "@vueuse/core"
|
||||
import { cn } from "@/lib/utils"
|
||||
import TableCell from "./TableCell.vue"
|
||||
import TableRow from "./TableRow.vue"
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
class?: HTMLAttributes["class"]
|
||||
colspan?: number
|
||||
}>(), {
|
||||
colspan: 1,
|
||||
})
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class")
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
||||
17
frontend/src/components/ui/table/TableFooter.vue
Normal file
17
frontend/src/components/ui/table/TableFooter.vue
Normal file
@@ -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>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
||||
17
frontend/src/components/ui/table/TableHead.vue
Normal file
17
frontend/src/components/ui/table/TableHead.vue
Normal file
@@ -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>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="cn('text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
17
frontend/src/components/ui/table/TableHeader.vue
Normal file
17
frontend/src/components/ui/table/TableHeader.vue
Normal file
@@ -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>
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
:class="cn('[&_tr]:border-b', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
17
frontend/src/components/ui/table/TableRow.vue
Normal file
17
frontend/src/components/ui/table/TableRow.vue
Normal file
@@ -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>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="cn('hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
9
frontend/src/components/ui/table/index.ts
Normal file
9
frontend/src/components/ui/table/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Table } from "./Table.vue"
|
||||
export { default as TableBody } from "./TableBody.vue"
|
||||
export { default as TableCaption } from "./TableCaption.vue"
|
||||
export { default as TableCell } from "./TableCell.vue"
|
||||
export { default as TableEmpty } from "./TableEmpty.vue"
|
||||
export { default as TableFooter } from "./TableFooter.vue"
|
||||
export { default as TableHead } from "./TableHead.vue"
|
||||
export { default as TableHeader } from "./TableHeader.vue"
|
||||
export { default as TableRow } from "./TableRow.vue"
|
||||
10
frontend/src/components/ui/table/utils.ts
Normal file
10
frontend/src/components/ui/table/utils.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { Updater } from "@tanstack/vue-table"
|
||||
|
||||
import type { Ref } from "vue"
|
||||
import { isFunction } from "@tanstack/vue-table"
|
||||
|
||||
export function valueUpdater<T>(updaterOrValue: Updater<T>, ref: Ref<T>) {
|
||||
ref.value = isFunction(updaterOrValue)
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue
|
||||
}
|
||||
138
frontend/src/layouts/AdminLayout.vue
Normal file
138
frontend/src/layouts/AdminLayout.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
KeyRound,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Sun
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const sidebarOpen = ref(true)
|
||||
const isDark = ref(document.documentElement.classList.contains('dark'))
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', path: '/admin/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Teams', path: '/admin/teams', icon: Users },
|
||||
{ name: 'Card Keys', path: '/admin/cardkeys', icon: KeyRound },
|
||||
]
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen.value = !sidebarOpen.value
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
isDark.value = !isDark.value
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
authStore.logout()
|
||||
router.push('/admin/login')
|
||||
}
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path === path || route.path.startsWith(path + '/')
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
if (localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
isDark.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background">
|
||||
<!-- Sidebar -->
|
||||
<aside
|
||||
:class="[
|
||||
'fixed left-0 top-0 z-40 h-screen transition-transform duration-300',
|
||||
sidebarOpen ? 'w-64' : 'w-16',
|
||||
'bg-card border-r'
|
||||
]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="flex h-16 items-center justify-between px-4 border-b">
|
||||
<span v-if="sidebarOpen" class="text-lg font-semibold">GPT Manager</span>
|
||||
<Button variant="ghost" size="icon" @click="toggleSidebar">
|
||||
<Menu class="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="flex-1 space-y-1 p-2">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 transition-colors',
|
||||
isActive(item.path)
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5 shrink-0" />
|
||||
<span v-if="sidebarOpen">{{ item.name }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- Bottom actions -->
|
||||
<div class="absolute bottom-0 left-0 right-0 border-t p-2 space-y-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
:class="['w-full justify-start gap-3', sidebarOpen ? '' : 'justify-center']"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<Sun v-if="isDark" class="h-5 w-5" />
|
||||
<Moon v-else class="h-5 w-5" />
|
||||
<span v-if="sidebarOpen">{{ isDark ? 'Light Mode' : 'Dark Mode' }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
:class="['w-full justify-start gap-3 text-destructive hover:text-destructive', sidebarOpen ? '' : 'justify-center']"
|
||||
@click="handleLogout"
|
||||
>
|
||||
<LogOut class="h-5 w-5" />
|
||||
<span v-if="sidebarOpen">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main
|
||||
:class="[
|
||||
'transition-all duration-300',
|
||||
sidebarOpen ? 'ml-64' : 'ml-16'
|
||||
]"
|
||||
>
|
||||
<!-- Top bar -->
|
||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-4 border-b bg-background/95 backdrop-blur px-6">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-semibold">
|
||||
{{ route.meta.title || route.name?.toString().replace('admin-', '').replace(/-/g, ' ') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div v-if="authStore.user" class="text-sm text-muted-foreground">
|
||||
{{ authStore.user.username }}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Page content -->
|
||||
<div class="p-6">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
17
frontend/src/layouts/PublicLayout.vue
Normal file
17
frontend/src/layouts/PublicLayout.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize theme
|
||||
if (localStorage.getItem('theme') === 'dark' ||
|
||||
(!localStorage.getItem('theme') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-background flex items-center justify-center">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
7
frontend/src/lib/utils.ts
Normal file
7
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx"
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
13
frontend/src/main.ts
Normal file
13
frontend/src/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
import 'vue-sonner/style.css'; // 导入样式
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
80
frontend/src/router/index.ts
Normal file
80
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
// Public routes
|
||||
{
|
||||
path: '/',
|
||||
name: 'join',
|
||||
component: () => import('@/views/public/JoinPage.vue'),
|
||||
meta: { layout: 'public' },
|
||||
},
|
||||
// Admin routes
|
||||
{
|
||||
path: '/admin',
|
||||
redirect: '/admin/dashboard',
|
||||
},
|
||||
{
|
||||
path: '/admin/login',
|
||||
name: 'admin-login',
|
||||
component: () => import('@/views/admin/LoginPage.vue'),
|
||||
meta: { layout: 'public' },
|
||||
},
|
||||
{
|
||||
path: '/admin/dashboard',
|
||||
name: 'admin-dashboard',
|
||||
component: () => import('@/views/admin/DashboardPage.vue'),
|
||||
meta: { layout: 'admin', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/teams',
|
||||
name: 'admin-teams',
|
||||
component: () => import('@/views/admin/TeamsPage.vue'),
|
||||
meta: { layout: 'admin', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/teams/:id/invites',
|
||||
name: 'admin-team-invites',
|
||||
component: () => import('@/views/admin/TeamInvitesPage.vue'),
|
||||
meta: { layout: 'admin', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/cardkeys',
|
||||
name: 'admin-cardkeys',
|
||||
component: () => import('@/views/admin/CardKeysPage.vue'),
|
||||
meta: { layout: 'admin', requiresAuth: true },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth) {
|
||||
if (!authStore.isAuthenticated) {
|
||||
next({ name: 'admin-login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
// Verify token is still valid
|
||||
const isValid = await authStore.checkAuth()
|
||||
if (!isValid) {
|
||||
next({ name: 'admin-login', query: { redirect: to.fullPath } })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect logged-in users away from login page
|
||||
if (to.name === 'admin-login' && authStore.isAuthenticated) {
|
||||
next({ name: 'admin-dashboard' })
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
53
frontend/src/stores/accounts.ts
Normal file
53
frontend/src/stores/accounts.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getAccounts, type Account } from '@/api/accounts'
|
||||
|
||||
export const useAccountsStore = defineStore('accounts', () => {
|
||||
const accounts = ref<Account[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const totalTeams = computed(() => accounts.value.length)
|
||||
const validTeams = computed(() => accounts.value.filter(a => a.is_active).length)
|
||||
const invalidTeams = computed(() => accounts.value.filter(a => !a.is_active).length)
|
||||
const totalAvailableSeats = computed(() =>
|
||||
accounts.value.reduce((sum, a) => sum + Math.max(0, (a.seats_entitled || 0) - (a.seats_in_use || 0)), 0)
|
||||
)
|
||||
|
||||
async function fetchAccounts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await getAccounts()
|
||||
if (response.data.success && response.data.data) {
|
||||
accounts.value = response.data.data
|
||||
} else {
|
||||
throw new Error(response.data.message || '获取账号列表失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '获取账号列表失败'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function updateAccount(updatedAccount: Account) {
|
||||
const index = accounts.value.findIndex(a => a.id === updatedAccount.id)
|
||||
if (index !== -1) {
|
||||
accounts.value[index] = updatedAccount
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
loading,
|
||||
error,
|
||||
totalTeams,
|
||||
validTeams,
|
||||
invalidTeams,
|
||||
totalAvailableSeats,
|
||||
fetchAccounts,
|
||||
updateAccount,
|
||||
}
|
||||
})
|
||||
69
frontend/src/stores/auth.ts
Normal file
69
frontend/src/stores/auth.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as apiLogin, getProfile, type LoginRequest } from '@/api/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem('token'))
|
||||
const user = ref<{ id: number; username: string } | null>(null)
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
|
||||
async function login(credentials: LoginRequest): Promise<{ success: boolean; message?: string }> {
|
||||
try {
|
||||
const response = await apiLogin(credentials)
|
||||
if (response.data.success && response.data.token) {
|
||||
token.value = response.data.token
|
||||
localStorage.setItem('token', response.data.token)
|
||||
return { success: true }
|
||||
}
|
||||
// 后端返回 success: false
|
||||
return { success: false, message: response.data.message || '登录失败' }
|
||||
} catch (e: any) {
|
||||
// 处理网络错误
|
||||
if (e.code === 'ERR_NETWORK' || e.message === 'Network Error') {
|
||||
return { success: false, message: '无法连接到服务器,请检查后端服务是否已启动' }
|
||||
}
|
||||
// 处理超时
|
||||
if (e.code === 'ECONNABORTED') {
|
||||
return { success: false, message: '连接超时,请稍后重试' }
|
||||
}
|
||||
// 处理后端返回的错误 (401, 403 等)
|
||||
if (e.response?.data?.message) {
|
||||
return { success: false, message: e.response.data.message }
|
||||
}
|
||||
// 其他错误
|
||||
return { success: false, message: '登录失败,请稍后重试' }
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
if (!token.value) return false
|
||||
try {
|
||||
const response = await getProfile()
|
||||
if (response.data.success && response.data.user) {
|
||||
user.value = response.data.user
|
||||
return true
|
||||
}
|
||||
logout()
|
||||
return false
|
||||
} catch {
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
localStorage.removeItem('token')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated,
|
||||
login,
|
||||
checkAuth,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
120
frontend/src/style.css
Normal file
120
frontend/src/style.css
Normal file
@@ -0,0 +1,120 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
608
frontend/src/views/admin/CardKeysPage.vue
Normal file
608
frontend/src/views/admin/CardKeysPage.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationFirst,
|
||||
PaginationItem,
|
||||
PaginationLast,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination'
|
||||
import {
|
||||
getCardKeys,
|
||||
createCardKey,
|
||||
batchCreateCardKeys,
|
||||
deleteCardKey,
|
||||
batchDeleteCardKeys,
|
||||
toggleCardKeyActive,
|
||||
type CardKey,
|
||||
} from '@/api/cardkeys'
|
||||
import { saveAs } from 'file-saver'
|
||||
import { Plus, Copy, KeyRound, Loader2, Layers, Trash2, Download } from 'lucide-vue-next'
|
||||
|
||||
const cardKeys = ref<CardKey[]>([])
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const batchCreating = ref(false)
|
||||
const total = ref(0)
|
||||
|
||||
// Selection - use array for reactivity
|
||||
const selectedIds = ref<number[]>([])
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return cardKeys.value.length > 0 && selectedIds.value.length === cardKeys.value.length
|
||||
})
|
||||
|
||||
const selectedCount = computed(() => selectedIds.value.length)
|
||||
|
||||
// Delete confirmation
|
||||
const deleteDialogOpen = ref(false)
|
||||
const pendingDeleteId = ref<number | null>(null)
|
||||
const pendingBatchDelete = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// Pagination
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const pageSizeOptions = [5, 10, 20, 50]
|
||||
|
||||
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
|
||||
|
||||
// Single create form
|
||||
const singleDialogOpen = ref(false)
|
||||
const singleForm = ref({
|
||||
validity_days: '',
|
||||
max_uses: '',
|
||||
})
|
||||
|
||||
// Batch create form
|
||||
const batchDialogOpen = ref(false)
|
||||
const batchForm = ref({
|
||||
count: '',
|
||||
validity_days: '',
|
||||
max_uses: '',
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadCardKeys()
|
||||
})
|
||||
|
||||
async function loadCardKeys() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await getCardKeys({ page: currentPage.value, page_size: pageSize.value })
|
||||
if (response.data.success && response.data.keys) {
|
||||
cardKeys.value = response.data.keys
|
||||
total.value = response.data.total || 0
|
||||
} else {
|
||||
toast.error(response.data.message || '获取卡密列表失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '获取卡密列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateSingle() {
|
||||
creating.value = true
|
||||
try {
|
||||
const response = await createCardKey({
|
||||
validity_days: singleForm.value.validity_days ? Number(singleForm.value.validity_days) : undefined,
|
||||
max_uses: singleForm.value.max_uses ? Number(singleForm.value.max_uses) : undefined,
|
||||
})
|
||||
if (response.data.success) {
|
||||
toast.success('创建卡密成功')
|
||||
singleDialogOpen.value = false
|
||||
singleForm.value = { validity_days: '', max_uses: '' }
|
||||
await loadCardKeys()
|
||||
} else {
|
||||
toast.error(response.data.message || '创建失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateBatch() {
|
||||
if (!batchForm.value.count || Number(batchForm.value.count) < 1) {
|
||||
toast.error('请输入有效的数量')
|
||||
return
|
||||
}
|
||||
|
||||
batchCreating.value = true
|
||||
try {
|
||||
const response = await batchCreateCardKeys({
|
||||
count: Number(batchForm.value.count),
|
||||
validity_days: batchForm.value.validity_days ? Number(batchForm.value.validity_days) : undefined,
|
||||
max_uses: batchForm.value.max_uses ? Number(batchForm.value.max_uses) : undefined,
|
||||
})
|
||||
if (response.data.success) {
|
||||
toast.success(`批量创建 ${batchForm.value.count} 个卡密成功`)
|
||||
batchDialogOpen.value = false
|
||||
batchForm.value = { count: '', validity_days: '', max_uses: '' }
|
||||
await loadCardKeys()
|
||||
} else {
|
||||
toast.error(response.data.message || '创建失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
batchCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(id: number) {
|
||||
pendingDeleteId.value = id
|
||||
pendingBatchDelete.value = false
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
function confirmBatchDelete() {
|
||||
if (selectedIds.value.length === 0) {
|
||||
toast.error('请先选择要删除的卡密')
|
||||
return
|
||||
}
|
||||
pendingDeleteId.value = null
|
||||
pendingBatchDelete.value = true
|
||||
deleteDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
deleting.value = true
|
||||
deleteDialogOpen.value = false
|
||||
|
||||
try {
|
||||
if (pendingBatchDelete.value) {
|
||||
const ids = [...selectedIds.value]
|
||||
const response = await batchDeleteCardKeys(ids)
|
||||
if (response.data.success) {
|
||||
toast.success(`成功删除 ${ids.length} 个卡密`)
|
||||
await loadCardKeys()
|
||||
} else {
|
||||
toast.error(response.data.message || '删除失败')
|
||||
}
|
||||
} else if (pendingDeleteId.value) {
|
||||
const response = await deleteCardKey(pendingDeleteId.value)
|
||||
if (response.data.success) {
|
||||
toast.success('删除成功')
|
||||
await loadCardKeys()
|
||||
} else {
|
||||
toast.error(response.data.message || '删除失败')
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
pendingDeleteId.value = null
|
||||
pendingBatchDelete.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleActive(cardKey: CardKey, newStatus: boolean) {
|
||||
try {
|
||||
const response = await toggleCardKeyActive(cardKey.id, newStatus)
|
||||
if (response.data.success) {
|
||||
cardKey.is_active = newStatus
|
||||
toast.success(newStatus ? '已启用' : '已禁用')
|
||||
} else {
|
||||
toast.error(response.data.message || '操作失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast.error(e.response?.data?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectAll() {
|
||||
if (isAllSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = cardKeys.value.map(ck => ck.id)
|
||||
}
|
||||
}
|
||||
|
||||
function isSelected(id: number) {
|
||||
return selectedIds.value.includes(id)
|
||||
}
|
||||
|
||||
function handleRowSelect(id: number) {
|
||||
const index = selectedIds.value.indexOf(id)
|
||||
if (index > -1) {
|
||||
selectedIds.value.splice(index, 1)
|
||||
} else {
|
||||
selectedIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
function copyKey(key: string) {
|
||||
navigator.clipboard.writeText(key)
|
||||
toast.success('已复制到剪贴板')
|
||||
}
|
||||
|
||||
function exportSelected() {
|
||||
if (selectedIds.value.length === 0) {
|
||||
toast.error('请先选择要导出的卡密')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedKeys = cardKeys.value.filter(ck => selectedIds.value.includes(ck.id))
|
||||
const content = selectedKeys.map(ck => ck.key).join('\r\n')
|
||||
const filename = `cardkeys_${new Date().toISOString().slice(0, 10)}.txt`
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||
saveAs(blob, filename)
|
||||
|
||||
toast.success(`已导出 ${selectedKeys.length} 个卡密`)
|
||||
} catch (e) {
|
||||
toast.error('导出失败')
|
||||
console.error('Export error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 卡密状态判断
|
||||
function getCardKeyStatus(cardKey: CardKey): { text: string; variant: 'default' | 'secondary' | 'destructive' } {
|
||||
// 判断是否已用完(使用次数 >= 最大次数)
|
||||
if (cardKey.max_uses > 0 && cardKey.used_count >= cardKey.max_uses) {
|
||||
return { text: '已使用', variant: 'secondary' }
|
||||
}
|
||||
// 判断是否已失效(手动禁用)
|
||||
if (!cardKey.is_active) {
|
||||
return { text: '已失效', variant: 'destructive' }
|
||||
}
|
||||
// 其他情况为有效
|
||||
return { text: '有效', variant: 'default' }
|
||||
}
|
||||
|
||||
function goToPage(page: number) {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
currentPage.value = page
|
||||
loadCardKeys()
|
||||
}
|
||||
}
|
||||
|
||||
function handlePageSizeChange(value: unknown) {
|
||||
if (value) {
|
||||
pageSize.value = Number(value)
|
||||
currentPage.value = 1
|
||||
loadCardKeys()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">卡密管理</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Batch operations - always show when there's data -->
|
||||
<template v-if="cardKeys.length > 0">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="exportSelected"
|
||||
:disabled="selectedCount === 0"
|
||||
>
|
||||
<Download class="h-4 w-4 mr-2" />
|
||||
导出{{ selectedCount > 0 ? ` (${selectedCount})` : '' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="confirmBatchDelete"
|
||||
:disabled="selectedCount === 0"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
删除{{ selectedCount > 0 ? ` (${selectedCount})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<!-- Single create -->
|
||||
<Dialog v-model:open="singleDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button variant="outline">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
创建卡密
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>创建卡密</DialogTitle>
|
||||
<DialogDescription>创建单个卡密</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="handleCreateSingle" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="validity_days">有效天数(可选)</Label>
|
||||
<Input
|
||||
id="validity_days"
|
||||
v-model="singleForm.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空默认30天"
|
||||
:disabled="creating"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="max_uses">最大使用次数(可选)</Label>
|
||||
<Input
|
||||
id="max_uses"
|
||||
v-model="singleForm.max_uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空默认1次"
|
||||
:disabled="creating"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="creating">
|
||||
<Loader2 v-if="creating" class="h-4 w-4 mr-2 animate-spin" />
|
||||
{{ creating ? '创建中...' : '创建' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- Batch create -->
|
||||
<Dialog v-model:open="batchDialogOpen">
|
||||
<DialogTrigger as-child>
|
||||
<Button>
|
||||
<Layers class="h-4 w-4 mr-2" />
|
||||
批量创建
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>批量创建卡密</DialogTitle>
|
||||
<DialogDescription>批量创建多个卡密</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form @submit.prevent="handleCreateBatch" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="batch_count">数量 *</Label>
|
||||
<Input
|
||||
id="batch_count"
|
||||
v-model="batchForm.count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
placeholder="1-100"
|
||||
:disabled="batchCreating"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="batch_validity_days">有效天数(可选)</Label>
|
||||
<Input
|
||||
id="batch_validity_days"
|
||||
v-model="batchForm.validity_days"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空默认30天"
|
||||
:disabled="batchCreating"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="batch_max_uses">最大使用次数(可选)</Label>
|
||||
<Input
|
||||
id="batch_max_uses"
|
||||
v-model="batchForm.max_uses"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="留空默认1次"
|
||||
:disabled="batchCreating"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" :disabled="batchCreating">
|
||||
<Loader2 v-if="batchCreating" class="h-4 w-4 mr-2 animate-spin" />
|
||||
{{ batchCreating ? '创建中...' : '批量创建' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card class="min-h-[600px] flex flex-col">
|
||||
<CardHeader>
|
||||
<CardTitle>卡密列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex-1 flex flex-col">
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<Skeleton v-for="i in 5" :key="i" class="h-12 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-else-if="cardKeys.length === 0"
|
||||
class="flex-1 flex flex-col items-center justify-center text-muted-foreground"
|
||||
>
|
||||
<KeyRound class="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>暂无卡密</p>
|
||||
<p class="text-sm">点击上方按钮创建卡密</p>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<template v-else>
|
||||
<div class="flex-1">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-[50px]">
|
||||
<Checkbox
|
||||
:model-value="isAllSelected"
|
||||
@update:model-value="handleSelectAll"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>卡密</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>使用次数</TableHead>
|
||||
<TableHead>有效期</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead class="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="cardKey in cardKeys" :key="cardKey.id">
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
:model-value="isSelected(cardKey.id)"
|
||||
@update:model-value="() => handleRowSelect(cardKey.id)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="font-mono text-sm">
|
||||
{{ cardKey.key }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge :variant="getCardKeyStatus(cardKey).variant">
|
||||
{{ getCardKeyStatus(cardKey).text }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{{ cardKey.used_count }} / {{ cardKey.max_uses || '∞' }}
|
||||
</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{{ formatDate(cardKey.expires_at) }}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
:model-value="cardKey.is_active"
|
||||
@update:model-value="(val) => handleToggleActive(cardKey, val)"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell class="text-right space-x-2">
|
||||
<Button variant="outline" size="sm" @click="copyKey(cardKey.key)">
|
||||
<Copy class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" @click="confirmDelete(cardKey.id)">
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between mt-4 pt-4 border-t">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-muted-foreground">每页</span>
|
||||
<Select :model-value="String(pageSize)" @update:model-value="handlePageSizeChange">
|
||||
<SelectTrigger class="w-[70px] h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="size in pageSizeOptions" :key="size" :value="String(size)">
|
||||
{{ size }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="text-sm text-muted-foreground">条,共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<Pagination v-if="totalPages > 1" :total="total" :items-per-page="pageSize" :default-page="1">
|
||||
<PaginationContent class="flex items-center gap-1">
|
||||
<PaginationFirst @click="goToPage(1)" />
|
||||
<PaginationPrevious @click="goToPage(currentPage - 1)" />
|
||||
|
||||
<template v-for="page in totalPages" :key="page">
|
||||
<PaginationItem
|
||||
v-if="page === 1 || page === totalPages || (page >= currentPage - 1 && page <= currentPage + 1)"
|
||||
:value="page"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
<Button class="w-9 h-9 p-0" :variant="page === currentPage ? 'default' : 'outline'">
|
||||
{{ page }}
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationEllipsis
|
||||
v-else-if="page === currentPage - 2 || page === currentPage + 2"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<PaginationNext @click="goToPage(currentPage + 1)" />
|
||||
<PaginationLast @click="goToPage(totalPages)" />
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Delete confirmation dialog -->
|
||||
<AlertDialog v-model:open="deleteDialogOpen">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<template v-if="pendingBatchDelete">
|
||||
确定要删除选中的 <strong>{{ selectedCount }}</strong> 个卡密吗?此操作不可撤销。
|
||||
</template>
|
||||
<template v-else>
|
||||
确定要删除此卡密吗?此操作不可撤销。
|
||||
</template>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="handleDelete" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</template>
|
||||
98
frontend/src/views/admin/DashboardPage.vue
Normal file
98
frontend/src/views/admin/DashboardPage.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAccountsStore } from '@/stores/accounts'
|
||||
import { Users, CheckCircle, XCircle, Armchair, RefreshCw } from 'lucide-vue-next'
|
||||
|
||||
const accountsStore = useAccountsStore()
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
await accountsStore.fetchAccounts()
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || '加载数据失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Dashboard</h1>
|
||||
<Button variant="outline" size="sm" @click="loadData" :disabled="accountsStore.loading">
|
||||
<RefreshCw :class="['h-4 w-4 mr-2', accountsStore.loading && 'animate-spin']" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total Teams -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">Team 总数</CardTitle>
|
||||
<Users class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold">{{ accountsStore.totalTeams }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Valid Teams -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">有效订阅</CardTitle>
|
||||
<CheckCircle class="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold text-green-600">{{ accountsStore.validTeams }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Invalid Teams -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">无效订阅</CardTitle>
|
||||
<XCircle class="h-4 w-4 text-red-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold text-red-600">{{ accountsStore.invalidTeams }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Available Seats -->
|
||||
<Card>
|
||||
<CardHeader class="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle class="text-sm font-medium">剩余席位</CardTitle>
|
||||
<Armchair class="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton v-if="accountsStore.loading" class="h-8 w-16" />
|
||||
<div v-else class="text-2xl font-bold">{{ accountsStore.totalAvailableSeats }}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<Card v-if="accountsStore.error" class="border-destructive">
|
||||
<CardContent class="pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-destructive">{{ accountsStore.error }}</p>
|
||||
<Button variant="outline" size="sm" @click="loadData">
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
85
frontend/src/views/admin/LoginPage.vue
Normal file
85
frontend/src/views/admin/LoginPage.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (!username.value.trim() || !password.value.trim()) {
|
||||
toast.error('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
|
||||
const result = await authStore.login({
|
||||
username: username.value.trim(),
|
||||
password: password.value,
|
||||
})
|
||||
|
||||
loading.value = false
|
||||
|
||||
if (result.success) {
|
||||
toast.success('登录成功')
|
||||
// Redirect to intended page or dashboard
|
||||
const redirect = route.query.redirect as string
|
||||
router.push(redirect || '/admin/dashboard')
|
||||
} else {
|
||||
toast.error(result.message || '登录失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="w-full max-w-md mx-4">
|
||||
<CardHeader class="text-center">
|
||||
<CardTitle class="text-2xl">管理后台登录</CardTitle>
|
||||
<CardDescription>请输入您的账号和密码</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form @submit.prevent="handleLogin" class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="username">账号</Label>
|
||||
<Input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
placeholder="请输入账号"
|
||||
:disabled="loading"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
:disabled="loading"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" class="w-full" :disabled="loading">
|
||||
<Loader2 v-if="loading" class="mr-2 h-4 w-4 animate-spin" />
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user