mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-10-21 19:13:09 +00:00
Classworks KV Admin
This commit is contained in:
parent
adab9a393b
commit
28ab05e768
2
.env
Normal file
2
.env
Normal file
@ -0,0 +1,2 @@
|
||||
VITE_ASSETS_URL=https://your-assets-url.com
|
||||
VITE_API_BASE_URL=http://localhost:3030
|
8
.env.example
Normal file
8
.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# Backend API Base URL (后端服务地址)
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
|
||||
# Site Key for authentication (站点密钥)
|
||||
VITE_SITE_KEY=your-site-key-here
|
||||
|
||||
# Assets URL for app icons (应用图标资源地址)
|
||||
VITE_ASSETS_URL=http://localhost:3000/assets
|
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
20
components.json
Normal file
20
components.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://shadcn-vue.com/schema.json",
|
||||
"style": "new-york",
|
||||
"typescript": false,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/style.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"composables": "@/composables",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Classworks KV</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
9
jsconfig.json
Normal file
9
jsconfig.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
43
package.json
Normal file
43
package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "kv-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@vee-validate/zod": "^4.15.1",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"axios": "^1.12.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.544.0",
|
||||
"lucide-vue-next": "^0.544.0",
|
||||
"marked": "^16.3.0",
|
||||
"pinia": "^3.0.3",
|
||||
"radix-vue": "^1.9.17",
|
||||
"reka-ui": "^2.5.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"vee-validate": "^4.15.1",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/devtools": "^8.0.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unplugin-vue-router": "^0.15.0",
|
||||
"vite": "^7.1.7",
|
||||
"vite-plugin-vue-devtools": "^8.0.2"
|
||||
}
|
||||
}
|
3915
pnpm-lock.yaml
generated
Normal file
3915
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
10
src/App.vue
Normal file
10
src/App.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import 'vue-sonner/style.css'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toaster class="pointer-events-auto" />
|
||||
</template>
|
1
src/assets/vue.svg
Normal file
1
src/assets/vue.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
After Width: | Height: | Size: 496 B |
296
src/components/AppCard.vue
Normal file
296
src/components/AppCard.vue
Normal file
@ -0,0 +1,296 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { marked } from "marked";
|
||||
import axios from "@/lib/axios";
|
||||
import Card from "./ui/card/Card.vue";
|
||||
import CardHeader from "./ui/card/CardHeader.vue";
|
||||
import CardTitle from "./ui/card/CardTitle.vue";
|
||||
import CardDescription from "./ui/card/CardDescription.vue";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "./ui/dialog";
|
||||
import { ExternalLink } from "lucide-vue-next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
appId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
class: {
|
||||
type: null,
|
||||
required: false,
|
||||
},
|
||||
compact: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const app = ref(null);
|
||||
const readme = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const showDialog = ref(false);
|
||||
|
||||
// 从环境变量获取 assets URL
|
||||
const assetsBaseUrl = "https://zerocat-bitiful.houlangs.com/material/asset";
|
||||
|
||||
// 根据 logo_url 生成图片 URL
|
||||
const iconUrl = computed(() => {
|
||||
if (!app.value?.logo_url) return null;
|
||||
return `${assetsBaseUrl}/${app.value.logo_url}`;
|
||||
});
|
||||
|
||||
// 渲染 Markdown 为 HTML
|
||||
const renderedReadme = computed(() => {
|
||||
if (!readme.value) return "";
|
||||
return marked(readme.value);
|
||||
});
|
||||
|
||||
// 获取应用信息
|
||||
const fetchApp = async () => {
|
||||
try {
|
||||
const response = await fetch(`https://zerocat-api.houlangs.com/oauth/applications/${props.appId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch app info: ${response.status}`);
|
||||
}
|
||||
|
||||
app.value = await response.json();
|
||||
|
||||
if (app.value.homepage_url) {
|
||||
await fetchReadme();
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 检测 Git 平台并获取 README
|
||||
const fetchReadme = async () => {
|
||||
if (!app.value?.homepage_url) return;
|
||||
|
||||
const url = app.value.homepage_url;
|
||||
let readmeUrl = null;
|
||||
|
||||
try {
|
||||
// GitHub
|
||||
if (url.includes("github.com")) {
|
||||
const match = url.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const [, owner, repo] = match;
|
||||
readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`;
|
||||
// 尝试 main,失败则尝试 master
|
||||
let response = await fetch(readmeUrl);
|
||||
if (!response.ok) {
|
||||
readmeUrl = `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`;
|
||||
response = await fetch(readmeUrl);
|
||||
}
|
||||
if (response.ok) {
|
||||
readme.value = await response.text();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GitLab
|
||||
if (url.includes("gitlab.com")) {
|
||||
const match = url.match(/gitlab\.com\/([^\/]+\/[^\/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const [, path] = match;
|
||||
readmeUrl = `https://gitlab.com/${path}/-/raw/main/README.md`;
|
||||
let response = await fetch(readmeUrl);
|
||||
if (!response.ok) {
|
||||
readmeUrl = `https://gitlab.com/${path}/-/raw/master/README.md`;
|
||||
response = await fetch(readmeUrl);
|
||||
}
|
||||
if (response.ok) {
|
||||
readme.value = await response.text();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bitbucket
|
||||
if (url.includes("bitbucket.org")) {
|
||||
const match = url.match(/bitbucket\.org\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
const [, owner, repo] = match;
|
||||
readmeUrl = `https://bitbucket.org/${owner}/${repo}/raw/main/README.md`;
|
||||
let response = await fetch(readmeUrl);
|
||||
if (!response.ok) {
|
||||
readmeUrl = `https://bitbucket.org/${owner}/${repo}/raw/master/README.md`;
|
||||
response = await fetch(readmeUrl);
|
||||
}
|
||||
if (response.ok) {
|
||||
readme.value = await response.text();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gitea/Forgejo 或通用处理
|
||||
const genericMatch = url.match(
|
||||
/https?:\/\/([^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?$/
|
||||
);
|
||||
if (genericMatch) {
|
||||
const [, domain, owner, repo] = genericMatch;
|
||||
// 尝试 Gitea/Forgejo 格式
|
||||
readmeUrl = `https://${domain}/${owner}/${repo}/raw/branch/main/README.md`;
|
||||
let response = await fetch(readmeUrl);
|
||||
if (!response.ok) {
|
||||
readmeUrl = `https://${domain}/${owner}/${repo}/raw/branch/master/README.md`;
|
||||
response = await fetch(readmeUrl);
|
||||
}
|
||||
if (response.ok) {
|
||||
readme.value = await response.text();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 最后尝试直接请求原地址
|
||||
const directResponse = await fetch(url);
|
||||
if (directResponse.ok) {
|
||||
readme.value = await directResponse.text();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to fetch README:", err);
|
||||
}
|
||||
};
|
||||
|
||||
// 组件挂载时获取数据
|
||||
fetchApp();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 卡片视图 -->
|
||||
<Card
|
||||
:class="
|
||||
cn(
|
||||
'app-card cursor-pointer hover:shadow-lg transition-shadow',
|
||||
props.class
|
||||
)
|
||||
"
|
||||
@click="showDialog = true"
|
||||
>
|
||||
<CardHeader v-if="loading" class="px-6">
|
||||
<div class="animate-pulse">加载中...</div>
|
||||
</CardHeader>
|
||||
|
||||
<template v-else-if="error">
|
||||
<CardHeader class="px-6">
|
||||
<CardTitle class="text-red-500">错误</CardTitle>
|
||||
<CardDescription>{{ error }}</CardDescription>
|
||||
</CardHeader>
|
||||
</template>
|
||||
|
||||
<template v-else-if="app">
|
||||
<CardHeader class="px-6">
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
v-if="iconUrl"
|
||||
:src="iconUrl"
|
||||
:alt="app.name"
|
||||
class="w-12 h-12 rounded-lg object-cover shrink-0"
|
||||
@error="(e) => (e.target.style.display = 'none')"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<CardTitle class="text-lg truncate">{{ app.name }}</CardTitle>
|
||||
<CardDescription v-if="app.description" class="line-clamp-2">
|
||||
{{ app.description }}
|
||||
</CardDescription>
|
||||
<div class="mt-2 text-xs text-muted-foreground">
|
||||
<span>{{ app.owner?.display_name || app.owner?.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<Dialog v-model:open="showDialog">
|
||||
<DialogContent class="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader v-if="app">
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<img
|
||||
v-if="iconUrl"
|
||||
:src="iconUrl"
|
||||
:alt="app.name"
|
||||
class="w-20 h-20 rounded-lg object-cover"
|
||||
@error="(e) => (e.target.style.display = 'none')"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<DialogTitle class="text-2xl mb-2">{{ app.name }}</DialogTitle>
|
||||
<DialogDescription v-if="app.description" class="text-base">
|
||||
{{ app.description }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 应用元信息 -->
|
||||
<div class="grid grid-cols-2 gap-4 py-4 border-y">
|
||||
<div class="space-y-1">
|
||||
<div class="text-sm text-muted-foreground">开发者</div>
|
||||
<div class="font-medium">{{ app.owner?.display_name || app.owner?.username }}</div>
|
||||
</div>
|
||||
<div v-if="app.homepage_url" class="space-y-1">
|
||||
<div class="text-sm text-muted-foreground">应用主页</div>
|
||||
<a
|
||||
:href="app.homepage_url"
|
||||
target="_blank"
|
||||
class="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
访问
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="app.terms_url" class="space-y-1">
|
||||
<div class="text-sm text-muted-foreground">服务条款</div>
|
||||
<a
|
||||
:href="app.terms_url"
|
||||
target="_blank"
|
||||
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
|
||||
>
|
||||
查看
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="app.privacy_url" class="space-y-1">
|
||||
<div class="text-sm text-muted-foreground">隐私政策</div>
|
||||
<a
|
||||
:href="app.privacy_url"
|
||||
target="_blank"
|
||||
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
|
||||
>
|
||||
查看
|
||||
<ExternalLink class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<!-- README 内容 -->
|
||||
<div v-if="readme" class="mt-6">
|
||||
<h3 class="text-lg font-semibold mb-4">README</h3>
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none border rounded-lg p-6 bg-muted/30 prose-headings:font-semibold prose-a:text-primary prose-blockquote:border-l-2 prose-blockquote:pl-4 prose-img:rounded-md prose-table:w-full break-words"
|
||||
v-html="renderedReadme"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!loading && app?.homepage_url"
|
||||
class="mt-6 text-center text-muted-foreground"
|
||||
>
|
||||
无法加载 README 文件
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
400
src/components/DeviceRegisterDialog.vue
Normal file
400
src/components/DeviceRegisterDialog.vue
Normal file
@ -0,0 +1,400 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { deviceStore, generateUUID } from '@/lib/deviceStore'
|
||||
import { apiClient } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import LoginDialog from '@/components/LoginDialog.vue'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Shuffle, Download, Plus, AlertTriangle } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'openLogin'])
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const newUuid = ref('')
|
||||
const deviceName = ref('')
|
||||
const bindToAccount = ref(false)
|
||||
const accountDevices = ref([])
|
||||
const loadingDevices = ref(false)
|
||||
const activeTab = ref('load') // 'load' 或 'register'
|
||||
const showLoginDialog = ref(false) // 登录对话框状态
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 监听对话框打开,自动加载账户设备(如果已登录)
|
||||
watch(isOpen, (newVal) => {
|
||||
if (newVal && accountStore.isAuthenticated && activeTab.value === 'load') {
|
||||
loadAccountDevices()
|
||||
}
|
||||
// 切换到注册选项卡时,自动生成UUID
|
||||
if (newVal && activeTab.value === 'register' && !newUuid.value) {
|
||||
generateRandomUuid()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听选项卡切换
|
||||
watch(activeTab, (newVal) => {
|
||||
if (newVal === 'load' && accountStore.isAuthenticated && isOpen.value) {
|
||||
loadAccountDevices()
|
||||
}
|
||||
if (newVal === 'register' && !newUuid.value) {
|
||||
generateRandomUuid()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听是否登录,自动设置绑定选项
|
||||
watch(() => accountStore.isAuthenticated, (isAuth) => {
|
||||
if (isAuth && activeTab.value === 'register') {
|
||||
bindToAccount.value = true
|
||||
} else if (!isAuth) {
|
||||
bindToAccount.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// 生成随机UUID
|
||||
const generateRandomUuid = () => {
|
||||
newUuid.value = generateUUID()
|
||||
}
|
||||
|
||||
// 处理打开登录对话框
|
||||
const handleOpenLogin = () => {
|
||||
showLoginDialog.value = true
|
||||
}
|
||||
|
||||
// 处理登录成功
|
||||
const handleLoginSuccess = async (token) => {
|
||||
// 关闭登录对话框
|
||||
showLoginDialog.value = false
|
||||
// 处理登录成功逻辑
|
||||
await accountStore.login(token)
|
||||
// 自动加载账户设备
|
||||
if (activeTab.value === 'load') {
|
||||
await loadAccountDevices()
|
||||
} else {
|
||||
// 在注册模式下自动选中绑定账户
|
||||
bindToAccount.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 加载账户绑定的设备
|
||||
const loadAccountDevices = async () => {
|
||||
if (!accountStore.isAuthenticated) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingDevices.value = true
|
||||
try {
|
||||
const response = await apiClient.getAccountDevices(accountStore.token)
|
||||
accountDevices.value = response.data || []
|
||||
|
||||
if (accountDevices.value.length === 0) {
|
||||
toast.info('您的账户暂未绑定任何设备')
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('加载设备列表失败:' + error.message)
|
||||
} finally {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载选中的设备
|
||||
const loadDevice = (device) => {
|
||||
deviceStore.setDeviceUuid(device.uuid)
|
||||
isOpen.value = false
|
||||
emit('confirm')
|
||||
resetForm()
|
||||
toast.success(`已切换到设备: ${device.name || device.uuid}`)
|
||||
}
|
||||
|
||||
// 注册新设备
|
||||
const registerDevice = async () => {
|
||||
if (!newUuid.value.trim()) {
|
||||
toast.error('请输入或生成UUID')
|
||||
return
|
||||
}
|
||||
|
||||
if (!deviceName.value.trim()) {
|
||||
toast.error('请输入设备名称')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 保存UUID到本地
|
||||
deviceStore.setDeviceUuid(newUuid.value.trim())
|
||||
|
||||
// 2. 调用设备注册接口(会自动在云端创建设备)
|
||||
await apiClient.registerDevice(
|
||||
newUuid.value.trim(),
|
||||
deviceName.value.trim(),
|
||||
accountStore.isAuthenticated ? accountStore.token : null
|
||||
)
|
||||
|
||||
// 3. 如果选择绑定到账户,现在可以安全地绑定
|
||||
if (bindToAccount.value && accountStore.isAuthenticated) {
|
||||
try {
|
||||
await apiClient.bindDeviceToAccount(accountStore.token, newUuid.value.trim())
|
||||
} catch (error) {
|
||||
console.warn('设备绑定失败:', error.message)
|
||||
toast.warning('设备注册成功,但绑定到账户失败')
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(`设备注册成功!UUID: ${newUuid.value.trim()}`)
|
||||
isOpen.value = false
|
||||
emit('confirm')
|
||||
resetForm()
|
||||
|
||||
const message = bindToAccount.value
|
||||
? '设备已注册并绑定到您的账户'
|
||||
: '设备已注册'
|
||||
toast.success(message)
|
||||
} catch (error) {
|
||||
toast.error('注册失败:' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
newUuid.value = ''
|
||||
deviceName.value = ''
|
||||
bindToAccount.value = accountStore.isAuthenticated
|
||||
accountDevices.value = []
|
||||
activeTab.value = 'load'
|
||||
}
|
||||
|
||||
// 处理弹框关闭
|
||||
const handleClose = () => {
|
||||
// 在required模式下不允许关闭
|
||||
if (props.required) {
|
||||
toast.error('请先注册或加载设备')
|
||||
return
|
||||
}
|
||||
resetForm()
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
// 处理ESC键按下,在必须模式下阻止关闭
|
||||
const handleKeydown = (e) => {
|
||||
if (e.key === 'Escape' && props.required) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
toast.error('请先注册或加载设备')
|
||||
}
|
||||
}
|
||||
|
||||
// 在组件挂载和卸载时添加/移除事件监听
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:open="isOpen"
|
||||
@update:open="(val) => !val && (props.required ? isOpen = true : handleClose())">
|
||||
<DialogContent class="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>设备管理</DialogTitle>
|
||||
<DialogDescription>
|
||||
加载账户设备或注册新设备
|
||||
</DialogDescription>
|
||||
|
||||
<!-- 必需模式的提示 -->
|
||||
<div v-if="props.required" class="mt-4 p-3 rounded-lg bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-900">
|
||||
<div class="flex items-start gap-2">
|
||||
<AlertTriangle class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">请先注册或加载设备</p>
|
||||
<p class="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
您需要注册或加载一个设备才能继续使用。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs v-model="activeTab" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="load">
|
||||
<Download class="h-4 w-4 mr-2" />
|
||||
加载设备
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="register">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
注册设备
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- 加载设备选项卡 -->
|
||||
<TabsContent value="load" class="space-y-4 mt-4">
|
||||
<div v-if="!accountStore.isAuthenticated" class="text-center py-8">
|
||||
<p class="text-muted-foreground mb-4">请先登录以查看您的设备列表</p>
|
||||
<Button variant="outline" @click="handleOpenLogin">
|
||||
登录账户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadingDevices" class="text-center py-8">
|
||||
<p class="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="accountDevices.length === 0" class="text-center py-8">
|
||||
<p class="text-muted-foreground mb-4">您的账户暂未绑定任何设备</p>
|
||||
<Button variant="outline" @click="activeTab = 'register'">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
注册新设备
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<div
|
||||
v-for="device in accountDevices"
|
||||
:key="device.uuid"
|
||||
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
||||
@click="loadDevice(device)"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-base">
|
||||
{{ device.name || '未命名设备' }}
|
||||
</div>
|
||||
<code class="text-xs text-muted-foreground block mt-1">
|
||||
{{ device.uuid }}
|
||||
</code>
|
||||
<div class="text-xs text-muted-foreground mt-2">
|
||||
创建时间: {{ new Date(device.createdAt).toLocaleString('zh-CN') }}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@click.stop="loadDevice(device)"
|
||||
>
|
||||
加载
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- 注册设备选项卡 -->
|
||||
<TabsContent value="register" class="space-y-4 mt-4">
|
||||
<div class="space-y-4">
|
||||
<!-- UUID输入 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="registerUuid">设备 UUID</Label>
|
||||
<div class="flex gap-2">
|
||||
<Input
|
||||
id="registerUuid"
|
||||
v-model="newUuid"
|
||||
placeholder="自动生成或手动输入UUID"
|
||||
class="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
@click="generateRandomUuid"
|
||||
title="生成随机UUID"
|
||||
>
|
||||
<Shuffle class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设备名称输入 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="deviceName">设备名称</Label>
|
||||
<Input
|
||||
id="deviceName"
|
||||
v-model="deviceName"
|
||||
placeholder="为设备设置一个易于识别的名称"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<!-- 绑定到账户选项 -->
|
||||
<div class="flex items-start space-x-3 p-4 rounded-lg border">
|
||||
<Checkbox
|
||||
id="bindToAccount"
|
||||
v-model:checked="bindToAccount"
|
||||
:disabled="!accountStore.isAuthenticated"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<label
|
||||
for="bindToAccount"
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
绑定到账户
|
||||
</label>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{{ accountStore.isAuthenticated
|
||||
? `将此设备绑定到账户 ${accountStore.userName},绑定后可在其他设备上快速加载`
|
||||
: '登录后可以将设备绑定到您的账户'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-3">
|
||||
<p><strong>提示:</strong></p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>UUID将保存到本地浏览器存储</li>
|
||||
<li v-if="deviceName">设备名称将帮助您快速识别不同的设备</li>
|
||||
<li v-if="bindToAccount && accountStore.isAuthenticated">绑定后可在任何设备上通过账户加载</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="handleClose"
|
||||
:disabled="props.required"
|
||||
:title="props.required ? '必须先注册设备' : '取消'"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="registerDevice" :disabled="!newUuid.trim() || !deviceName.trim()">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
注册设备
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 登录对话框 -->
|
||||
<LoginDialog
|
||||
v-model="showLoginDialog"
|
||||
:on-success="handleLoginSuccess"
|
||||
/>
|
||||
</template>
|
136
src/components/EditDeviceNameDialog.vue
Normal file
136
src/components/EditDeviceNameDialog.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { apiClient } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Edit } from 'lucide-vue-next'
|
||||
import { toast } from 'vue-sonner'
|
||||
import PasswordInput from './PasswordInput.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
deviceUuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
currentName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
hasPassword: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const deviceName = ref('')
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
if (val) {
|
||||
deviceName.value = props.currentName || ''
|
||||
password.value = ''
|
||||
}
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const needsPassword = computed(() => {
|
||||
return props.hasPassword && !accountStore.isAuthenticated
|
||||
})
|
||||
|
||||
const updateDeviceName = async () => {
|
||||
if (!deviceName.value.trim()) {
|
||||
toast.error('请输入设备名称')
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await apiClient.setDeviceName(
|
||||
props.deviceUuid,
|
||||
deviceName.value.trim(),
|
||||
needsPassword.value ? password.value : null,
|
||||
accountStore.isAuthenticated ? accountStore.token : null
|
||||
)
|
||||
|
||||
toast.success('设备名称已更新')
|
||||
isOpen.value = false
|
||||
emit('success', deviceName.value.trim())
|
||||
} catch (error) {
|
||||
toast.error('更新失败:' + error.message)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<Edit class="h-5 w-5" />
|
||||
编辑设备名称
|
||||
</div>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
为设备设置一个易于识别的名称
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="deviceName">设备名称</Label>
|
||||
<Input
|
||||
id="deviceName"
|
||||
v-model="deviceName"
|
||||
placeholder="输入设备名称"
|
||||
@keyup.enter="updateDeviceName"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="needsPassword">
|
||||
<PasswordInput
|
||||
v-model="password"
|
||||
label="设备密码"
|
||||
placeholder="输入设备密码"
|
||||
:device-uuid="deviceUuid"
|
||||
:show-hint="true"
|
||||
:show-strength="false"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="accountStore.isAuthenticated && hasPassword" class="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
您已登录绑定的账户,无需输入密码
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="isOpen = false" :disabled="isSubmitting">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="updateDeviceName" :disabled="isSubmitting || !deviceName.trim()">
|
||||
{{ isSubmitting ? '更新中...' : '确认' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
41
src/components/HelloWorld.vue
Normal file
41
src/components/HelloWorld.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>{{ msg }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<button type="button" @click="count++">count is {{ count }}</button>
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test HMR
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Check out
|
||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||
>create-vue</a
|
||||
>, the official Vue + Vite starter
|
||||
</p>
|
||||
<p>
|
||||
Learn more about IDE Support for Vue in the
|
||||
<a
|
||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||
target="_blank"
|
||||
>Vue Docs Scaling up Guide</a
|
||||
>.
|
||||
</p>
|
||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
220
src/components/LoginDialog.vue
Normal file
220
src/components/LoginDialog.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- 登录弹框 -->
|
||||
<Dialog
|
||||
v-model:open="isOpen"
|
||||
:default-open="false"
|
||||
>
|
||||
<DialogContent class="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>账户登录</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择一个OAuth提供者进行登录
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div class="space-y-3">
|
||||
<div v-if="providers.length === 0" class="text-center py-4 text-muted-foreground">
|
||||
正在加载登录方式...
|
||||
</div>
|
||||
<button
|
||||
v-for="provider in providers"
|
||||
:key="provider.id"
|
||||
@click="handleLogin(provider)"
|
||||
class="w-full flex items-center gap-3 px-4 py-3 border rounded-lg hover:bg-accent transition-colors"
|
||||
:style="{ borderColor: provider.color + '20' }"
|
||||
>
|
||||
<div class="w-10 h-10 flex items-center justify-center rounded-lg" :style="{ backgroundColor: provider.color + '10' }">
|
||||
<component :is="getProviderIcon(provider.icon)" class="w-6 h-6" :style="{ color: provider.color }" />
|
||||
</div>
|
||||
<div class="flex-1 text-left">
|
||||
<div class="font-medium">{{ provider.name }}</div>
|
||||
<div class="text-sm text-muted-foreground">{{ provider.description }}</div>
|
||||
</div>
|
||||
<ChevronRight class="w-5 h-5 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 登录状态处理 -->
|
||||
<div v-if="isAuthenticating" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-background p-6 rounded-lg shadow-xl">
|
||||
<div class="flex items-center gap-3">
|
||||
<Loader2 class="w-5 h-5 animate-spin" />
|
||||
<span>正在进行身份验证...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Github, Globe, ChevronRight, Loader2 } from 'lucide-vue-next'
|
||||
import { apiClient } from '@/lib/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
onSuccess: Function,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const isOpen = ref(false)
|
||||
const providers = ref([])
|
||||
const isAuthenticating = ref(false)
|
||||
let authWindow = null
|
||||
|
||||
// 监听props的变化
|
||||
watch(() => props.modelValue, (val) => {
|
||||
isOpen.value = val
|
||||
})
|
||||
|
||||
// 监听内部状态变化,同步到父组件
|
||||
watch(isOpen, (val) => {
|
||||
emit('update:modelValue', val)
|
||||
})
|
||||
|
||||
// 获取提供者图标
|
||||
const getProviderIcon = (icon) => {
|
||||
const icons = {
|
||||
github: Github,
|
||||
zerocat: Globe,
|
||||
}
|
||||
return icons[icon] || Globe
|
||||
}
|
||||
|
||||
// 加载OAuth提供者列表
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const response = await apiClient.getOAuthProviders()
|
||||
providers.value = response.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load OAuth providers:', error)
|
||||
toast.error('无法加载登录方式', {
|
||||
description: '请检查网络连接'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = (provider) => {
|
||||
// 构建OAuth URL
|
||||
const authUrl = `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'}${provider.authUrl}`
|
||||
|
||||
// 打开新窗口进行OAuth认证
|
||||
const width = 600
|
||||
const height = 700
|
||||
const left = (window.screen.width - width) / 2
|
||||
const top = (window.screen.height - height) / 2
|
||||
|
||||
authWindow = window.open(
|
||||
authUrl,
|
||||
`oauth_${provider.id}`,
|
||||
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`
|
||||
)
|
||||
|
||||
isAuthenticating.value = true
|
||||
isOpen.value = false
|
||||
|
||||
// 监听来自OAuth窗口的消息
|
||||
const handleMessage = (event) => {
|
||||
// 验证消息来源
|
||||
if (event.origin !== window.location.origin) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.data.type === 'oauth_success') {
|
||||
clearInterval(checkInterval)
|
||||
clearTimeout(timeoutId)
|
||||
isAuthenticating.value = false
|
||||
window.removeEventListener('message', handleMessage)
|
||||
|
||||
if (authWindow && !authWindow.closed) {
|
||||
authWindow.close()
|
||||
}
|
||||
|
||||
toast.success('登录成功', {
|
||||
description: `已通过 ${event.data.provider} 登录`
|
||||
})
|
||||
|
||||
// 调用成功回调
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess(event.data.token)
|
||||
}
|
||||
} else if (event.data.type === 'oauth_error') {
|
||||
clearInterval(checkInterval)
|
||||
clearTimeout(timeoutId)
|
||||
isAuthenticating.value = false
|
||||
window.removeEventListener('message', handleMessage)
|
||||
|
||||
if (authWindow && !authWindow.closed) {
|
||||
authWindow.close()
|
||||
}
|
||||
|
||||
toast.error('登录失败', {
|
||||
description: event.data.error
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage)
|
||||
|
||||
// 监听OAuth回调(降级方案,通过轮询检测窗口关闭)
|
||||
const checkInterval = setInterval(() => {
|
||||
try {
|
||||
// 检查窗口是否关闭
|
||||
if (authWindow && authWindow.closed) {
|
||||
clearInterval(checkInterval)
|
||||
clearTimeout(timeoutId)
|
||||
window.removeEventListener('message', handleMessage)
|
||||
isAuthenticating.value = false
|
||||
|
||||
// 检查localStorage中是否有token(降级方案)
|
||||
const token = localStorage.getItem('auth_token')
|
||||
const authProvider = localStorage.getItem('auth_provider')
|
||||
|
||||
if (token) {
|
||||
toast.success('登录成功', {
|
||||
description: `已通过 ${authProvider} 登录`
|
||||
})
|
||||
|
||||
// 调用成功回调
|
||||
if (props.onSuccess) {
|
||||
props.onSuccess(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 跨域错误,忽略
|
||||
}
|
||||
}, 500)
|
||||
|
||||
// 30秒后超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(checkInterval)
|
||||
window.removeEventListener('message', handleMessage)
|
||||
if (authWindow && !authWindow.closed) {
|
||||
authWindow.close()
|
||||
}
|
||||
if (isAuthenticating.value) {
|
||||
isAuthenticating.value = false
|
||||
toast.error('登录超时', {
|
||||
description: '请重试'
|
||||
})
|
||||
}
|
||||
}, 30000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
})
|
||||
</script>
|
262
src/components/PasswordInput.vue
Normal file
262
src/components/PasswordInput.vue
Normal file
@ -0,0 +1,262 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { apiClient } from '@/lib/api'
|
||||
import { deviceStore } from '@/lib/deviceStore'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
HelpCircle,
|
||||
Info,
|
||||
AlertCircle
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps({
|
||||
// 基础属性
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: '密码'
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '输入密码'
|
||||
},
|
||||
id: {
|
||||
type: String,
|
||||
default: () => `password-${Math.random().toString(36).substr(2, 9)}`
|
||||
},
|
||||
|
||||
// 功能属性
|
||||
showHint: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
// 密码提示相关
|
||||
deviceUuid: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
customHint: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 验证相关
|
||||
required: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
confirmPassword: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
// 样式相关
|
||||
error: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// 状态
|
||||
const passwordHint = ref('')
|
||||
const showHintPopup = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const localValue = ref(props.modelValue)
|
||||
|
||||
// 获取设备UUID
|
||||
const effectiveDeviceUuid = computed(() => {
|
||||
return props.deviceUuid || deviceStore.getDeviceUuid()
|
||||
})
|
||||
|
||||
|
||||
|
||||
// 验证状态
|
||||
const validationState = computed(() => {
|
||||
const errors = []
|
||||
|
||||
if (props.required && !localValue.value) {
|
||||
errors.push('密码不能为空')
|
||||
}
|
||||
|
||||
if (props.confirmPassword && localValue.value && localValue.value !== props.confirmPassword) {
|
||||
errors.push('两次输入的密码不一致')
|
||||
}
|
||||
|
||||
if (props.error) {
|
||||
errors.push(props.error)
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
})
|
||||
|
||||
// 加载密码提示
|
||||
const loadPasswordHint = async () => {
|
||||
if (!props.showHint || props.customHint) {
|
||||
passwordHint.value = props.customHint
|
||||
return
|
||||
}
|
||||
|
||||
if (!effectiveDeviceUuid.value) return
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
// 首先尝试从设备信息API获取
|
||||
const deviceInfo = await apiClient.getDeviceInfo(effectiveDeviceUuid.value)
|
||||
if (deviceInfo.passwordHint) {
|
||||
passwordHint.value = deviceInfo.passwordHint
|
||||
} else {
|
||||
// 如果设备信息中没有,尝试从专门的密码提示API获取
|
||||
const data = await apiClient.getPasswordHint(effectiveDeviceUuid.value)
|
||||
if (data.hint) {
|
||||
passwordHint.value = data.hint
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Failed to load password hint:', error)
|
||||
// 不再使用localStorage作为后备
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 处理输入变化
|
||||
const handleInput = (event) => {
|
||||
localValue.value = event.target.value
|
||||
emit('update:modelValue', localValue.value)
|
||||
}
|
||||
|
||||
// 监听外部值变化
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
localValue.value = newVal
|
||||
})
|
||||
|
||||
// 监听自定义提示变化
|
||||
watch(() => props.customHint, (newVal) => {
|
||||
if (newVal) {
|
||||
passwordHint.value = newVal
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadPasswordHint()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<!-- 标签行 -->
|
||||
<div v-if="label" class="flex items-center justify-between">
|
||||
<Label :for="id" class="text-sm font-medium">
|
||||
{{ label }}
|
||||
<span v-if="required" class="text-red-500 ml-0.5">*</span>
|
||||
</Label>
|
||||
|
||||
<!-- 密码提示按钮 -->
|
||||
<button
|
||||
v-if="showHint && passwordHint"
|
||||
type="button"
|
||||
@click="showHintPopup = !showHintPopup"
|
||||
class="group relative"
|
||||
>
|
||||
<div class="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors">
|
||||
<HelpCircle class="h-3.5 w-3.5" />
|
||||
<span>密码提示</span>
|
||||
</div>
|
||||
|
||||
<!-- 密码提示弹出框 -->
|
||||
<div
|
||||
v-if="showHintPopup"
|
||||
class="absolute right-0 top-6 z-50 w-64 animate-in fade-in slide-in-from-top-1"
|
||||
>
|
||||
<div class="rounded-lg border bg-popover p-3 shadow-lg">
|
||||
<div class="flex items-start gap-2">
|
||||
<Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium">密码提示</p>
|
||||
<p class="text-xs text-muted-foreground">{{ passwordHint }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码输入框 -->
|
||||
<div class="relative">
|
||||
<div class="relative">
|
||||
<Input
|
||||
:id="id"
|
||||
type="text"
|
||||
:value="localValue"
|
||||
@input="handleInput"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:class="{
|
||||
'border-red-500': !validationState.isValid && localValue
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 可见性切换按钮(已移除) -->
|
||||
</div>
|
||||
|
||||
<!-- 内联密码提示(紧凑模式) -->
|
||||
<div
|
||||
v-if="showHint && passwordHint && !showHintPopup && !localValue"
|
||||
class="absolute left-0 -bottom-5 text-xs text-muted-foreground flex items-center gap-1"
|
||||
>
|
||||
<HelpCircle class="h-3 w-3" />
|
||||
<span class="truncate max-w-[200px]">{{ passwordHint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="!validationState.isValid && localValue" class="space-y-1">
|
||||
<div
|
||||
v-for="(error, index) in validationState.errors"
|
||||
:key="index"
|
||||
class="flex items-center gap-1.5 text-xs text-red-500"
|
||||
>
|
||||
<AlertCircle class="h-3 w-3" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 添加动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
</style>
|
274
src/components/ResetDevicePasswordDialog.vue
Normal file
274
src/components/ResetDevicePasswordDialog.vue
Normal file
@ -0,0 +1,274 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { apiClient } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import PasswordInput from './PasswordInput.vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
deviceUuid: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
deviceName: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'success'])
|
||||
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const password = ref('')
|
||||
const isSubmitting = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const showHintDialog = ref(false)
|
||||
const passwordHint = ref('')
|
||||
const isSettingHint = ref(false)
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => {
|
||||
if (!val) {
|
||||
password.value = ''
|
||||
}
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
const resetPassword = async () => {
|
||||
if (!password.value.trim()) {
|
||||
toast.error('请输入新密码')
|
||||
return
|
||||
}
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
// 账户拥有者使用专门的重置接口,无需当前密码
|
||||
if (accountStore.isAuthenticated) {
|
||||
await apiClient.resetDevicePasswordAsOwner(
|
||||
props.deviceUuid,
|
||||
password.value,
|
||||
null, // passwordHint 可以后续单独设置
|
||||
accountStore.token
|
||||
)
|
||||
} else {
|
||||
// 非账户拥有者使用普通设置密码接口
|
||||
await apiClient.setDevicePassword(
|
||||
props.deviceUuid,
|
||||
{ password: password.value }
|
||||
)
|
||||
}
|
||||
|
||||
toast.success('密码重置成功')
|
||||
isOpen.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
toast.error('重置密码失败:' + error.message)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeletePassword = () => {
|
||||
// 先关闭主弹框,避免重叠
|
||||
isOpen.value = false
|
||||
// 延迟打开删除确认弹框,确保主弹框完全关闭
|
||||
setTimeout(() => {
|
||||
showDeleteConfirm.value = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const deletePassword = async () => {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await apiClient.deleteDevicePassword(props.deviceUuid, null, accountStore.token)
|
||||
toast.success('密码已删除')
|
||||
showDeleteConfirm.value = false
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
toast.error('删除密码失败:' + error.message)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openHintDialog = () => {
|
||||
// 先关闭主弹框,避免重叠
|
||||
isOpen.value = false
|
||||
// 延迟打开设置提示弹框,确保主弹框完全关闭
|
||||
setTimeout(() => {
|
||||
showHintDialog.value = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const setPasswordHint = async () => {
|
||||
isSettingHint.value = true
|
||||
try {
|
||||
await apiClient.setDevicePasswordHint(
|
||||
props.deviceUuid,
|
||||
passwordHint.value,
|
||||
null,
|
||||
accountStore.token
|
||||
)
|
||||
toast.success('密码提示已设置')
|
||||
showHintDialog.value = false
|
||||
passwordHint.value = ''
|
||||
emit('success')
|
||||
} catch (error) {
|
||||
toast.error('设置密码提示失败:' + error.message)
|
||||
} finally {
|
||||
isSettingHint.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCancel = () => {
|
||||
showDeleteConfirm.value = false
|
||||
// 延迟打开主弹框,避免重叠
|
||||
setTimeout(() => {
|
||||
isOpen.value = true
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleHintCancel = () => {
|
||||
showHintDialog.value = false
|
||||
passwordHint.value = ''
|
||||
// 延迟打开主弹框,避免重叠
|
||||
setTimeout(() => {
|
||||
isOpen.value = true
|
||||
}, 100)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重置设备密码</DialogTitle>
|
||||
<DialogDescription>
|
||||
为设备 {{ deviceName || deviceUuid }} 设置新密码
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div class="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
您已登录绑定的账户,可以直接重置密码而无需输入当前密码
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
v-model="password"
|
||||
label="新密码"
|
||||
placeholder="输入新密码"
|
||||
:show-hint="false"
|
||||
:show-strength="true"
|
||||
:min-length="8"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="flex-col gap-2 sm:flex-row sm:justify-between">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
@click="confirmDeletePassword"
|
||||
:disabled="isSubmitting"
|
||||
class="flex-1 sm:flex-none"
|
||||
>
|
||||
删除密码
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="openHintDialog"
|
||||
:disabled="isSubmitting"
|
||||
class="flex-1 sm:flex-none"
|
||||
>
|
||||
设置提示
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" @click="isOpen = false" :disabled="isSubmitting">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="resetPassword" :disabled="isSubmitting || !password.trim()">
|
||||
{{ isSubmitting ? '重置中...' : '确认重置' }}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<!-- 删除密码确认对话框 -->
|
||||
<AlertDialog v-model:open="showDeleteConfirm">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除密码</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要删除设备 "{{ deviceName || deviceUuid }}" 的密码吗?删除后任何人都可以访问该设备。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="handleDeleteCancel">取消</AlertDialogCancel>
|
||||
<AlertDialogAction @click="deletePassword" :disabled="isSubmitting">
|
||||
{{ isSubmitting ? '删除中...' : '确认删除' }}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<!-- 设置密码提示对话框 -->
|
||||
<Dialog v-model:open="showHintDialog">
|
||||
<DialogContent class="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>设置密码提示</DialogTitle>
|
||||
<DialogDescription>
|
||||
为设备 {{ deviceName || deviceUuid }} 设置密码提示
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="space-y-4 py-4">
|
||||
<div>
|
||||
<Label for="hint">密码提示</Label>
|
||||
<Input
|
||||
id="hint"
|
||||
v-model="passwordHint"
|
||||
placeholder="输入密码提示(可选)"
|
||||
:disabled="isSettingHint"
|
||||
class="mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" @click="handleHintCancel" :disabled="isSettingHint">
|
||||
取消
|
||||
</Button>
|
||||
<Button @click="setPasswordHint" :disabled="isSettingHint">
|
||||
{{ isSettingHint ? '设置中...' : '确认设置' }}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
17
src/components/ui/alert-dialog/AlertDialog.vue
Normal file
17
src/components/ui/alert-dialog/AlertDialog.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, required: false },
|
||||
defaultOpen: { type: Boolean, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:open"]);
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogRoot data-slot="alert-dialog" v-bind="forwarded">
|
||||
<slot />
|
||||
</AlertDialogRoot>
|
||||
</template>
|
23
src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
23
src/components/ui/alert-dialog/AlertDialogAction.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { AlertDialogAction } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogAction
|
||||
v-bind="delegatedProps"
|
||||
:class="cn(buttonVariants(), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogAction>
|
||||
</template>
|
25
src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
25
src/components/ui/alert-dialog/AlertDialogCancel.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { AlertDialogCancel } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogCancel
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogCancel>
|
||||
</template>
|
51
src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
51
src/components/ui/alert-dialog/AlertDialogContent.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
AlertDialogContent,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits([
|
||||
"escapeKeyDown",
|
||||
"pointerDownOutside",
|
||||
"focusOutside",
|
||||
"interactOutside",
|
||||
"openAutoFocus",
|
||||
"closeAutoFocus",
|
||||
]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
|
||||
/>
|
||||
<AlertDialogContent
|
||||
data-slot="alert-dialog-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</template>
|
23
src/components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
23
src/components/ui/alert-dialog/AlertDialogDescription.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { AlertDialogDescription } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogDescription
|
||||
data-slot="alert-dialog-description"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogDescription>
|
||||
</template>
|
18
src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
18
src/components/ui/alert-dialog/AlertDialogFooter.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
:class="
|
||||
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
16
src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
16
src/components/ui/alert-dialog/AlertDialogHeader.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
23
src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
23
src/components/ui/alert-dialog/AlertDialogTitle.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { AlertDialogTitle } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTitle
|
||||
data-slot="alert-dialog-title"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('text-lg font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</AlertDialogTitle>
|
||||
</template>
|
14
src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
14
src/components/ui/alert-dialog/AlertDialogTrigger.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { AlertDialogTrigger } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AlertDialogTrigger data-slot="alert-dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</AlertDialogTrigger>
|
||||
</template>
|
9
src/components/ui/alert-dialog/index.js
Normal file
9
src/components/ui/alert-dialog/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as AlertDialog } from "./AlertDialog.vue";
|
||||
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
|
||||
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
|
||||
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
|
||||
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
|
||||
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
|
||||
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
|
||||
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
|
||||
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";
|
25
src/components/ui/badge/Badge.vue
Normal file
25
src/components/ui/badge/Badge.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Primitive } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { badgeVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
variant: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="badge"
|
||||
:class="cn(badgeVariants({ variant }), props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
24
src/components/ui/badge/index.js
Normal file
24
src/components/ui/badge/index.js
Normal file
@ -0,0 +1,24 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Badge } from "./Badge.vue";
|
||||
|
||||
export const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
24
src/components/ui/button/Button.vue
Normal file
24
src/components/ui/button/Button.vue
Normal file
@ -0,0 +1,24 @@
|
||||
<script setup>
|
||||
import { Primitive } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from ".";
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: null, required: false },
|
||||
size: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false, default: "button" },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
data-slot="button"
|
||||
:as="as"
|
||||
:as-child="asChild"
|
||||
:class="cn(buttonVariants({ variant, size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
34
src/components/ui/button/index.js
Normal file
34
src/components/ui/button/index.js
Normal file
@ -0,0 +1,34 @@
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
export { default as Button } from "./Button.vue";
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
21
src/components/ui/card/Card.vue
Normal file
21
src/components/ui/card/Card.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card"
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
21
src/components/ui/card/CardAction.vue
Normal file
21
src/components/ui/card/CardAction.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-action"
|
||||
:class="
|
||||
cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
13
src/components/ui/card/CardContent.vue
Normal file
13
src/components/ui/card/CardContent.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="card-content" :class="cn('px-6', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
16
src/components/ui/card/CardDescription.vue
Normal file
16
src/components/ui/card/CardDescription.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="card-description"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
16
src/components/ui/card/CardFooter.vue
Normal file
16
src/components/ui/card/CardFooter.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
:class="cn('flex items-center px-6 [.border-t]:pt-6', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
21
src/components/ui/card/CardHeader.vue
Normal file
21
src/components/ui/card/CardHeader.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="card-header"
|
||||
:class="
|
||||
cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
16
src/components/ui/card/CardTitle.vue
Normal file
16
src/components/ui/card/CardTitle.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3
|
||||
data-slot="card-title"
|
||||
:class="cn('leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
7
src/components/ui/card/index.js
Normal file
7
src/components/ui/card/index.js
Normal file
@ -0,0 +1,7 @@
|
||||
export { default as Card } from "./Card.vue";
|
||||
export { default as CardAction } from "./CardAction.vue";
|
||||
export { default as CardContent } from "./CardContent.vue";
|
||||
export { default as CardDescription } from "./CardDescription.vue";
|
||||
export { default as CardFooter } from "./CardFooter.vue";
|
||||
export { default as CardHeader } from "./CardHeader.vue";
|
||||
export { default as CardTitle } from "./CardTitle.vue";
|
46
src/components/ui/checkbox/Checkbox.vue
Normal file
46
src/components/ui/checkbox/Checkbox.vue
Normal file
@ -0,0 +1,46 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Check } from "lucide-vue-next";
|
||||
import { CheckboxIndicator, CheckboxRoot, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
defaultValue: { type: [Boolean, String], required: false },
|
||||
modelValue: { type: [Boolean, String, null], required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
value: { type: null, required: false },
|
||||
id: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
name: { type: String, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CheckboxRoot
|
||||
data-slot="checkbox"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckboxIndicator
|
||||
data-slot="checkbox-indicator"
|
||||
class="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<slot>
|
||||
<Check class="size-3.5" />
|
||||
</slot>
|
||||
</CheckboxIndicator>
|
||||
</CheckboxRoot>
|
||||
</template>
|
1
src/components/ui/checkbox/index.js
Normal file
1
src/components/ui/checkbox/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Checkbox } from "./Checkbox.vue";
|
18
src/components/ui/dialog/Dialog.vue
Normal file
18
src/components/ui/dialog/Dialog.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { DialogRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, required: false },
|
||||
defaultOpen: { type: Boolean, required: false },
|
||||
modal: { type: Boolean, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:open"]);
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot data-slot="dialog" v-bind="forwarded">
|
||||
<slot />
|
||||
</DialogRoot>
|
||||
</template>
|
14
src/components/ui/dialog/DialogClose.vue
Normal file
14
src/components/ui/dialog/DialogClose.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { DialogClose } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogClose data-slot="dialog-close" v-bind="props">
|
||||
<slot />
|
||||
</DialogClose>
|
||||
</template>
|
57
src/components/ui/dialog/DialogContent.vue
Normal file
57
src/components/ui/dialog/DialogContent.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import DialogOverlay from "./DialogOverlay.vue";
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits([
|
||||
"escapeKeyDown",
|
||||
"pointerDownOutside",
|
||||
"focusOutside",
|
||||
"interactOutside",
|
||||
"openAutoFocus",
|
||||
"closeAutoFocus",
|
||||
]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogContent
|
||||
data-slot="dialog-content"
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<X />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</template>
|
25
src/components/ui/dialog/DialogDescription.vue
Normal file
25
src/components/ui/dialog/DialogDescription.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogDescription, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogDescription
|
||||
data-slot="dialog-description"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-muted-foreground text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogDescription>
|
||||
</template>
|
18
src/components/ui/dialog/DialogFooter.vue
Normal file
18
src/components/ui/dialog/DialogFooter.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
:class="
|
||||
cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
16
src/components/ui/dialog/DialogHeader.vue
Normal file
16
src/components/ui/dialog/DialogHeader.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
:class="cn('flex flex-col gap-2 text-center sm:text-left', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
29
src/components/ui/dialog/DialogOverlay.vue
Normal file
29
src/components/ui/dialog/DialogOverlay.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogOverlay } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogOverlay
|
||||
data-slot="dialog-overlay"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</DialogOverlay>
|
||||
</template>
|
71
src/components/ui/dialog/DialogScrollContent.vue
Normal file
71
src/components/ui/dialog/DialogScrollContent.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { X } from "lucide-vue-next";
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
disableOutsidePointerEvents: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits([
|
||||
"escapeKeyDown",
|
||||
"pointerDownOutside",
|
||||
"focusOutside",
|
||||
"interactOutside",
|
||||
"openAutoFocus",
|
||||
"closeAutoFocus",
|
||||
]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="fixed inset-0 z-50 grid place-items-center overflow-y-auto bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
|
||||
>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'relative z-50 grid w-full max-w-lg my-8 gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg md:w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="forwarded"
|
||||
@pointer-down-outside="
|
||||
(event) => {
|
||||
const originalEvent = event.detail.originalEvent;
|
||||
const target = originalEvent.target;
|
||||
if (
|
||||
originalEvent.offsetX > target.clientWidth ||
|
||||
originalEvent.offsetY > target.clientHeight
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
|
||||
<DialogClose
|
||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
<span class="sr-only">Close</span>
|
||||
</DialogClose>
|
||||
</DialogContent>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
</template>
|
25
src/components/ui/dialog/DialogTitle.vue
Normal file
25
src/components/ui/dialog/DialogTitle.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { DialogTitle, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTitle
|
||||
data-slot="dialog-title"
|
||||
v-bind="forwardedProps"
|
||||
:class="cn('text-lg leading-none font-semibold', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</DialogTitle>
|
||||
</template>
|
14
src/components/ui/dialog/DialogTrigger.vue
Normal file
14
src/components/ui/dialog/DialogTrigger.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { DialogTrigger } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogTrigger data-slot="dialog-trigger" v-bind="props">
|
||||
<slot />
|
||||
</DialogTrigger>
|
||||
</template>
|
10
src/components/ui/dialog/index.js
Normal file
10
src/components/ui/dialog/index.js
Normal file
@ -0,0 +1,10 @@
|
||||
export { default as Dialog } from "./Dialog.vue";
|
||||
export { default as DialogClose } from "./DialogClose.vue";
|
||||
export { default as DialogContent } from "./DialogContent.vue";
|
||||
export { default as DialogDescription } from "./DialogDescription.vue";
|
||||
export { default as DialogFooter } from "./DialogFooter.vue";
|
||||
export { default as DialogHeader } from "./DialogHeader.vue";
|
||||
export { default as DialogOverlay } from "./DialogOverlay.vue";
|
||||
export { default as DialogScrollContent } from "./DialogScrollContent.vue";
|
||||
export { default as DialogTitle } from "./DialogTitle.vue";
|
||||
export { default as DialogTrigger } from "./DialogTrigger.vue";
|
22
src/components/ui/dropdown-menu/DropdownItem.vue
Normal file
22
src/components/ui/dropdown-menu/DropdownItem.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
defineProps({
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="menuitem"
|
||||
class="px-4 py-2 text-sm cursor-pointer flex items-center gap-2 hover:bg-muted hover:text-foreground transition-colors"
|
||||
:class="{ 'opacity-50 cursor-not-allowed': disabled }"
|
||||
:tabindex="disabled ? -1 : 0"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</template>
|
62
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
62
src/components/ui/dropdown-menu/DropdownMenu.vue
Normal file
@ -0,0 +1,62 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, defineProps, defineEmits } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open'])
|
||||
|
||||
const isOpen = ref(props.open)
|
||||
const menuRef = ref(null)
|
||||
|
||||
const toggle = () => {
|
||||
isOpen.value = !isOpen.value
|
||||
emit('update:open', isOpen.value)
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (isOpen.value) {
|
||||
isOpen.value = false
|
||||
emit('update:open', false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClickOutside = (event) => {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="menuRef" class="relative inline-block text-left">
|
||||
<slot name="trigger" :toggle="toggle" :open="isOpen"></slot>
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-popover border border-border z-50"
|
||||
:class="$attrs.class"
|
||||
>
|
||||
<div
|
||||
class="py-1 rounded-md bg-popover text-popover-foreground"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="options-menu"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
32
src/components/ui/input/Input.vue
Normal file
32
src/components/ui/input/Input.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { useVModel } from "@vueuse/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
defaultValue: { type: [String, Number], required: false },
|
||||
modelValue: { type: [String, Number], required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const modelValue = useVModel(props, "modelValue", emits, {
|
||||
passive: true,
|
||||
defaultValue: props.defaultValue,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="modelValue"
|
||||
data-slot="input"
|
||||
:class="
|
||||
cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
1
src/components/ui/input/index.js
Normal file
1
src/components/ui/input/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Input } from "./Input.vue";
|
29
src/components/ui/label/Label.vue
Normal file
29
src/components/ui/label/Label.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Label } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
for: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
data-slot="label"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
1
src/components/ui/label/index.js
Normal file
1
src/components/ui/label/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Label } from "./Label.vue";
|
26
src/components/ui/select/Select.vue
Normal file
26
src/components/ui/select/Select.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
import { SelectRoot, useForwardPropsEmits } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, required: false },
|
||||
defaultOpen: { type: Boolean, required: false },
|
||||
defaultValue: { type: null, required: false },
|
||||
modelValue: { type: null, required: false },
|
||||
by: { type: [String, Function], required: false },
|
||||
dir: { type: String, required: false },
|
||||
multiple: { type: Boolean, required: false },
|
||||
autocomplete: { type: String, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
name: { type: String, required: false },
|
||||
required: { type: Boolean, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:modelValue", "update:open"]);
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot data-slot="select" v-bind="forwarded">
|
||||
<slot />
|
||||
</SelectRoot>
|
||||
</template>
|
81
src/components/ui/select/SelectContent.vue
Normal file
81
src/components/ui/select/SelectContent.vue
Normal file
@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import {
|
||||
SelectContent,
|
||||
SelectPortal,
|
||||
SelectViewport,
|
||||
useForwardPropsEmits,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SelectScrollDownButton, SelectScrollUpButton } from ".";
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
forceMount: { type: Boolean, required: false },
|
||||
position: { type: String, required: false, default: "popper" },
|
||||
bodyLock: { type: Boolean, required: false },
|
||||
side: { type: null, required: false },
|
||||
sideOffset: { type: Number, required: false },
|
||||
sideFlip: { type: Boolean, required: false },
|
||||
align: { type: null, required: false },
|
||||
alignOffset: { type: Number, required: false },
|
||||
alignFlip: { type: Boolean, required: false },
|
||||
avoidCollisions: { type: Boolean, required: false },
|
||||
collisionBoundary: { type: null, required: false },
|
||||
collisionPadding: { type: [Number, Object], required: false },
|
||||
arrowPadding: { type: Number, required: false },
|
||||
sticky: { type: String, required: false },
|
||||
hideWhenDetached: { type: Boolean, required: false },
|
||||
positionStrategy: { type: String, required: false },
|
||||
updatePositionStrategy: { type: String, required: false },
|
||||
disableUpdateOnLayoutShift: { type: Boolean, required: false },
|
||||
prioritizePosition: { type: Boolean, required: false },
|
||||
reference: { type: null, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits([
|
||||
"closeAutoFocus",
|
||||
"escapeKeyDown",
|
||||
"pointerDownOutside",
|
||||
]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
data-slot="select-content"
|
||||
v-bind="{ ...forwarded, ...$attrs }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectViewport
|
||||
:class="
|
||||
cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--reka-select-trigger-height)] w-full min-w-[var(--reka-select-trigger-width)] scroll-my-1',
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</SelectViewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</template>
|
14
src/components/ui/select/SelectGroup.vue
Normal file
14
src/components/ui/select/SelectGroup.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { SelectGroup } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectGroup data-slot="select-group" v-bind="props">
|
||||
<slot />
|
||||
</SelectGroup>
|
||||
</template>
|
47
src/components/ui/select/SelectItem.vue
Normal file
47
src/components/ui/select/SelectItem.vue
Normal file
@ -0,0 +1,47 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Check } from "lucide-vue-next";
|
||||
import {
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
useForwardProps,
|
||||
} from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: null, required: true },
|
||||
disabled: { type: Boolean, required: false },
|
||||
textValue: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItem
|
||||
data-slot="select-item"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
`focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2`,
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectItemIndicator>
|
||||
<Check class="size-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</SelectItem>
|
||||
</template>
|
14
src/components/ui/select/SelectItemText.vue
Normal file
14
src/components/ui/select/SelectItemText.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { SelectItemText } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectItemText data-slot="select-item-text" v-bind="props">
|
||||
<slot />
|
||||
</SelectItemText>
|
||||
</template>
|
20
src/components/ui/select/SelectLabel.vue
Normal file
20
src/components/ui/select/SelectLabel.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import { SelectLabel } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
for: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectLabel
|
||||
data-slot="select-label"
|
||||
:class="cn('px-2 py-1.5 text-sm font-medium', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</SelectLabel>
|
||||
</template>
|
30
src/components/ui/select/SelectScrollDownButton.vue
Normal file
30
src/components/ui/select/SelectScrollDownButton.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronDown } from "lucide-vue-next";
|
||||
import { SelectScrollDownButton, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn('flex cursor-default items-center justify-center py-1', props.class)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<ChevronDown class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollDownButton>
|
||||
</template>
|
30
src/components/ui/select/SelectScrollUpButton.vue
Normal file
30
src/components/ui/select/SelectScrollUpButton.vue
Normal file
@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronUp } from "lucide-vue-next";
|
||||
import { SelectScrollUpButton, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn('flex cursor-default items-center justify-center py-1', props.class)
|
||||
"
|
||||
>
|
||||
<slot>
|
||||
<ChevronUp class="size-4" />
|
||||
</slot>
|
||||
</SelectScrollUpButton>
|
||||
</template>
|
21
src/components/ui/select/SelectSeparator.vue
Normal file
21
src/components/ui/select/SelectSeparator.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { SelectSeparator } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectSeparator
|
||||
data-slot="select-separator"
|
||||
v-bind="delegatedProps"
|
||||
:class="cn('bg-border pointer-events-none -mx-1 my-1 h-px', props.class)"
|
||||
/>
|
||||
</template>
|
37
src/components/ui/select/SelectTrigger.vue
Normal file
37
src/components/ui/select/SelectTrigger.vue
Normal file
@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { ChevronDown } from "lucide-vue-next";
|
||||
import { SelectIcon, SelectTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
disabled: { type: Boolean, required: false },
|
||||
reference: { type: null, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
size: { type: String, required: false, default: "default" },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class", "size");
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectTrigger
|
||||
data-slot="select-trigger"
|
||||
:data-size="size"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
`border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="size-4 opacity-50" />
|
||||
</SelectIcon>
|
||||
</SelectTrigger>
|
||||
</template>
|
15
src/components/ui/select/SelectValue.vue
Normal file
15
src/components/ui/select/SelectValue.vue
Normal file
@ -0,0 +1,15 @@
|
||||
<script setup>
|
||||
import { SelectValue } from "reka-ui";
|
||||
|
||||
const props = defineProps({
|
||||
placeholder: { type: String, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectValue data-slot="select-value" v-bind="props">
|
||||
<slot />
|
||||
</SelectValue>
|
||||
</template>
|
11
src/components/ui/select/index.js
Normal file
11
src/components/ui/select/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
export { default as Select } from "./Select.vue";
|
||||
export { default as SelectContent } from "./SelectContent.vue";
|
||||
export { default as SelectGroup } from "./SelectGroup.vue";
|
||||
export { default as SelectItem } from "./SelectItem.vue";
|
||||
export { default as SelectItemText } from "./SelectItemText.vue";
|
||||
export { default as SelectLabel } from "./SelectLabel.vue";
|
||||
export { default as SelectScrollDownButton } from "./SelectScrollDownButton.vue";
|
||||
export { default as SelectScrollUpButton } from "./SelectScrollUpButton.vue";
|
||||
export { default as SelectSeparator } from "./SelectSeparator.vue";
|
||||
export { default as SelectTrigger } from "./SelectTrigger.vue";
|
||||
export { default as SelectValue } from "./SelectValue.vue";
|
28
src/components/ui/separator/Separator.vue
Normal file
28
src/components/ui/separator/Separator.vue
Normal file
@ -0,0 +1,28 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { Separator } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
orientation: { type: String, required: false, default: "horizontal" },
|
||||
decorative: { type: Boolean, required: false, default: true },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Separator
|
||||
data-slot="separator-root"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
`bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px`,
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
1
src/components/ui/separator/index.js
Normal file
1
src/components/ui/separator/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Separator } from "./Separator.vue";
|
39
src/components/ui/sonner/Sonner.vue
Normal file
39
src/components/ui/sonner/Sonner.vue
Normal file
@ -0,0 +1,39 @@
|
||||
<script setup>
|
||||
import { Toaster as Sonner } from "vue-sonner";
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, required: false },
|
||||
invert: { type: Boolean, required: false },
|
||||
theme: { type: String, required: false },
|
||||
position: { type: String, required: false },
|
||||
closeButtonPosition: { type: String, required: false },
|
||||
hotkey: { type: Array, required: false },
|
||||
richColors: { type: Boolean, required: false },
|
||||
expand: { type: Boolean, required: false },
|
||||
duration: { type: Number, required: false },
|
||||
gap: { type: Number, required: false },
|
||||
visibleToasts: { type: Number, required: false },
|
||||
closeButton: { type: Boolean, required: false },
|
||||
toastOptions: { type: Object, required: false },
|
||||
class: { type: String, required: false },
|
||||
style: { type: Object, required: false },
|
||||
offset: { type: [Object, String, Number], required: false },
|
||||
mobileOffset: { type: [Object, String, Number], required: false },
|
||||
dir: { type: String, required: false },
|
||||
swipeDirections: { type: Array, required: false },
|
||||
icons: { type: Object, required: false },
|
||||
containerAriaLabel: { type: String, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Sonner
|
||||
class="toaster group"
|
||||
v-bind="props"
|
||||
:style="{
|
||||
'--normal-bg': 'var(--popover)',
|
||||
'--normal-text': 'var(--popover-foreground)',
|
||||
'--normal-border': 'var(--border)',
|
||||
}"
|
||||
/>
|
||||
</template>
|
1
src/components/ui/sonner/index.js
Normal file
1
src/components/ui/sonner/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default as Toaster } from "./Sonner.vue";
|
18
src/components/ui/table/Table.vue
Normal file
18
src/components/ui/table/Table.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-slot="table-container" class="relative w-full overflow-auto">
|
||||
<table
|
||||
data-slot="table"
|
||||
:class="cn('w-full caption-bottom text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
16
src/components/ui/table/TableBody.vue
Normal file
16
src/components/ui/table/TableBody.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
:class="cn('[&_tr:last-child]:border-0', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
16
src/components/ui/table/TableCaption.vue
Normal file
16
src/components/ui/table/TableCaption.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
:class="cn('text-muted-foreground mt-4 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</caption>
|
||||
</template>
|
21
src/components/ui/table/TableCell.vue
Normal file
21
src/components/ui/table/TableCell.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
:class="
|
||||
cn(
|
||||
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
31
src/components/ui/table/TableEmpty.vue
Normal file
31
src/components/ui/table/TableEmpty.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import TableCell from "./TableCell.vue";
|
||||
import TableRow from "./TableRow.vue";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
colspan: { type: Number, required: false, default: 1 },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
:class="
|
||||
cn(
|
||||
'p-4 whitespace-nowrap align-middle text-sm text-foreground',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<div class="flex items-center justify-center py-10">
|
||||
<slot />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</template>
|
18
src/components/ui/table/TableFooter.vue
Normal file
18
src/components/ui/table/TableFooter.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
:class="
|
||||
cn('bg-muted/50 border-t font-medium [&>tr]:last:border-b-0', props.class)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tfoot>
|
||||
</template>
|
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<th
|
||||
data-slot="table-head"
|
||||
:class="
|
||||
cn(
|
||||
'text-muted-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
13
src/components/ui/table/TableHeader.vue
Normal file
13
src/components/ui/table/TableHeader.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<thead data-slot="table-header" :class="cn('[&_tr]:border-b', props.class)">
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
21
src/components/ui/table/TableRow.vue
Normal file
21
src/components/ui/table/TableRow.vue
Normal file
@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
:class="
|
||||
cn(
|
||||
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
9
src/components/ui/table/index.js
Normal file
9
src/components/ui/table/index.js
Normal file
@ -0,0 +1,9 @@
|
||||
export { default as Table } from "./Table.vue";
|
||||
export { default as TableBody } from "./TableBody.vue";
|
||||
export { default as TableCaption } from "./TableCaption.vue";
|
||||
export { default as TableCell } from "./TableCell.vue";
|
||||
export { default as TableEmpty } from "./TableEmpty.vue";
|
||||
export { default as TableFooter } from "./TableFooter.vue";
|
||||
export { default as TableHead } from "./TableHead.vue";
|
||||
export { default as TableHeader } from "./TableHeader.vue";
|
||||
export { default as TableRow } from "./TableRow.vue";
|
7
src/components/ui/table/utils.js
Normal file
7
src/components/ui/table/utils.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { isFunction } from "@tanstack/vue-table";
|
||||
|
||||
export function valueUpdater(updaterOrValue, ref) {
|
||||
ref.value = isFunction(updaterOrValue)
|
||||
? updaterOrValue(ref.value)
|
||||
: updaterOrValue;
|
||||
}
|
31
src/components/ui/tabs/Tabs.vue
Normal file
31
src/components/ui/tabs/Tabs.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { TabsRoot, useForwardPropsEmits } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
defaultValue: { type: null, required: false },
|
||||
orientation: { type: String, required: false },
|
||||
dir: { type: String, required: false },
|
||||
activationMode: { type: String, required: false },
|
||||
modelValue: { type: null, required: false },
|
||||
unmountOnHide: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
const emits = defineEmits(["update:modelValue"]);
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
data-slot="tabs"
|
||||
v-bind="forwarded"
|
||||
:class="cn('flex flex-col gap-2', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
25
src/components/ui/tabs/TabsContent.vue
Normal file
25
src/components/ui/tabs/TabsContent.vue
Normal file
@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { TabsContent } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [String, Number], required: true },
|
||||
forceMount: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent
|
||||
data-slot="tabs-content"
|
||||
:class="cn('flex-1 outline-none', props.class)"
|
||||
v-bind="delegatedProps"
|
||||
>
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
29
src/components/ui/tabs/TabsList.vue
Normal file
29
src/components/ui/tabs/TabsList.vue
Normal file
@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { TabsList } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
loop: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
data-slot="tabs-list"
|
||||
v-bind="delegatedProps"
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
32
src/components/ui/tabs/TabsTrigger.vue
Normal file
32
src/components/ui/tabs/TabsTrigger.vue
Normal file
@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import { reactiveOmit } from "@vueuse/core";
|
||||
import { TabsTrigger, useForwardProps } from "reka-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
value: { type: [String, Number], required: true },
|
||||
disabled: { type: Boolean, required: false },
|
||||
asChild: { type: Boolean, required: false },
|
||||
as: { type: null, required: false },
|
||||
class: { type: null, required: false },
|
||||
});
|
||||
|
||||
const delegatedProps = reactiveOmit(props, "class");
|
||||
|
||||
const forwardedProps = useForwardProps(delegatedProps);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
data-slot="tabs-trigger"
|
||||
v-bind="forwardedProps"
|
||||
:class="
|
||||
cn(
|
||||
`data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
4
src/components/ui/tabs/index.js
Normal file
4
src/components/ui/tabs/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as Tabs } from "./Tabs.vue";
|
||||
export { default as TabsContent } from "./TabsContent.vue";
|
||||
export { default as TabsList } from "./TabsList.vue";
|
||||
export { default as TabsTrigger } from "./TabsTrigger.vue";
|
126
src/composables/useOAuthCallback.js
Normal file
126
src/composables/useOAuthCallback.js
Normal file
@ -0,0 +1,126 @@
|
||||
import { onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
/**
|
||||
* 处理OAuth回调
|
||||
* 检查URL参数中是否有OAuth回调信息
|
||||
*/
|
||||
export function useOAuthCallback() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const accountStore = useAccountStore()
|
||||
|
||||
const handleOAuthCallback = async () => {
|
||||
const { token, provider, success, error } = route.query
|
||||
|
||||
// 检查是否是OAuth回调
|
||||
if (!success && !error) {
|
||||
return
|
||||
}
|
||||
|
||||
// 处理成功回调
|
||||
if (success === 'true' && token) {
|
||||
try {
|
||||
// 保存token到localStorage
|
||||
localStorage.setItem('auth_token', token)
|
||||
localStorage.setItem('auth_provider', provider)
|
||||
|
||||
// 登录到store
|
||||
await accountStore.login(token)
|
||||
|
||||
// 显示成功提示
|
||||
toast.success('登录成功', {
|
||||
description: `已通过 ${provider} 登录`
|
||||
})
|
||||
|
||||
// 清除URL参数
|
||||
router.replace({ query: {} })
|
||||
|
||||
// 触发storage事件,通知其他窗口
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: 'auth_token',
|
||||
newValue: token,
|
||||
url: window.location.href
|
||||
}))
|
||||
|
||||
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
||||
if (window.opener) {
|
||||
// 通知父窗口登录成功
|
||||
window.opener.postMessage({
|
||||
type: 'oauth_success',
|
||||
token,
|
||||
provider
|
||||
}, window.location.origin)
|
||||
|
||||
// 延迟关闭窗口,确保消息已发送
|
||||
setTimeout(() => {
|
||||
window.close()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
toast.error('登录失败', {
|
||||
description: err.message || '处理登录信息时出错'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理错误回调
|
||||
if (success === 'false' || error) {
|
||||
const errorMessages = {
|
||||
'invalid_state': 'State验证失败,可能存在安全风险',
|
||||
'access_denied': '用户拒绝了授权请求',
|
||||
'temporarily_unavailable': '服务暂时不可用,请稍后重试'
|
||||
}
|
||||
|
||||
const errorMsg = errorMessages[error] || error || '登录过程中出现错误'
|
||||
|
||||
toast.error('登录失败', {
|
||||
description: errorMsg
|
||||
})
|
||||
|
||||
// 清除URL参数
|
||||
router.replace({ query: {} })
|
||||
|
||||
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
||||
if (window.opener) {
|
||||
// 通知父窗口登录失败
|
||||
window.opener.postMessage({
|
||||
type: 'oauth_error',
|
||||
error: errorMsg
|
||||
}, window.location.origin)
|
||||
|
||||
// 延迟关闭窗口
|
||||
setTimeout(() => {
|
||||
window.close()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleOAuthCallback()
|
||||
})
|
||||
|
||||
// 监听storage事件,处理其他标签页的登录
|
||||
const handleStorageChange = (e) => {
|
||||
if (e.key === 'auth_token' && e.newValue) {
|
||||
// 其他标签页已登录,刷新当前页面的状态
|
||||
accountStore.login(e.newValue)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('storage', handleStorageChange)
|
||||
})
|
||||
|
||||
return {
|
||||
handleOAuthCallback
|
||||
}
|
||||
}
|
533
src/lib/api.js
Normal file
533
src/lib/api.js
Normal file
@ -0,0 +1,533 @@
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
|
||||
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
|
||||
|
||||
class ApiClient {
|
||||
constructor(baseUrl, siteKey) {
|
||||
this.baseUrl = baseUrl
|
||||
this.siteKey = siteKey
|
||||
}
|
||||
|
||||
async fetch(endpoint, options = {}) {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'x-site-key': this.siteKey,
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Unknown error' }))
|
||||
throw new Error(error.message || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// 带认证的fetch
|
||||
async authenticatedFetch(endpoint, options = {}, token = null) {
|
||||
const headers = {
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
// 如果提供了token,添加Authorization头
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
return this.fetch(endpoint, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
// 应用相关 API
|
||||
async getApps(params = {}) {
|
||||
const query = new URLSearchParams(params).toString()
|
||||
return this.fetch(`/apps${query ? `?${query}` : ''}`)
|
||||
}
|
||||
|
||||
async getApp(appId) {
|
||||
return this.fetch(`/apps/info/${appId}`)
|
||||
}
|
||||
|
||||
async getAppInstallations(appId, deviceUuid, params = {}) {
|
||||
const query = new URLSearchParams(params).toString()
|
||||
return this.fetch(`/apps/info/${appId}/device-installations${query ? `?${query}` : ''}`, {
|
||||
headers: {
|
||||
'x-device-uuid': deviceUuid,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Token 管理 API
|
||||
async getDeviceTokens(deviceUuid, options = {}) {
|
||||
const params = new URLSearchParams({
|
||||
uuid: deviceUuid,
|
||||
});
|
||||
|
||||
return this.fetch(`/apps/tokens?${params}`);
|
||||
}
|
||||
|
||||
async revokeToken(targetToken, authOptions = {}) {
|
||||
const { deviceUuid, password, usePathParam = true, bearerToken } = authOptions;
|
||||
|
||||
if (usePathParam) {
|
||||
// 使用路径参数方式 (推荐)
|
||||
const headers = {};
|
||||
|
||||
if (bearerToken) {
|
||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
} else if (deviceUuid) {
|
||||
headers['x-device-uuid'] = deviceUuid;
|
||||
if (password) {
|
||||
headers['x-device-password'] = password;
|
||||
}
|
||||
}
|
||||
|
||||
return this.fetch(`/apps/tokens/${targetToken}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
} else {
|
||||
// 使用查询参数方式 (向后兼容)
|
||||
const params = new URLSearchParams({ token: targetToken });
|
||||
const headers = {};
|
||||
|
||||
if (bearerToken) {
|
||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||
} else if (deviceUuid) {
|
||||
headers['x-device-uuid'] = deviceUuid;
|
||||
if (password) {
|
||||
headers['x-device-password'] = password;
|
||||
}
|
||||
}
|
||||
|
||||
return this.fetch(`/apps/tokens?${params}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId)
|
||||
async authorizeApp(appId, deviceUuid, options = {}) {
|
||||
const { password, note, token } = options;
|
||||
|
||||
const headers = {
|
||||
'x-device-uuid': deviceUuid,
|
||||
};
|
||||
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 使用新的安装接口
|
||||
return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}?password=${password}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ note: note || '应用授权' }),
|
||||
});
|
||||
}
|
||||
|
||||
// 设备级别的应用卸载,使用新的 uninstall 接口
|
||||
async revokeDeviceToken(deviceUuid, installId, password = null, token = null) {
|
||||
const params = new URLSearchParams({ uuid: deviceUuid });
|
||||
const headers = {};
|
||||
|
||||
if (password) {
|
||||
params.set('password', password);
|
||||
}
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return this.fetch(`/apps/devices/${deviceUuid}/uninstall/${installId}?${params}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// 设备密码管理 API
|
||||
async setDevicePassword(deviceUuid, data, token = null) {
|
||||
const { newPassword, currentPassword, passwordHint } = data;
|
||||
|
||||
// 检查设备是否已设置密码
|
||||
const deviceInfo = await this.getDeviceInfo(deviceUuid);
|
||||
const hasPassword = deviceInfo.hasPassword;
|
||||
|
||||
if (hasPassword) {
|
||||
// 使用PUT修改密码
|
||||
const params = new URLSearchParams();
|
||||
params.set('uuid', deviceUuid);
|
||||
params.set('newPassword', newPassword);
|
||||
if (currentPassword) {
|
||||
params.set('currentPassword', currentPassword);
|
||||
}
|
||||
if (passwordHint !== undefined) {
|
||||
params.set('passwordHint', passwordHint);
|
||||
}
|
||||
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
});
|
||||
} else {
|
||||
// 使用POST初次设置密码
|
||||
const params = new URLSearchParams();
|
||||
params.set('newPassword', newPassword);
|
||||
if (passwordHint !== undefined) {
|
||||
params.set('passwordHint', passwordHint);
|
||||
}
|
||||
|
||||
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteDevicePassword(deviceUuid, password, token = null) {
|
||||
const params = new URLSearchParams({ uuid: deviceUuid });
|
||||
const headers = {};
|
||||
|
||||
// 如果提供了账户token,使用JWT认证
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
} else if (password) {
|
||||
params.set('password', password);
|
||||
}
|
||||
|
||||
return this.fetch(`/devices/${deviceUuid}/password?${params}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async setDevicePasswordHint(deviceUuid, hint, password = null, token = null) {
|
||||
return this.authenticatedFetch(`/devices/${deviceUuid}/password-hint`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ hint, password }),
|
||||
}, token)
|
||||
}
|
||||
|
||||
async getDevicePasswordHint(deviceUuid) {
|
||||
return this.fetch(`/devices/${deviceUuid}/password-hint`)
|
||||
}
|
||||
|
||||
// 设备授权相关 API
|
||||
async bindDeviceCode(deviceCode, token) {
|
||||
return this.fetch('/auth/device/bind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ device_code: deviceCode, token }),
|
||||
})
|
||||
}
|
||||
|
||||
async getDeviceCodeStatus(deviceCode) {
|
||||
return this.fetch(`/auth/device/status?device_code=${deviceCode}`)
|
||||
}
|
||||
|
||||
// KV 存储管理 API
|
||||
async listKVItems(token, params = {}) {
|
||||
const query = new URLSearchParams(params).toString()
|
||||
return this.fetch(`/kv${query ? `?${query}` : ''}`, {
|
||||
headers: { 'x-app-token': token }
|
||||
})
|
||||
}
|
||||
|
||||
async getKVItem(token, key) {
|
||||
return this.fetch(`/kv/${encodeURIComponent(key)}`, {
|
||||
headers: { 'x-app-token': token }
|
||||
})
|
||||
}
|
||||
|
||||
async setKVItem(token, key, value) {
|
||||
return this.fetch(`/kv/${encodeURIComponent(key)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'x-app-token': token },
|
||||
body: JSON.stringify(value),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteKVItem(token, key) {
|
||||
return this.fetch(`/kv/${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'x-app-token': token }
|
||||
})
|
||||
}
|
||||
|
||||
async getKVKeys(token, pattern = '*') {
|
||||
return this.fetch(`/kv/_keys?pattern=${encodeURIComponent(pattern)}`, {
|
||||
headers: { 'x-app-token': token }
|
||||
})
|
||||
}
|
||||
|
||||
// 设备信息 API
|
||||
async getDeviceInfo(deviceUuid) {
|
||||
return this.fetch(`/devices/${deviceUuid}`)
|
||||
}
|
||||
|
||||
// 获取设备应用列表 API (公开接口,无需认证)
|
||||
async getDeviceApps(deviceUuid) {
|
||||
return this.fetch(`/apps/devices/${deviceUuid}/apps`)
|
||||
}
|
||||
|
||||
// 密码提示管理 API
|
||||
async getPasswordHint(deviceUuid) {
|
||||
try {
|
||||
const response = await this.fetch(`/devices/${deviceUuid}`)
|
||||
return { hint: response.device?.passwordHint || '' }
|
||||
} catch (error) {
|
||||
// 如果接口不存在,返回空提示
|
||||
return { hint: '' }
|
||||
}
|
||||
}
|
||||
|
||||
async setPasswordHint(deviceUuid, hint, password) {
|
||||
try {
|
||||
return await this.fetch(`/devices/${deviceUuid}/password-hint?password=${encodeURIComponent(password)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'x-device-uuid': deviceUuid,
|
||||
},
|
||||
body: JSON.stringify({ passwordHint: hint }),
|
||||
})
|
||||
} catch (error) {
|
||||
// 如果接口不存在,忽略错误
|
||||
console.log('Password hint API not available')
|
||||
return { success: false }
|
||||
}
|
||||
}
|
||||
|
||||
// 账户相关 API
|
||||
async getOAuthProviders() {
|
||||
return this.fetch('/accounts/oauth/providers')
|
||||
}
|
||||
|
||||
async getAccountProfile(token) {
|
||||
return this.fetch('/accounts/profile', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
}
|
||||
|
||||
async getAccountDevices(token) {
|
||||
return this.fetch('/accounts/devices', {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
}
|
||||
|
||||
async bindDevice(token, deviceUuid) {
|
||||
return this.fetch('/accounts/devices/bind', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ uuid: deviceUuid }),
|
||||
})
|
||||
}
|
||||
|
||||
async unbindDevice(token, deviceUuid) {
|
||||
return this.fetch('/accounts/devices/unbind', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ uuid: deviceUuid }),
|
||||
})
|
||||
}
|
||||
|
||||
async getDeviceAccount(deviceUuid) {
|
||||
return this.fetch(`/accounts/device/${deviceUuid}/account`)
|
||||
}
|
||||
|
||||
// 绑定设备到当前账户
|
||||
async bindDeviceToAccount(token, deviceUuid) {
|
||||
return this.authenticatedFetch('/accounts/devices/bind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: deviceUuid }),
|
||||
}, token)
|
||||
}
|
||||
|
||||
// 解绑设备
|
||||
async unbindDeviceFromAccount(token, deviceUuid) {
|
||||
return this.authenticatedFetch('/accounts/devices/unbind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid: deviceUuid }),
|
||||
}, token)
|
||||
}
|
||||
|
||||
// 批量解绑设备
|
||||
async batchUnbindDevices(token, deviceUuids) {
|
||||
return this.authenticatedFetch('/accounts/devices/unbind', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuids: deviceUuids }),
|
||||
}, token)
|
||||
}
|
||||
|
||||
// 设备名称管理 API
|
||||
async setDeviceName(deviceUuid, name, password = null, token = null) {
|
||||
const headers = {
|
||||
'x-device-uuid': deviceUuid,
|
||||
};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
if (password) {
|
||||
headers['x-device-password'] = password;
|
||||
}
|
||||
|
||||
return this.fetch(`/devices/${deviceUuid}/name`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
}
|
||||
|
||||
// 修改设备密码 API
|
||||
async updateDevicePassword(deviceUuid, currentPassword, newPassword, passwordHint = null, token = null) {
|
||||
const headers = {
|
||||
'x-device-uuid': deviceUuid,
|
||||
};
|
||||
|
||||
// 如果提供了账户token,使用JWT认证(账户拥有者无需当前密码)
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
} else if (currentPassword) {
|
||||
headers['x-device-password'] = currentPassword;
|
||||
}
|
||||
|
||||
const body = { newPassword, passwordHint };
|
||||
// 只有在非账户拥有者时才需要发送当前密码
|
||||
if (!token && currentPassword) {
|
||||
body.currentPassword = currentPassword;
|
||||
}
|
||||
|
||||
return this.fetch(`/devices/${deviceUuid}/password`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// 验证设备密码 API
|
||||
async verifyDevicePassword(deviceUuid, password) {
|
||||
return this.fetch(`/devices/${deviceUuid}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'x-device-uuid': deviceUuid,
|
||||
'x-device-password': password,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 设备注册 API
|
||||
async registerDevice(uuid, deviceName, token = null) {
|
||||
return this.authenticatedFetch('/devices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ uuid, deviceName }),
|
||||
}, token)
|
||||
}
|
||||
|
||||
// 账户拥有者重置设备密码 API
|
||||
async resetDevicePasswordAsOwner(deviceUuid, newPassword, passwordHint = null, token) {
|
||||
return this.fetch(`/devices/${deviceUuid}/password`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'x-device-uuid': deviceUuid,
|
||||
},
|
||||
body: JSON.stringify({ newPassword, passwordHint }),
|
||||
});
|
||||
}
|
||||
|
||||
// 兼容性方法 - 保持旧的API调用方式
|
||||
async getTokens(deviceUuid, options = {}) {
|
||||
return this.getDeviceTokens(deviceUuid, options);
|
||||
}
|
||||
|
||||
async deleteToken(targetToken, deviceUuid = null) {
|
||||
// 向后兼容的删除方法
|
||||
return this.revokeToken(targetToken, { deviceUuid, usePathParam: true });
|
||||
}
|
||||
|
||||
// 便捷方法:使用设备UUID和密码删除token
|
||||
async revokeTokenByDevice(targetToken, deviceUuid, password = null) {
|
||||
return this.revokeToken(targetToken, {
|
||||
deviceUuid,
|
||||
password,
|
||||
usePathParam: true
|
||||
});
|
||||
}
|
||||
|
||||
// 便捷方法:使用账户token删除token
|
||||
async revokeTokenByAccount(targetToken, bearerToken) {
|
||||
return this.revokeToken(targetToken, {
|
||||
bearerToken,
|
||||
usePathParam: true
|
||||
});
|
||||
}
|
||||
|
||||
// 便捷方法:应用自撤销
|
||||
async revokeOwnToken(targetToken) {
|
||||
return this.fetch(`/apps/tokens/${targetToken}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'x-app-token': targetToken,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 新的便捷方法
|
||||
async getTokensWithAuth(authType, authValue, options = {}) {
|
||||
const headers = {};
|
||||
const params = new URLSearchParams(options);
|
||||
|
||||
switch (authType) {
|
||||
case 'uuid':
|
||||
headers['x-device-uuid'] = authValue;
|
||||
params.set('uuid', authValue);
|
||||
break;
|
||||
case 'token':
|
||||
headers['x-app-token'] = authValue;
|
||||
break;
|
||||
case 'bearer':
|
||||
headers['Authorization'] = `Bearer ${authValue}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.fetch(`/apps/tokens?${params}`, { headers });
|
||||
}
|
||||
|
||||
async revokeTokenWithAuth(targetToken, authType, authValue) {
|
||||
const headers = {};
|
||||
const params = new URLSearchParams({ token: targetToken });
|
||||
|
||||
switch (authType) {
|
||||
case 'uuid':
|
||||
headers['x-device-uuid'] = authValue;
|
||||
break;
|
||||
case 'token':
|
||||
headers['x-app-token'] = authValue;
|
||||
break;
|
||||
case 'bearer':
|
||||
headers['Authorization'] = `Bearer ${authValue}`;
|
||||
break;
|
||||
}
|
||||
|
||||
return this.fetch(`/apps/tokens?${params}`, {
|
||||
method: 'DELETE',
|
||||
headers,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)
|
37
src/lib/axios.js
Normal file
37
src/lib/axios.js
Normal file
@ -0,0 +1,37 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
|
||||
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
|
||||
|
||||
// 创建 axios 实例
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-site-key': SITE_KEY,
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data
|
||||
},
|
||||
(error) => {
|
||||
const message = error.response?.data?.message || error.message || 'Unknown error'
|
||||
return Promise.reject(new Error(message))
|
||||
}
|
||||
)
|
||||
|
||||
export default axiosInstance
|
191
src/lib/deviceStore.js
Normal file
191
src/lib/deviceStore.js
Normal file
@ -0,0 +1,191 @@
|
||||
// 生成 UUID v4
|
||||
export function generateUUID() {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||||
const r = Math.random() * 16 | 0
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||
return v.toString(16)
|
||||
})
|
||||
}
|
||||
|
||||
// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失
|
||||
export const deviceStore = {
|
||||
// 存储键名
|
||||
STORAGE_KEY: 'device_uuid',
|
||||
BACKUP_KEY: 'device_uuid_backup',
|
||||
SESSION_KEY: 'device_uuid_session',
|
||||
|
||||
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
||||
getDeviceUuid() {
|
||||
// 1. 首先从 localStorage 获取
|
||||
let uuid = localStorage.getItem(this.STORAGE_KEY)
|
||||
|
||||
// 2. 如果没有,尝试从备份位置获取
|
||||
if (!uuid) {
|
||||
uuid = localStorage.getItem(this.BACKUP_KEY)
|
||||
if (uuid) {
|
||||
// 恢复到主存储位置
|
||||
this.setDeviceUuid(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 如果还没有,尝试从 sessionStorage 获取
|
||||
if (!uuid) {
|
||||
uuid = sessionStorage.getItem(this.SESSION_KEY)
|
||||
if (uuid) {
|
||||
// 恢复到主存储位置
|
||||
this.setDeviceUuid(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 如果还没有,尝试从 cookie 获取(如果有的话)
|
||||
if (!uuid) {
|
||||
uuid = this.getFromCookie()
|
||||
if (uuid) {
|
||||
// 恢复到所有存储位置
|
||||
this.setDeviceUuid(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
return uuid
|
||||
},
|
||||
|
||||
// 设置设备 UUID(同时存储到多个位置)
|
||||
setDeviceUuid(uuid) {
|
||||
// 1. 存储到 localStorage 主位置
|
||||
localStorage.setItem(this.STORAGE_KEY, uuid)
|
||||
|
||||
// 2. 存储到备份位置
|
||||
localStorage.setItem(this.BACKUP_KEY, uuid)
|
||||
|
||||
// 3. 存储到 sessionStorage
|
||||
sessionStorage.setItem(this.SESSION_KEY, uuid)
|
||||
|
||||
// 4. 存储到 cookie(设置较长的过期时间)
|
||||
this.saveToCookie(uuid)
|
||||
|
||||
// 5. 尝试存储到 IndexedDB(异步)
|
||||
this.saveToIndexedDB(uuid)
|
||||
},
|
||||
|
||||
// 生成并保存新的设备 UUID
|
||||
generateAndSave() {
|
||||
const uuid = generateUUID()
|
||||
this.setDeviceUuid(uuid)
|
||||
return uuid
|
||||
},
|
||||
|
||||
// 获取或生成设备 UUID
|
||||
getOrGenerate() {
|
||||
let uuid = this.getDeviceUuid()
|
||||
if (!uuid) {
|
||||
uuid = this.generateAndSave()
|
||||
} else {
|
||||
// 确保UUID被保存到所有位置
|
||||
this.setDeviceUuid(uuid)
|
||||
}
|
||||
return uuid
|
||||
},
|
||||
|
||||
// 清除设备 UUID(从所有存储位置清除)
|
||||
clear() {
|
||||
localStorage.removeItem(this.STORAGE_KEY)
|
||||
localStorage.removeItem(this.BACKUP_KEY)
|
||||
sessionStorage.removeItem(this.SESSION_KEY)
|
||||
this.clearCookie()
|
||||
this.clearIndexedDB()
|
||||
},
|
||||
|
||||
// Cookie 操作
|
||||
saveToCookie(uuid) {
|
||||
try {
|
||||
const expires = new Date()
|
||||
expires.setFullYear(expires.getFullYear() + 10) // 10年过期
|
||||
document.cookie = `device_uuid=${uuid}; expires=${expires.toUTCString()}; path=/; SameSite=Strict`
|
||||
} catch (e) {
|
||||
console.log('Failed to save UUID to cookie:', e)
|
||||
}
|
||||
},
|
||||
|
||||
getFromCookie() {
|
||||
try {
|
||||
const match = document.cookie.match(/(?:^|; )device_uuid=([^;]*)/)
|
||||
return match ? match[1] : null
|
||||
} catch (e) {
|
||||
console.log('Failed to get UUID from cookie:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
clearCookie() {
|
||||
try {
|
||||
document.cookie = 'device_uuid=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'
|
||||
} catch (e) {
|
||||
console.log('Failed to clear UUID cookie:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// IndexedDB 操作(异步,作为额外的备份)
|
||||
async saveToIndexedDB(uuid) {
|
||||
try {
|
||||
const db = await this.openDB()
|
||||
const transaction = db.transaction(['device'], 'readwrite')
|
||||
const store = transaction.objectStore('device')
|
||||
await store.put({ id: 'uuid', value: uuid })
|
||||
} catch (e) {
|
||||
console.log('Failed to save UUID to IndexedDB:', e)
|
||||
}
|
||||
},
|
||||
|
||||
async getFromIndexedDB() {
|
||||
try {
|
||||
const db = await this.openDB()
|
||||
const transaction = db.transaction(['device'], 'readonly')
|
||||
const store = transaction.objectStore('device')
|
||||
const result = await store.get('uuid')
|
||||
return result?.value || null
|
||||
} catch (e) {
|
||||
console.log('Failed to get UUID from IndexedDB:', e)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async clearIndexedDB() {
|
||||
try {
|
||||
const db = await this.openDB()
|
||||
const transaction = db.transaction(['device'], 'readwrite')
|
||||
const store = transaction.objectStore('device')
|
||||
await store.delete('uuid')
|
||||
} catch (e) {
|
||||
console.log('Failed to clear UUID from IndexedDB:', e)
|
||||
}
|
||||
},
|
||||
|
||||
openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open('ClassworksKV', 1)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result
|
||||
if (!db.objectStoreNames.contains('device')) {
|
||||
db.createObjectStore('device', { keyPath: 'id' })
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 尝试从 IndexedDB 恢复 UUID(在初始化时调用)
|
||||
async tryRestoreFromIndexedDB() {
|
||||
const uuid = await this.getFromIndexedDB()
|
||||
if (uuid && !this.getDeviceUuid()) {
|
||||
this.setDeviceUuid(uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 在页面加载时尝试从 IndexedDB 恢复
|
||||
if (typeof window !== 'undefined') {
|
||||
deviceStore.tryRestoreFromIndexedDB()
|
||||
}
|
66
src/lib/tokenStore.js
Normal file
66
src/lib/tokenStore.js
Normal file
@ -0,0 +1,66 @@
|
||||
// 生成随机设备码
|
||||
export function generateDeviceCode() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
const segments = []
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let segment = ''
|
||||
for (let j = 0; j < 4; j++) {
|
||||
segment += chars[Math.floor(Math.random() * chars.length)]
|
||||
}
|
||||
segments.push(segment)
|
||||
}
|
||||
|
||||
return segments.join('-')
|
||||
}
|
||||
|
||||
// Token 管理
|
||||
export const tokenStore = {
|
||||
// 获取所有 token
|
||||
getTokens() {
|
||||
const tokens = localStorage.getItem('kv_tokens')
|
||||
return tokens ? JSON.parse(tokens) : []
|
||||
},
|
||||
|
||||
// 添加 token
|
||||
addToken(token, appName = '') {
|
||||
const tokens = this.getTokens()
|
||||
const newToken = {
|
||||
id: Date.now().toString(),
|
||||
token,
|
||||
appName,
|
||||
deviceCode: generateDeviceCode(),
|
||||
createdAt: new Date().toISOString(),
|
||||
lastUsed: new Date().toISOString()
|
||||
}
|
||||
tokens.push(newToken)
|
||||
localStorage.setItem('kv_tokens', JSON.stringify(tokens))
|
||||
return newToken
|
||||
},
|
||||
|
||||
// 删除 token
|
||||
removeToken(id) {
|
||||
const tokens = this.getTokens().filter(t => t.id !== id)
|
||||
localStorage.setItem('kv_tokens', JSON.stringify(tokens))
|
||||
},
|
||||
|
||||
// 更新 token
|
||||
updateToken(id, updates) {
|
||||
const tokens = this.getTokens().map(t =>
|
||||
t.id === id ? { ...t, ...updates } : t
|
||||
)
|
||||
localStorage.setItem('kv_tokens', JSON.stringify(tokens))
|
||||
},
|
||||
|
||||
// 获取当前活跃的 token
|
||||
getActiveToken() {
|
||||
const activeId = localStorage.getItem('kv_active_token')
|
||||
if (!activeId) return null
|
||||
return this.getTokens().find(t => t.id === activeId)
|
||||
},
|
||||
|
||||
// 设置活跃 token
|
||||
setActiveToken(id) {
|
||||
localStorage.setItem('kv_active_token', id)
|
||||
}
|
||||
}
|
6
src/lib/utils.js
Normal file
6
src/lib/utils.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
45
src/main.js
Normal file
45
src/main.js
Normal file
@ -0,0 +1,45 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia } from 'pinia'
|
||||
import { routes } from 'vue-router/auto-routes'
|
||||
import { tokenStore } from './lib/tokenStore'
|
||||
import { deviceStore } from './lib/deviceStore'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
|
||||
// 检查 URL 参数中的 uuid 并设置到本地存储
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const urlUuid = urlParams.get('uuid')
|
||||
if (urlUuid) {
|
||||
deviceStore.setDeviceUuid(urlUuid)
|
||||
// 清除 URL 中的 uuid 参数
|
||||
urlParams.delete('uuid')
|
||||
const newUrl = urlParams.toString()
|
||||
? `${window.location.pathname}?${urlParams.toString()}`
|
||||
: window.location.pathname
|
||||
window.history.replaceState({}, '', newUrl)
|
||||
}
|
||||
|
||||
const pinia = createPinia()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// Navigation guard for authentication
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const requiresAuth = to.meta?.requiresAuth
|
||||
const activeToken = tokenStore.getActiveToken()
|
||||
|
||||
if (requiresAuth && !activeToken) {
|
||||
next({ path: '/' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user