Initial commit: 网络验证平台

This commit is contained in:
Admin
2026-04-27 17:22:56 +08:00
commit afe67d704e
780 changed files with 88960 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# This is api base url
# VITE_API_BASE_URL=https://api.example.com
VITE_SERVER_API_URL=http://localhost:3000
VITE_SERVER_API_PREFIX=/api
VITE_SERVER_API_TIMEOUT=5000
+35
View File
@@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
stats.html
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
!.vscode/mcp.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.local
.env.development
.env.test
.env.production
+20
View File
@@ -0,0 +1,20 @@
{
"$schema": "https://shadcn-vue.com/schema.json",
"style": "new-york",
"typescript": true,
"tailwind": {
"config": "",
"css": "src/assets/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"composables": "@/composables",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib"
},
"iconLibrary": "lucide"
}
+39
View File
@@ -0,0 +1,39 @@
import antfu from '@antfu/eslint-config'
import pluginQuery from '@tanstack/eslint-plugin-query'
export default antfu({
type: 'app',
vue: {
overrides: {
'vue/block-lang': ['warn', {
script: { lang: ['ts', 'tsx'] },
}],
},
},
typescript: true,
formatters: {
css: true,
html: true,
markdown: 'prettier',
},
ignores: [
'**/build/**',
'**/components/ui/**',
],
settings: {
'import/core-modules': ['vue-router/auto-routes'],
},
globals: {
definePage: 'readonly',
},
imports: {
overrides: {
'perfectionist/sort-imports': ['error', {
tsconfig: { rootDir: '.' },
}],
},
},
...pluginQuery.configs['flat/recommended'],
})
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" media="(prefers-color-scheme: dark)" href="/logo.svg" />
<link rel="icon" type="image/svg+xml" media="(prefers-color-scheme: light)" href="/logo-black.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{.SiteName}}</title>
<meta name="description" content="{{.SiteDescription}}" />
<meta name="keywords" content="{{.SiteKeywords}}" />
<!-- Google Fonts - Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script>
window.__SITE_CONFIG__ = {
siteName: '{{.SiteName}}',
siteDescription: '{{.SiteDescription}}',
logoLight: '{{.LogoLight}}',
logoDark: '{{.LogoDark}}',
favicon: '{{.Favicon}}',
}
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
{
"name": "developer-platform-frontend",
"type": "module",
"version": "1.0.0",
"private": true,
"packageManager": "pnpm@10.30.3",
"description": "Developer Platform Frontend - Developer portal, application management, and agent system",
"author": "viaeon",
"license": "MIT",
"keywords": [
"vue",
"vue-router",
"vite",
"typescript",
"tailwindcss",
"shadcn-vue",
"developer",
"license-management"
],
"engines": {
"node": ">=22.15",
"pnpm": ">=10"
},
"scripts": {
"postinstall": "simple-git-hooks",
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"release": "npx bumpp"
},
"dependencies": {
"@formkit/auto-animate": "^0.9.0",
"@internationalized/date": "^3.11.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@tanstack/vue-query": "^5.92.9",
"@tanstack/vue-table": "^8.21.3",
"@unovis/ts": "^1.6.4",
"@unovis/vue": "^1.6.4",
"@vee-validate/zod": "^4.15.1",
"@vueuse/core": "^14.2.1",
"@vueuse/integrations": "^14.2.1",
"axios": "^1.13.6",
"chart.js": "^4.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"echarts": "^6.0.0",
"embla-carousel-autoplay": "^8.6.0",
"embla-carousel-vue": "^8.6.0",
"lucide-vue-next": "0.575.0",
"markdown-it": "^14.1.1",
"motion-v": "1.7.4",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"reka-ui": "^2.8.2",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"universal-cookie": "^8.0.1",
"vaul-vue": "^0.4.1",
"vee-validate": "^4.15.1",
"vue": "^3.5.29",
"vue-i18n": "^11.2.8",
"vue-input-otp": "^0.3.2",
"vue-router": "^5.0.3",
"vue-sonner": "^2.0.9",
"zod": "^4.3.6"
},
"devDependencies": {
"@antfu/eslint-config": "^7.6.1",
"@faker-js/faker": "^10.3.0",
"@iconify-json/simple-icons": "^1.2.72",
"@iconify/vue": "^5.0.0",
"@tanstack/eslint-plugin-query": "^5.91.4",
"@tanstack/vue-query-devtools": "^6.1.5",
"@types/node": "^24.11.0",
"@types/nprogress": "^0.2.3",
"@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.3",
"eslint-plugin-format": "^1.5.0",
"lint-staged": "^16.3.1",
"rollup-plugin-visualizer": "^7.0.0",
"simple-git-hooks": "^2.13.1",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vite-plugin-vue-devtools": "^8.0.7",
"vite-plugin-vue-layouts": "^0.11.0",
"vue-tsc": "^3.2.5"
},
"simple-git-hooks": {
"pre-commit": "pnpm lint-staged"
},
"lint-staged": {
"*": "eslint --fix"
}
}
+8185
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
/* /index.html 200
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-satellite"><path d="M13 7 9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4Z"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>

After

Width:  |  Height:  |  Size: 353 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-satellite"><path d="M13 7 9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4Z"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>

After

Width:  |  Height:  |  Size: 353 B

+25
View File
@@ -0,0 +1,25 @@
<script setup lang="ts">
import { ConfigProvider } from 'reka-ui'
import Loading from '@/components/loading.vue'
import { Toaster } from '@/components/ui/sonner'
import { useSystemTheme } from '@/composables/use-system-theme'
useSystemTheme()
</script>
<template>
<ConfigProvider :scroll-body="{ padding: false, margin: false }">
<Toaster />
<Suspense>
<router-view v-slot="{ Component, route }">
<component :is="Component" :key="route" />
</router-view>
<template #fallback>
<Loading />
</template>
</Suspense>
</ConfigProvider>
</template>
+118
View File
@@ -0,0 +1,118 @@
/* theme red */
.theme-red {
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
}
.theme-red.dark {
--chart-1: oklch(0.808 0.114 19.571);
--chart-2: oklch(0.637 0.237 25.331);
--chart-3: oklch(0.577 0.245 27.325);
--chart-4: oklch(0.505 0.213 27.518);
--chart-5: oklch(0.444 0.177 26.899);
}
/* theme rose */
.theme-rose {
--chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697);
}
.theme-rose.dark {
--chart-1: oklch(0.81 0.117 11.638);
--chart-2: oklch(0.645 0.246 16.439);
--chart-3: oklch(0.586 0.253 17.585);
--chart-4: oklch(0.514 0.222 16.935);
--chart-5: oklch(0.455 0.188 13.697);
}
/* theme orange */
.theme-orange {
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
}
.theme-orange.dark {
--chart-1: oklch(0.837 0.128 66.29);
--chart-2: oklch(0.705 0.213 47.604);
--chart-3: oklch(0.646 0.222 41.116);
--chart-4: oklch(0.553 0.195 38.402);
--chart-5: oklch(0.47 0.157 37.304);
}
/* theme green */
.theme-green {
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
}
.theme-green.dark {
--chart-1: oklch(0.871 0.15 154.449);
--chart-2: oklch(0.723 0.219 149.579);
--chart-3: oklch(0.627 0.194 149.214);
--chart-4: oklch(0.527 0.154 150.069);
--chart-5: oklch(0.448 0.119 151.328);
}
/* theme blue */
.theme-blue {
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
}
.theme-blue.dark {
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
}
/* theme yellow */
.theme-yellow {
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
}
.theme-yellow.dark {
--chart-1: oklch(0.905 0.182 98.111);
--chart-2: oklch(0.795 0.184 86.047);
--chart-3: oklch(0.681 0.162 75.834);
--chart-4: oklch(0.554 0.135 66.442);
--chart-5: oklch(0.476 0.114 61.907);
}
/* theme violet */
.theme-violet {
--chart-1: oklch(0.811 0.111 293.571);
--chart-2: oklch(0.606 0.25 292.717);
--chart-3: oklch(0.541 0.281 293.009);
--chart-4: oklch(0.491 0.27 292.581);
--chart-5: oklch(0.432 0.232 292.759);
}
.theme-violet.dark {
--chart-1: oklch(0.811 0.111 293.571);
--chart-2: oklch(0.606 0.25 292.717);
--chart-3: oklch(0.541 0.281 293.009);
--chart-4: oklch(0.491 0.27 292.581);
--chart-5: oklch(0.432 0.232 292.759);
}
+4
View File
@@ -0,0 +1,4 @@
<svg width="144" height="141" viewBox="0 0 144 141" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M129.189 0.0490494C128.744 0.119441 126.422 0.377545 124.03 0.635648C114.719 1.6446 109.23 2.4893 108.058 3.09936C107.119 3.56864 106.674 4.34295 106.674 5.44576C106.674 6.71281 107.424 7.51058 109.043 7.97986C110.403 8.37875 110.825 8.42567 118.87 9.52847C121.778 9.92736 124.288 10.3028 124.475 10.3732C124.663 10.4436 122.951 11.1006 120.676 11.8749C110.028 15.4414 100.412 20.7677 91.7339 27.9242C88.38 30.7164 81.6957 37.4271 79.2096 40.5009C73.8387 47.2116 69.6874 54.8139 66.5681 63.7302C65.9348 65.4665 65.3484 66.8978 65.2546 66.8978C65.1374 66.8978 63.7771 66.7336 62.2291 66.5693C52.9649 65.5134 43.1847 68.1649 34.1316 74.2186C24.7735 80.46 18.5349 87.7338 10.5371 101.742C2.53943 115.726 -1.0959 127.482 0.287874 135.014C0.89767 138.463 2.0469 140.035 3.97011 140.082C5.28352 140.105 5.37733 139.659 4.20465 139.049C3.05541 138.463 2.6567 137.9 2.32835 136.281C0.616228 128.021 6.24512 113.028 17.4325 96.1104C23.2725 87.241 28.362 81.9147 35.5622 77.1046C43.8649 71.5437 52.7069 69.033 61.1737 69.8308C64.9967 70.1828 64.6917 69.9247 64.1992 72.4822C62.2525 82.5013 63.8005 92.6378 67.9753 97.354C73.1116 103.079 81.9771 102 85.0027 95.2657C86.3395 92.2858 86.3864 87.7103 85.1434 83.9796C83.1498 78.0901 80.007 73.8197 75.4335 70.8163C73.8152 69.7604 70.4848 68.1883 69.875 68.1883C69.359 68.1883 69.4294 67.6487 70.2268 65.3257C72.3377 59.2486 75.457 52.7021 78.4122 48.244C83.2436 40.9232 91.4524 32.5701 99.1687 27.103C105.806 22.4102 113.241 18.5386 120.512 16.0045C123.772 14.8548 129.87 13.1889 130.081 13.3766C130.128 13.447 129.541 14.362 128.791 15.4414C124.78 21.0258 122.716 26.0706 122.388 30.998C122.224 33.7198 122.341 34.588 122.88 34.2595C122.998 34.1891 123.678 32.969 124.405 31.5611C126.281 27.8069 131.722 20.6738 139.579 11.6402C141.127 9.85697 142.652 7.86254 143.027 7.08823C144.552 4.03792 143.52 1.48035 140.377 0.471397C139.439 0.166366 138.102 0.0490408 134.584 0.0255769C132.074 -0.021351 129.635 0.00212153 129.189 0.0490494ZM137.117 4.92955C137.187 5.0234 136.718 5.63346 136.061 6.29045L134.865 7.48712L131.042 6.73627C128.931 6.33739 126.727 5.9385 126.14 5.8681C124.827 5.68039 124.123 5.32843 124.968 5.28151C125.296 5.28151 126.868 5.11725 128.486 4.953C131.3 4.64797 136.812 4.62451 137.117 4.92955ZM71.5168 72.5292C76.2075 74.899 79.4441 78.8175 81.3204 84.355C83.6189 91.1361 81.2266 96.8378 76.0433 96.8847C73.3227 96.9082 70.9773 95.2188 69.5936 92.2389C68.2802 89.4232 67.6938 86.5606 67.5765 82.1259C67.4593 78.3248 67.6 76.4242 68.2333 72.7403L68.4912 71.2856L69.359 71.5906C69.8515 71.7548 70.8132 72.1772 71.5168 72.5292Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1,5 @@
<svg width="144" height="141" viewBox="0 0 144 141" fill="white" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M129.189 0.0490494C128.744 0.119441 126.422 0.377545 124.03 0.635648C114.719 1.6446 109.23 2.4893 108.058 3.09936C107.119 3.56864 106.674 4.34295 106.674 5.44576C106.674 6.71281 107.424 7.51058 109.043 7.97986C110.403 8.37875 110.825 8.42567 118.87 9.52847C121.778 9.92736 124.288 10.3028 124.475 10.3732C124.663 10.4436 122.951 11.1006 120.676 11.8749C110.028 15.4414 100.412 20.7677 91.7339 27.9242C88.38 30.7164 81.6957 37.4271 79.2096 40.5009C73.8387 47.2116 69.6874 54.8139 66.5681 63.7302C65.9348 65.4665 65.3484 66.8978 65.2546 66.8978C65.1374 66.8978 63.7771 66.7336 62.2291 66.5693C52.9649 65.5134 43.1847 68.1649 34.1316 74.2186C24.7735 80.46 18.5349 87.7338 10.5371 101.742C2.53943 115.726 -1.0959 127.482 0.287874 135.014C0.89767 138.463 2.0469 140.035 3.97011 140.082C5.28352 140.105 5.37733 139.659 4.20465 139.049C3.05541 138.463 2.6567 137.9 2.32835 136.281C0.616228 128.021 6.24512 113.028 17.4325 96.1104C23.2725 87.241 28.362 81.9147 35.5622 77.1046C43.8649 71.5437 52.7069 69.033 61.1737 69.8308C64.9967 70.1828 64.6917 69.9247 64.1992 72.4822C62.2525 82.5013 63.8005 92.6378 67.9753 97.354C73.1116 103.079 81.9771 102 85.0027 95.2657C86.3395 92.2858 86.3864 87.7103 85.1434 83.9796C83.1498 78.0901 80.007 73.8197 75.4335 70.8163C73.8152 69.7604 70.4848 68.1883 69.875 68.1883C69.359 68.1883 69.4294 67.6487 70.2268 65.3257C72.3377 59.2486 75.457 52.7021 78.4122 48.244C83.2436 40.9232 91.4524 32.5701 99.1687 27.103C105.806 22.4102 113.241 18.5386 120.512 16.0045C123.772 14.8548 129.87 13.1889 130.081 13.3766C130.128 13.447 129.541 14.362 128.791 15.4414C124.78 21.0258 122.716 26.0706 122.388 30.998C122.224 33.7198 122.341 34.588 122.88 34.2595C122.998 34.1891 123.678 32.969 124.405 31.5611C126.281 27.8069 131.722 20.6738 139.579 11.6402C141.127 9.85697 142.652 7.86254 143.027 7.08823C144.552 4.03792 143.52 1.48035 140.377 0.471397C139.439 0.166366 138.102 0.0490408 134.584 0.0255769C132.074 -0.021351 129.635 0.00212153 129.189 0.0490494ZM137.117 4.92955C137.187 5.0234 136.718 5.63346 136.061 6.29045L134.865 7.48712L131.042 6.73627C128.931 6.33739 126.727 5.9385 126.14 5.8681C124.827 5.68039 124.123 5.32843 124.968 5.28151C125.296 5.28151 126.868 5.11725 128.486 4.953C131.3 4.64797 136.812 4.62451 137.117 4.92955ZM71.5168 72.5292C76.2075 74.899 79.4441 78.8175 81.3204 84.355C83.6189 91.1361 81.2266 96.8378 76.0433 96.8847C73.3227 96.9082 70.9773 95.2188 69.5936 92.2389C68.2802 89.4232 67.6938 86.5606 67.5765 82.1259C67.4593 78.3248 67.6 76.4242 68.2333 72.7403L68.4912 71.2856L69.359 71.5906C69.8515 71.7548 70.8132 72.1772 71.5168 72.5292Z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

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

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