mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-12-07 18:13:09 +00:00
规范代码格式
This commit is contained in:
parent
008d93e76c
commit
5fd99c2121
12
src/App.vue
12
src/App.vue
@ -1,14 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { RouterView } from 'vue-router'
|
import {RouterView} from 'vue-router'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import {Toaster} from '@/components/ui/sonner'
|
||||||
import 'vue-sonner/style.css'
|
import 'vue-sonner/style.css'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView v-slot="{ Component, route }">
|
||||||
<Transition name="page" mode="out-in">
|
<Transition mode="out-in" name="page">
|
||||||
<component :is="Component" :key="route.fullPath" />
|
<component :is="Component" :key="route.fullPath"/>
|
||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
<Toaster class="pointer-events-auto" />
|
<Toaster class="pointer-events-auto"/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,23 +1,25 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" viewBox="0 0 256 256" fill="none">
|
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="256" height="256"
|
||||||
<g clip-path="url(#clip-path-74_1)">
|
viewBox="0 0 256 256" fill="none">
|
||||||
<path fill="#FFFFFF" d="M0 256L256 256L256 0L0 0L0 256Z">
|
<g clip-path="url(#clip-path-74_1)">
|
||||||
</path>
|
<path fill="#FFFFFF" d="M0 256L256 256L256 0L0 0L0 256Z">
|
||||||
<rect x="0" y="0" width="256" height="128" fill="#D8C4A0" >
|
</path>
|
||||||
</rect>
|
<rect x="0" y="0" width="256" height="128" fill="#D8C4A0">
|
||||||
<rect x="0" y="128" width="256" height="128" fill="#F5E0BB" >
|
</rect>
|
||||||
</rect>
|
<rect x="0" y="128" width="256" height="128" fill="#F5E0BB">
|
||||||
<path d="M28 228L128 128L228 128L128 228L28 228Z" fill-rule="evenodd" fill="#241A04" >
|
</rect>
|
||||||
</path>
|
<path d="M28 228L128 128L228 128L128 228L28 228Z" fill-rule="evenodd" fill="#241A04">
|
||||||
<path d="M28 128L128 28L228 28L128 128L28 128Z" fill-rule="evenodd" fill="#52452A" >
|
</path>
|
||||||
</path>
|
<path d="M28 128L128 28L228 28L128 128L28 128Z" fill-rule="evenodd" fill="#52452A">
|
||||||
<g >
|
</path>
|
||||||
<path fill="#000000" d="M-3049.01 2467.94L-3043.48 2467.94L-3043.48 2466.99L-3045.92 2466.99C-3046.36 2466.99 -3046.9 2467.04 -3047.36 2467.08C-3045.29 2465.12 -3043.9 2463.33 -3043.9 2461.57C-3043.9 2460.01 -3044.9 2458.99 -3046.47 2458.99C-3047.58 2458.99 -3048.35 2459.49 -3049.06 2460.27L-3048.43 2460.9C-3047.93 2460.31 -3047.32 2459.88 -3046.6 2459.88C-3045.51 2459.88 -3044.98 2460.61 -3044.98 2461.62C-3044.98 2463.13 -3046.25 2464.88 -3049.01 2467.29L-3049.01 2467.94ZM-3039.27 2468.1C-3037.9 2468.1 -3036.74 2466.95 -3036.74 2465.24C-3036.74 2463.39 -3037.7 2462.48 -3039.19 2462.48C-3039.87 2462.48 -3040.64 2462.88 -3041.18 2463.54C-3041.13 2460.81 -3040.13 2459.89 -3038.91 2459.89C-3038.38 2459.89 -3037.85 2460.15 -3037.52 2460.56L-3036.89 2459.89C-3037.39 2459.36 -3038.04 2458.99 -3038.96 2458.99C-3040.66 2458.99 -3042.21 2460.3 -3042.21 2463.74C-3042.21 2466.65 -3040.95 2468.1 -3039.27 2468.1ZM-3041.15 2464.41C-3040.58 2463.6 -3039.91 2463.3 -3039.36 2463.3C-3038.3 2463.3 -3037.78 2464.05 -3037.78 2465.24C-3037.78 2466.44 -3038.43 2467.23 -3039.27 2467.23C-3040.37 2467.23 -3041.03 2466.24 -3041.15 2464.41ZM-3035.17 2467.94L-3030.34 2467.94L-3030.34 2467.03L-3032.1 2467.03L-3032.1 2459.15L-3032.95 2459.15C-3033.43 2459.42 -3033.99 2459.62 -3034.77 2459.77L-3034.77 2460.47L-3033.2 2460.47L-3033.2 2467.03L-3035.17 2467.03L-3035.17 2467.94ZM-3029.51 2467.94L-3028.4 2467.94L-3027.54 2465.25L-3024.33 2465.25L-3023.49 2467.94L-3022.31 2467.94L-3025.3 2459.15L-3026.54 2459.15L-3029.51 2467.94ZM-3027.27 2464.38L-3026.84 2463.02C-3026.52 2462.02 -3026.24 2461.08 -3025.96 2460.04L-3025.91 2460.04C-3025.62 2461.06 -3025.35 2462.02 -3025.02 2463.02L-3024.6 2464.38L-3027.27 2464.38ZM-3018.93 2468.1C-3017.26 2468.1 -3016.19 2466.58 -3016.19 2463.51C-3016.19 2460.47 -3017.26 2458.99 -3018.93 2458.99C-3020.61 2458.99 -3021.67 2460.47 -3021.67 2463.51C-3021.67 2466.58 -3020.61 2468.1 -3018.93 2468.1ZM-3018.93 2467.21C-3019.93 2467.21 -3020.61 2466.09 -3020.61 2463.51C-3020.61 2460.95 -3019.93 2459.85 -3018.93 2459.85C-3017.93 2459.85 -3017.25 2460.95 -3017.25 2463.51C-3017.25 2466.09 -3017.93 2467.21 -3018.93 2467.21ZM-3012.27 2468.1C-3010.6 2468.1 -3009.53 2466.58 -3009.53 2463.51C-3009.53 2460.47 -3010.6 2458.99 -3012.27 2458.99C-3013.95 2458.99 -3015.01 2460.47 -3015.01 2463.51C-3015.01 2466.58 -3013.95 2468.1 -3012.27 2468.1ZM-3012.27 2467.21C-3013.27 2467.21 -3013.95 2466.09 -3013.95 2463.51C-3013.95 2460.95 -3013.27 2459.85 -3012.27 2459.85C-3011.27 2459.85 -3010.59 2460.95 -3010.59 2463.51C-3010.59 2466.09 -3011.27 2467.21 -3012.27 2467.21Z">
|
<g>
|
||||||
</path>
|
<path fill="#000000"
|
||||||
</g>
|
d="M-3049.01 2467.94L-3043.48 2467.94L-3043.48 2466.99L-3045.92 2466.99C-3046.36 2466.99 -3046.9 2467.04 -3047.36 2467.08C-3045.29 2465.12 -3043.9 2463.33 -3043.9 2461.57C-3043.9 2460.01 -3044.9 2458.99 -3046.47 2458.99C-3047.58 2458.99 -3048.35 2459.49 -3049.06 2460.27L-3048.43 2460.9C-3047.93 2460.31 -3047.32 2459.88 -3046.6 2459.88C-3045.51 2459.88 -3044.98 2460.61 -3044.98 2461.62C-3044.98 2463.13 -3046.25 2464.88 -3049.01 2467.29L-3049.01 2467.94ZM-3039.27 2468.1C-3037.9 2468.1 -3036.74 2466.95 -3036.74 2465.24C-3036.74 2463.39 -3037.7 2462.48 -3039.19 2462.48C-3039.87 2462.48 -3040.64 2462.88 -3041.18 2463.54C-3041.13 2460.81 -3040.13 2459.89 -3038.91 2459.89C-3038.38 2459.89 -3037.85 2460.15 -3037.52 2460.56L-3036.89 2459.89C-3037.39 2459.36 -3038.04 2458.99 -3038.96 2458.99C-3040.66 2458.99 -3042.21 2460.3 -3042.21 2463.74C-3042.21 2466.65 -3040.95 2468.1 -3039.27 2468.1ZM-3041.15 2464.41C-3040.58 2463.6 -3039.91 2463.3 -3039.36 2463.3C-3038.3 2463.3 -3037.78 2464.05 -3037.78 2465.24C-3037.78 2466.44 -3038.43 2467.23 -3039.27 2467.23C-3040.37 2467.23 -3041.03 2466.24 -3041.15 2464.41ZM-3035.17 2467.94L-3030.34 2467.94L-3030.34 2467.03L-3032.1 2467.03L-3032.1 2459.15L-3032.95 2459.15C-3033.43 2459.42 -3033.99 2459.62 -3034.77 2459.77L-3034.77 2460.47L-3033.2 2460.47L-3033.2 2467.03L-3035.17 2467.03L-3035.17 2467.94ZM-3029.51 2467.94L-3028.4 2467.94L-3027.54 2465.25L-3024.33 2465.25L-3023.49 2467.94L-3022.31 2467.94L-3025.3 2459.15L-3026.54 2459.15L-3029.51 2467.94ZM-3027.27 2464.38L-3026.84 2463.02C-3026.52 2462.02 -3026.24 2461.08 -3025.96 2460.04L-3025.91 2460.04C-3025.62 2461.06 -3025.35 2462.02 -3025.02 2463.02L-3024.6 2464.38L-3027.27 2464.38ZM-3018.93 2468.1C-3017.26 2468.1 -3016.19 2466.58 -3016.19 2463.51C-3016.19 2460.47 -3017.26 2458.99 -3018.93 2458.99C-3020.61 2458.99 -3021.67 2460.47 -3021.67 2463.51C-3021.67 2466.58 -3020.61 2468.1 -3018.93 2468.1ZM-3018.93 2467.21C-3019.93 2467.21 -3020.61 2466.09 -3020.61 2463.51C-3020.61 2460.95 -3019.93 2459.85 -3018.93 2459.85C-3017.93 2459.85 -3017.25 2460.95 -3017.25 2463.51C-3017.25 2466.09 -3017.93 2467.21 -3018.93 2467.21ZM-3012.27 2468.1C-3010.6 2468.1 -3009.53 2466.58 -3009.53 2463.51C-3009.53 2460.47 -3010.6 2458.99 -3012.27 2458.99C-3013.95 2458.99 -3015.01 2460.47 -3015.01 2463.51C-3015.01 2466.58 -3013.95 2468.1 -3012.27 2468.1ZM-3012.27 2467.21C-3013.27 2467.21 -3013.95 2466.09 -3013.95 2463.51C-3013.95 2460.95 -3013.27 2459.85 -3012.27 2459.85C-3011.27 2459.85 -3010.59 2460.95 -3010.59 2463.51C-3010.59 2466.09 -3011.27 2467.21 -3012.27 2467.21Z">
|
||||||
</g>
|
</path>
|
||||||
<defs>
|
</g>
|
||||||
<clipPath id="clip-path-74_1">
|
</g>
|
||||||
<path d="M0 256L256 256L256 0L0 0L0 256Z" fill="white"/>
|
<defs>
|
||||||
</clipPath>
|
<clipPath id="clip-path-74_1">
|
||||||
</defs>
|
<path d="M0 256L256 256L256 0L0 0L0 256Z" fill="white"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.4 KiB |
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from "vue";
|
import {ref, computed} from "vue";
|
||||||
import { marked } from "marked";
|
import {marked} from "marked";
|
||||||
import axios from "@/lib/axios";
|
import axios from "@/lib/axios";
|
||||||
import Card from "./ui/card/Card.vue";
|
import Card from "./ui/card/Card.vue";
|
||||||
import CardHeader from "./ui/card/CardHeader.vue";
|
import CardHeader from "./ui/card/CardHeader.vue";
|
||||||
@ -13,8 +13,8 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "./ui/dialog";
|
} from "./ui/dialog";
|
||||||
import { ExternalLink } from "lucide-vue-next";
|
import {ExternalLink} from "lucide-vue-next";
|
||||||
import { cn } from "@/lib/utils";
|
import {cn} from "@/lib/utils";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
appId: {
|
appId: {
|
||||||
@ -138,7 +138,7 @@ const fetchReadme = async () => {
|
|||||||
|
|
||||||
// Gitea/Forgejo 或通用处理
|
// Gitea/Forgejo 或通用处理
|
||||||
const genericMatch = url.match(
|
const genericMatch = url.match(
|
||||||
/https?:\/\/([^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?$/
|
/https?:\/\/([^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?$/
|
||||||
);
|
);
|
||||||
if (genericMatch) {
|
if (genericMatch) {
|
||||||
const [, domain, owner, repo] = genericMatch;
|
const [, domain, owner, repo] = genericMatch;
|
||||||
@ -172,13 +172,13 @@ fetchApp();
|
|||||||
<template>
|
<template>
|
||||||
<!-- 卡片视图 -->
|
<!-- 卡片视图 -->
|
||||||
<Card
|
<Card
|
||||||
:class="
|
:class="
|
||||||
cn(
|
cn(
|
||||||
'app-card cursor-pointer hover:shadow-lg transition-shadow',
|
'app-card cursor-pointer hover:shadow-lg transition-shadow',
|
||||||
props.class
|
props.class
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
@click="showDialog = true"
|
@click="showDialog = true"
|
||||||
>
|
>
|
||||||
<CardHeader v-if="loading" class="px-6">
|
<CardHeader v-if="loading" class="px-6">
|
||||||
<div class="animate-pulse">加载中...</div>
|
<div class="animate-pulse">加载中...</div>
|
||||||
@ -195,11 +195,11 @@ fetchApp();
|
|||||||
<CardHeader class="px-6">
|
<CardHeader class="px-6">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<img
|
<img
|
||||||
v-if="iconUrl"
|
v-if="iconUrl"
|
||||||
:src="iconUrl"
|
:alt="app.name"
|
||||||
:alt="app.name"
|
:src="iconUrl"
|
||||||
class="w-12 h-12 rounded-lg object-cover shrink-0"
|
class="w-12 h-12 rounded-lg object-cover shrink-0"
|
||||||
@error="(e) => (e.target.style.display = 'none')"
|
@error="(e) => (e.target.style.display = 'none')"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<CardTitle class="text-lg truncate">{{ app.name }}</CardTitle>
|
<CardTitle class="text-lg truncate">{{ app.name }}</CardTitle>
|
||||||
@ -221,11 +221,11 @@ fetchApp();
|
|||||||
<DialogHeader v-if="app">
|
<DialogHeader v-if="app">
|
||||||
<div class="flex items-start gap-4 mb-4">
|
<div class="flex items-start gap-4 mb-4">
|
||||||
<img
|
<img
|
||||||
v-if="iconUrl"
|
v-if="iconUrl"
|
||||||
:src="iconUrl"
|
:alt="app.name"
|
||||||
:alt="app.name"
|
:src="iconUrl"
|
||||||
class="w-20 h-20 rounded-lg object-cover"
|
class="w-20 h-20 rounded-lg object-cover"
|
||||||
@error="(e) => (e.target.style.display = 'none')"
|
@error="(e) => (e.target.style.display = 'none')"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<DialogTitle class="text-2xl mb-2">{{ app.name }}</DialogTitle>
|
<DialogTitle class="text-2xl mb-2">{{ app.name }}</DialogTitle>
|
||||||
@ -244,34 +244,34 @@ fetchApp();
|
|||||||
<div v-if="app.homepage_url" class="space-y-1">
|
<div v-if="app.homepage_url" class="space-y-1">
|
||||||
<div class="text-sm text-muted-foreground">应用主页</div>
|
<div class="text-sm text-muted-foreground">应用主页</div>
|
||||||
<a
|
<a
|
||||||
:href="app.homepage_url"
|
:href="app.homepage_url"
|
||||||
target="_blank"
|
class="text-primary hover:underline inline-flex items-center gap-1"
|
||||||
class="text-primary hover:underline inline-flex items-center gap-1"
|
target="_blank"
|
||||||
>
|
>
|
||||||
访问
|
访问
|
||||||
<ExternalLink class="h-3 w-3" />
|
<ExternalLink class="h-3 w-3"/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="app.terms_url" class="space-y-1">
|
<div v-if="app.terms_url" class="space-y-1">
|
||||||
<div class="text-sm text-muted-foreground">服务条款</div>
|
<div class="text-sm text-muted-foreground">服务条款</div>
|
||||||
<a
|
<a
|
||||||
:href="app.terms_url"
|
:href="app.terms_url"
|
||||||
target="_blank"
|
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
|
||||||
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
|
target="_blank"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
<ExternalLink class="h-3 w-3" />
|
<ExternalLink class="h-3 w-3"/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="app.privacy_url" class="space-y-1">
|
<div v-if="app.privacy_url" class="space-y-1">
|
||||||
<div class="text-sm text-muted-foreground">隐私政策</div>
|
<div class="text-sm text-muted-foreground">隐私政策</div>
|
||||||
<a
|
<a
|
||||||
:href="app.privacy_url"
|
:href="app.privacy_url"
|
||||||
target="_blank"
|
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
|
||||||
class="text-primary hover:underline inline-flex items-center gap-1 truncate"
|
target="_blank"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
<ExternalLink class="h-3 w-3" />
|
<ExternalLink class="h-3 w-3"/>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -281,13 +281,13 @@ fetchApp();
|
|||||||
<div v-if="readme" class="mt-6">
|
<div v-if="readme" class="mt-6">
|
||||||
<h3 class="text-lg font-semibold mb-4">README</h3>
|
<h3 class="text-lg font-semibold mb-4">README</h3>
|
||||||
<div
|
<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"
|
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"
|
v-html="renderedReadme"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="!loading && app?.homepage_url"
|
v-else-if="!loading && app?.homepage_url"
|
||||||
class="mt-6 text-center text-muted-foreground"
|
class="mt-6 text-center text-muted-foreground"
|
||||||
>
|
>
|
||||||
无法加载 README 文件
|
无法加载 README 文件
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch } from 'vue'
|
import {ref, computed, watch} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -9,9 +9,9 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -19,9 +19,9 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import {Checkbox} from '@/components/ui/checkbox'
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
import {Loader2} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
modelValue: Boolean,
|
||||||
@ -51,11 +51,11 @@ const dialogTitle = computed(() => {
|
|||||||
|
|
||||||
// 设备类型选项
|
// 设备类型选项
|
||||||
const deviceTypeOptions = [
|
const deviceTypeOptions = [
|
||||||
{ value: 'teacher', label: '教师' },
|
{value: 'teacher', label: '教师'},
|
||||||
{ value: 'student', label: '学生' },
|
{value: 'student', label: '学生'},
|
||||||
{ value: 'classroom', label: '班级一体机' },
|
{value: 'classroom', label: '班级一体机'},
|
||||||
{ value: 'parent', label: '家长' },
|
{value: 'parent', label: '家长'},
|
||||||
{ value: null, label: '未指定' },
|
{value: null, label: '未指定'},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 监听对话框打开状态,重置表单
|
// 监听对话框打开状态,重置表单
|
||||||
@ -104,10 +104,10 @@ const saveConfig = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.updateAutoAuthConfig(
|
await apiClient.updateAutoAuthConfig(
|
||||||
props.deviceUuid,
|
props.deviceUuid,
|
||||||
props.accountToken,
|
props.accountToken,
|
||||||
props.config.id,
|
props.config.id,
|
||||||
updates
|
updates
|
||||||
)
|
)
|
||||||
toast.success('配置更新成功')
|
toast.success('配置更新成功')
|
||||||
} else {
|
} else {
|
||||||
@ -122,9 +122,9 @@ const saveConfig = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await apiClient.createAutoAuthConfig(
|
await apiClient.createAutoAuthConfig(
|
||||||
props.deviceUuid,
|
props.deviceUuid,
|
||||||
props.accountToken,
|
props.accountToken,
|
||||||
config
|
config
|
||||||
)
|
)
|
||||||
toast.success('配置创建成功')
|
toast.success('配置创建成功')
|
||||||
}
|
}
|
||||||
@ -159,11 +159,11 @@ const saveConfig = async () => {
|
|||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
type="text"
|
v-model="formData.password"
|
||||||
v-model="formData.password"
|
:placeholder="isEditMode ? '留空表示无密码授权' : '留空表示无密码授权'"
|
||||||
:placeholder="isEditMode ? '留空表示无密码授权' : '留空表示无密码授权'"
|
autocomplete="new-password"
|
||||||
autocomplete="new-password"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
{{ isEditMode ? '留空表示设为无密码' : '设备使用此密码可以自动获取访问授权' }}
|
{{ isEditMode ? '留空表示设为无密码' : '设备使用此密码可以自动获取访问授权' }}
|
||||||
@ -175,13 +175,13 @@ const saveConfig = async () => {
|
|||||||
<Label for="deviceType">设备类型</Label>
|
<Label for="deviceType">设备类型</Label>
|
||||||
<Select v-model="formData.deviceType">
|
<Select v-model="formData.deviceType">
|
||||||
<SelectTrigger id="deviceType">
|
<SelectTrigger id="deviceType">
|
||||||
<SelectValue placeholder="选择设备类型" />
|
<SelectValue placeholder="选择设备类型"/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-for="option in deviceTypeOptions"
|
v-for="option in deviceTypeOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:value="option.value"
|
:value="option.value"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@ -195,12 +195,12 @@ const saveConfig = async () => {
|
|||||||
<!-- 只读权限 -->
|
<!-- 只读权限 -->
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="isReadOnly"
|
id="isReadOnly"
|
||||||
v-model="formData.isReadOnly"
|
v-model="formData.isReadOnly"
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
for="isReadOnly"
|
class="text-sm font-normal cursor-pointer"
|
||||||
class="text-sm font-normal cursor-pointer"
|
for="isReadOnly"
|
||||||
>
|
>
|
||||||
只读权限(仅允许读取数据,不能修改)
|
只读权限(仅允许读取数据,不能修改)
|
||||||
</Label>
|
</Label>
|
||||||
@ -219,19 +219,19 @@ const saveConfig = async () => {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
:disabled="isLoading"
|
||||||
variant="outline"
|
type="button"
|
||||||
@click="closeDialog"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
:disabled="isLoading"
|
||||||
@click="saveConfig"
|
type="button"
|
||||||
:disabled="isLoading"
|
@click="saveConfig"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
{{ isEditMode ? '保存' : '创建' }}
|
{{ isEditMode ? '保存' : '创建' }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import {ref, watch} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -10,11 +10,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Loader2, Eye, EyeOff } from 'lucide-vue-next'
|
import {Loader2, Eye, EyeOff} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
modelValue: Boolean,
|
||||||
@ -85,7 +85,7 @@ const handleAuth = async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog :open="modelValue" @update:open="(val) => closable && emit('update:modelValue', val)">
|
<Dialog :open="modelValue" @update:open="(val) => closable && emit('update:modelValue', val)">
|
||||||
<DialogContent class="sm:max-w-[500px]" :closable="closable">
|
<DialogContent :closable="closable" class="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ title }}</DialogTitle>
|
<DialogTitle>{{ title }}</DialogTitle>
|
||||||
<DialogDescription>{{ description }}</DialogDescription>
|
<DialogDescription>{{ description }}</DialogDescription>
|
||||||
@ -96,10 +96,10 @@ const handleAuth = async () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="device-uuid">设备 UUID *</Label>
|
<Label for="device-uuid">设备 UUID *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="device-uuid"
|
id="device-uuid"
|
||||||
v-model="deviceUuid"
|
v-model="deviceUuid"
|
||||||
placeholder="输入设备 UUID"
|
placeholder="输入设备 UUID"
|
||||||
@keyup.enter="handleAuth"
|
@keyup.enter="handleAuth"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -108,20 +108,20 @@ const handleAuth = async () => {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
v-if="closable"
|
v-if="closable"
|
||||||
type="button"
|
:disabled="isLoading"
|
||||||
variant="outline"
|
type="button"
|
||||||
@click="closeDialog"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
:disabled="isLoading"
|
||||||
@click="handleAuth"
|
type="button"
|
||||||
:disabled="isLoading"
|
@click="handleAuth"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
确认
|
确认
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,18 +1,18 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import {ref, computed, watch, onMounted, onUnmounted} from 'vue'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { deviceStore, generateUUID } from '@/lib/deviceStore'
|
import {deviceStore, generateUUID} from '@/lib/deviceStore'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import LoginDialog from '@/components/LoginDialog.vue'
|
import LoginDialog from '@/components/LoginDialog.vue'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import {Separator} from '@/components/ui/separator'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import {Checkbox} from '@/components/ui/checkbox'
|
||||||
import { Shuffle, Download, Plus, AlertTriangle } from 'lucide-vue-next'
|
import {Shuffle, Download, Plus, AlertTriangle} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@ -94,7 +94,7 @@ const generateRandomUuid = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理打开登录对话框
|
// 处理打开登录对话框
|
||||||
const handleOpenLogin = () => {
|
const handleOpenLogin = () => {
|
||||||
showLoginDialog.value = true
|
showLoginDialog.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +121,7 @@ const loadAccountDevices = async () => {
|
|||||||
|
|
||||||
loadingDevices.value = true
|
loadingDevices.value = true
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getAccountDevices()
|
const response = await apiClient.getAccountDevices()
|
||||||
accountDevices.value = response.data || []
|
accountDevices.value = response.data || []
|
||||||
|
|
||||||
if (accountDevices.value.length === 0) {
|
if (accountDevices.value.length === 0) {
|
||||||
@ -138,7 +138,7 @@ const loadAccountDevices = async () => {
|
|||||||
const loadDevice = (device) => {
|
const loadDevice = (device) => {
|
||||||
deviceStore.setDeviceUuid(device.uuid)
|
deviceStore.setDeviceUuid(device.uuid)
|
||||||
// 写入历史
|
// 写入历史
|
||||||
deviceStore.addDeviceToHistory({ uuid: device.uuid, name: device.name })
|
deviceStore.addDeviceToHistory({uuid: device.uuid, name: device.name})
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
resetForm()
|
resetForm()
|
||||||
@ -159,7 +159,7 @@ const loadByUuid = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
deviceStore.setDeviceUuid(id)
|
deviceStore.setDeviceUuid(id)
|
||||||
deviceStore.addDeviceToHistory({ uuid: id })
|
deviceStore.addDeviceToHistory({uuid: id})
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
resetForm()
|
resetForm()
|
||||||
@ -182,12 +182,12 @@ const registerDevice = async () => {
|
|||||||
// 1. 保存UUID到本地
|
// 1. 保存UUID到本地
|
||||||
deviceStore.setDeviceUuid(newUuid.value.trim())
|
deviceStore.setDeviceUuid(newUuid.value.trim())
|
||||||
// 写入历史
|
// 写入历史
|
||||||
deviceStore.addDeviceToHistory({ uuid: newUuid.value.trim(), name: deviceName.value.trim() })
|
deviceStore.addDeviceToHistory({uuid: newUuid.value.trim(), name: deviceName.value.trim()})
|
||||||
|
|
||||||
// 2. 调用设备注册接口(会自动在云端创建设备)
|
// 2. 调用设备注册接口(会自动在云端创建设备)
|
||||||
await apiClient.registerDevice(
|
await apiClient.registerDevice(
|
||||||
newUuid.value.trim(),
|
newUuid.value.trim(),
|
||||||
deviceName.value.trim()
|
deviceName.value.trim()
|
||||||
)
|
)
|
||||||
|
|
||||||
// 3. 如果选择绑定到账户,现在可以安全地绑定
|
// 3. 如果选择绑定到账户,现在可以安全地绑定
|
||||||
@ -206,8 +206,8 @@ const registerDevice = async () => {
|
|||||||
resetForm()
|
resetForm()
|
||||||
|
|
||||||
const message = bindToAccount.value
|
const message = bindToAccount.value
|
||||||
? '设备已注册并绑定到您的账户'
|
? '设备已注册并绑定到您的账户'
|
||||||
: '设备已注册'
|
: '设备已注册'
|
||||||
toast.success(message)
|
toast.success(message)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('注册失败:' + error.message)
|
toast.error('注册失败:' + error.message)
|
||||||
@ -261,8 +261,8 @@ const loadHistoryDevices = () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:open="isOpen"
|
v-model:open="isOpen"
|
||||||
@update:open="(val) => !val && (props.required ? isOpen = true : handleClose())">
|
@update:open="(val) => !val && (props.required ? isOpen = true : handleClose())">
|
||||||
<DialogContent class="max-w-2xl">
|
<DialogContent class="max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>设备管理</DialogTitle>
|
<DialogTitle>设备管理</DialogTitle>
|
||||||
@ -271,9 +271,10 @@ const loadHistoryDevices = () => {
|
|||||||
</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 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">
|
<div class="flex items-start gap-2">
|
||||||
<AlertTriangle class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
|
<AlertTriangle class="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5"/>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-amber-900 dark:text-amber-100">请先注册或加载设备</p>
|
<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 class="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||||
@ -287,20 +288,20 @@ const loadHistoryDevices = () => {
|
|||||||
<Tabs v-model="activeTab" class="w-full">
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-3">
|
<TabsList class="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="load">
|
<TabsTrigger value="load">
|
||||||
<Download class="h-4 w-4 mr-2" />
|
<Download class="h-4 w-4 mr-2"/>
|
||||||
加载设备
|
加载设备
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="history">
|
<TabsTrigger value="history">
|
||||||
历史记录
|
历史记录
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="register">
|
<TabsTrigger value="register">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
注册设备
|
注册设备
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- 加载设备选项卡 -->
|
<!-- 加载设备选项卡 -->
|
||||||
<TabsContent value="load" class="space-y-4 mt-4">
|
<TabsContent class="space-y-4 mt-4" value="load">
|
||||||
<!-- 账户设备区域 -->
|
<!-- 账户设备区域 -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div v-if="!accountStore.isAuthenticated" class="text-center py-6">
|
<div v-if="!accountStore.isAuthenticated" class="text-center py-6">
|
||||||
@ -318,17 +319,17 @@ const loadHistoryDevices = () => {
|
|||||||
<div v-else-if="accountDevices.length === 0" class="text-center py-6">
|
<div v-else-if="accountDevices.length === 0" class="text-center py-6">
|
||||||
<p class="text-muted-foreground mb-3">您的账户暂未绑定任何设备</p>
|
<p class="text-muted-foreground mb-3">您的账户暂未绑定任何设备</p>
|
||||||
<Button variant="outline" @click="activeTab = 'register'">
|
<Button variant="outline" @click="activeTab = 'register'">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
注册新设备
|
注册新设备
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-for="device in accountDevices"
|
v-for="device in accountDevices"
|
||||||
:key="device.uuid"
|
:key="device.uuid"
|
||||||
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
||||||
@click="loadDevice(device)"
|
@click="loadDevice(device)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@ -343,9 +344,9 @@ const loadHistoryDevices = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
@click.stop="loadDevice(device)"
|
@click.stop="loadDevice(device)"
|
||||||
>
|
>
|
||||||
加载
|
加载
|
||||||
</Button>
|
</Button>
|
||||||
@ -355,20 +356,20 @@ const loadHistoryDevices = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator/>
|
||||||
|
|
||||||
<!-- 手动输入 UUID 加载 -->
|
<!-- 手动输入 UUID 加载 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="manualUuid">手动输入 UUID</Label>
|
<Label for="manualUuid">手动输入 UUID</Label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="manualUuid"
|
id="manualUuid"
|
||||||
v-model="manualUuid"
|
v-model="manualUuid"
|
||||||
placeholder="输入设备 UUID 直接加载"
|
class="flex-1"
|
||||||
class="flex-1"
|
placeholder="输入设备 UUID 直接加载"
|
||||||
@keyup.enter="loadByUuid"
|
@keyup.enter="loadByUuid"
|
||||||
/>
|
/>
|
||||||
<Button @click="loadByUuid" :disabled="!manualUuid.trim()">
|
<Button :disabled="!manualUuid.trim()" @click="loadByUuid">
|
||||||
加载
|
加载
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -377,25 +378,25 @@ const loadHistoryDevices = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 注册设备选项卡 -->
|
<!-- 注册设备选项卡 -->
|
||||||
<TabsContent value="register" class="space-y-4 mt-4">
|
<TabsContent class="space-y-4 mt-4" value="register">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- UUID输入 -->
|
<!-- UUID输入 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="registerUuid">设备 UUID</Label>
|
<Label for="registerUuid">设备 UUID</Label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="registerUuid"
|
id="registerUuid"
|
||||||
v-model="newUuid"
|
v-model="newUuid"
|
||||||
placeholder="自动生成或手动输入UUID"
|
class="flex-1"
|
||||||
class="flex-1"
|
placeholder="自动生成或手动输入UUID"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
size="icon"
|
||||||
size="icon"
|
title="生成随机UUID"
|
||||||
@click="generateRandomUuid"
|
variant="outline"
|
||||||
title="生成随机UUID"
|
@click="generateRandomUuid"
|
||||||
>
|
>
|
||||||
<Shuffle class="h-4 w-4" />
|
<Shuffle class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -404,33 +405,33 @@ const loadHistoryDevices = () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="deviceName">* 设备名称</Label>
|
<Label for="deviceName">* 设备名称</Label>
|
||||||
<Input
|
<Input
|
||||||
id="deviceName"
|
id="deviceName"
|
||||||
v-model="deviceName"
|
v-model="deviceName"
|
||||||
placeholder="为设备设置一个易于识别的名称"
|
placeholder="为设备设置一个易于识别的名称"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator/>
|
||||||
|
|
||||||
<!-- 绑定到账户选项 -->
|
<!-- 绑定到账户选项 -->
|
||||||
<div class="flex items-start space-x-3 p-4 rounded-lg border">
|
<div class="flex items-start space-x-3 p-4 rounded-lg border">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="bindToAccount"
|
id="bindToAccount"
|
||||||
v-model:checked="bindToAccount"
|
v-model:checked="bindToAccount"
|
||||||
:disabled="!accountStore.isAuthenticated"
|
:disabled="!accountStore.isAuthenticated"
|
||||||
/>
|
/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label
|
<label
|
||||||
for="bindToAccount"
|
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
for="bindToAccount"
|
||||||
>
|
>
|
||||||
绑定到账户
|
绑定到账户
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-muted-foreground mt-1">
|
<p class="text-xs text-muted-foreground mt-1">
|
||||||
{{ accountStore.isAuthenticated
|
{{ accountStore.isAuthenticated
|
||||||
? `将此设备绑定到账户 ${accountStore.userName},绑定后可在其他设备上快速加载`
|
? `将此设备绑定到账户 ${accountStore.userName},绑定后可在其他设备上快速加载`
|
||||||
: '登录后可以将设备绑定到您的账户'
|
: '登录后可以将设备绑定到您的账户'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -439,31 +440,31 @@ const loadHistoryDevices = () => {
|
|||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
:disabled="props.required"
|
||||||
@click="handleClose"
|
:title="props.required ? '必须先注册设备' : '取消'"
|
||||||
:disabled="props.required"
|
variant="outline"
|
||||||
:title="props.required ? '必须先注册设备' : '取消'"
|
@click="handleClose"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="registerDevice" :disabled="!newUuid.trim() || !deviceName.trim()">
|
<Button :disabled="!newUuid.trim() || !deviceName.trim()" @click="registerDevice">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
注册设备
|
注册设备
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 历史设备选项卡 -->
|
<!-- 历史设备选项卡 -->
|
||||||
<TabsContent value="history" class="space-y-4 mt-4">
|
<TabsContent class="space-y-4 mt-4" value="history">
|
||||||
<div v-if="historyDevices.length === 0" class="text-center py-8 text-muted-foreground">
|
<div v-if="historyDevices.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
暂无历史设备
|
暂无历史设备
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-for="device in historyDevices"
|
v-for="device in historyDevices"
|
||||||
:key="device.uuid"
|
:key="device.uuid"
|
||||||
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
||||||
@click="loadDevice(device)"
|
@click="loadDevice(device)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@ -478,9 +479,9 @@ const loadHistoryDevices = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
size="sm"
|
||||||
size="sm"
|
variant="ghost"
|
||||||
@click.stop="loadDevice(device)"
|
@click.stop="loadDevice(device)"
|
||||||
>
|
>
|
||||||
加载
|
加载
|
||||||
</Button>
|
</Button>
|
||||||
@ -494,7 +495,7 @@ const loadHistoryDevices = () => {
|
|||||||
|
|
||||||
<!-- 登录对话框 -->
|
<!-- 登录对话框 -->
|
||||||
<LoginDialog
|
<LoginDialog
|
||||||
v-model="showLoginDialog"
|
v-model="showLoginDialog"
|
||||||
:on-success="handleLoginSuccess"
|
:on-success="handleLoginSuccess"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import {ref, computed, onMounted, watch} from 'vue'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import {Badge} from '@/components/ui/badge'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import {Separator} from '@/components/ui/separator'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||||
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
||||||
import DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
|
import DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
|
||||||
import LoginDialog from '@/components/LoginDialog.vue'
|
import LoginDialog from '@/components/LoginDialog.vue'
|
||||||
@ -23,7 +23,7 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Layers
|
Layers
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
deviceInfo: {
|
deviceInfo: {
|
||||||
@ -76,9 +76,9 @@ const filteredAccountDevices = computed(() => {
|
|||||||
if (!searchQuery.value) return accountDevices.value
|
if (!searchQuery.value) return accountDevices.value
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
return accountDevices.value.filter(device =>
|
return accountDevices.value.filter(device =>
|
||||||
(device.name || '').toLowerCase().includes(query) ||
|
(device.name || '').toLowerCase().includes(query) ||
|
||||||
device.uuid.toLowerCase().includes(query) ||
|
device.uuid.toLowerCase().includes(query) ||
|
||||||
(device.namespace || '').toLowerCase().includes(query)
|
(device.namespace || '').toLowerCase().includes(query)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -87,8 +87,8 @@ const filteredHistoryDevices = computed(() => {
|
|||||||
if (!searchQuery.value) return historyDevices.value
|
if (!searchQuery.value) return historyDevices.value
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
return historyDevices.value.filter(device =>
|
return historyDevices.value.filter(device =>
|
||||||
(device.name || '').toLowerCase().includes(query) ||
|
(device.name || '').toLowerCase().includes(query) ||
|
||||||
device.uuid.toLowerCase().includes(query)
|
device.uuid.toLowerCase().includes(query)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ const handleManualInput = () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switchToDevice({ uuid, name: '' })
|
switchToDevice({uuid, name: ''})
|
||||||
showManualInputDialog.value = false
|
showManualInputDialog.value = false
|
||||||
manualUuid.value = ''
|
manualUuid.value = ''
|
||||||
}
|
}
|
||||||
@ -189,12 +189,12 @@ onMounted(() => {
|
|||||||
<DropdownMenu v-model:open="showDropdown">
|
<DropdownMenu v-model:open="showDropdown">
|
||||||
<template #trigger="{ toggle, open }">
|
<template #trigger="{ toggle, open }">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-8 px-3 max-w-[300px] justify-start font-normal hover:bg-accent/50 border border-border"
|
||||||
class="h-8 px-3 max-w-[300px] justify-start font-normal hover:bg-accent/50 border border-border"
|
variant="ghost"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<Monitor class="h-4 w-4 text-muted-foreground flex-shrink-0" />
|
<Monitor class="h-4 w-4 text-muted-foreground flex-shrink-0"/>
|
||||||
<div class="flex flex-col items-start min-w-0 flex-1">
|
<div class="flex flex-col items-start min-w-0 flex-1">
|
||||||
<div class="truncate text-sm font-medium max-w-[180px]">
|
<div class="truncate text-sm font-medium max-w-[180px]">
|
||||||
{{ currentDevice.name }}
|
{{ currentDevice.name }}
|
||||||
@ -202,12 +202,12 @@ onMounted(() => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 ml-auto">
|
<div class="flex items-center gap-1 ml-auto">
|
||||||
<Badge v-if="!currentDevice.isOwned" variant="secondary" class="h-4 px-1 text-[10px]">
|
<Badge v-if="!currentDevice.isOwned" class="h-4 px-1 text-[10px]" variant="secondary">
|
||||||
未绑定
|
未绑定
|
||||||
</Badge>
|
</Badge>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="h-3 w-3 text-muted-foreground flex-shrink-0 transition-transform duration-200"
|
:class="{ 'rotate-180': open }"
|
||||||
:class="{ 'rotate-180': open }"
|
class="h-3 w-3 text-muted-foreground flex-shrink-0 transition-transform duration-200"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -219,11 +219,11 @@ onMounted(() => {
|
|||||||
<!-- 搜索框 -->
|
<!-- 搜索框 -->
|
||||||
<div class="p-3 border-b">
|
<div class="p-3 border-b">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
<Search class="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
<Input
|
<Input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
placeholder="搜索设备..."
|
class="pl-9 h-8"
|
||||||
class="pl-9 h-8"
|
placeholder="搜索设备..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -232,14 +232,14 @@ onMounted(() => {
|
|||||||
<div class="p-3 border-b bg-muted/20">
|
<div class="p-3 border-b bg-muted/20">
|
||||||
<div class="text-xs font-medium text-muted-foreground mb-1">当前设备</div>
|
<div class="text-xs font-medium text-muted-foreground mb-1">当前设备</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Monitor class="h-4 w-4 text-primary" />
|
<Monitor class="h-4 w-4 text-primary"/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-sm truncate">{{ currentDevice.name }}</div>
|
<div class="font-medium text-sm truncate">{{ currentDevice.name }}</div>
|
||||||
<code class="text-xs text-muted-foreground truncate block">
|
<code class="text-xs text-muted-foreground truncate block">
|
||||||
{{ currentDevice.namespace }}
|
{{ currentDevice.namespace }}
|
||||||
</code>
|
</code>
|
||||||
</div>
|
</div>
|
||||||
<Check class="h-4 w-4 text-green-500" />
|
<Check class="h-4 w-4 text-green-500"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -247,7 +247,7 @@ onMounted(() => {
|
|||||||
<!-- 账户设备 -->
|
<!-- 账户设备 -->
|
||||||
<div v-if="accountStore.isAuthenticated">
|
<div v-if="accountStore.isAuthenticated">
|
||||||
<div class="px-3 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
<div class="px-3 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||||
<User class="h-3 w-3" />
|
<User class="h-3 w-3"/>
|
||||||
账户设备
|
账户设备
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -263,13 +263,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="device in filteredAccountDevices"
|
v-for="device in filteredAccountDevices"
|
||||||
:key="device.uuid"
|
:key="device.uuid"
|
||||||
@click="switchToDevice(device)"
|
class="cursor-pointer"
|
||||||
class="cursor-pointer"
|
@click="switchToDevice(device)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
<Monitor class="h-4 w-4 text-muted-foreground" />
|
<Monitor class="h-4 w-4 text-muted-foreground"/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-sm truncate">
|
<div class="font-medium text-sm truncate">
|
||||||
{{ device.name || '未命名设备' }}
|
{{ device.name || '未命名设备' }}
|
||||||
@ -283,24 +283,24 @@ onMounted(() => {
|
|||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator class="my-1" />
|
<Separator class="my-1"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 历史设备 -->
|
<!-- 历史设备 -->
|
||||||
<div v-if="filteredHistoryDevices.length > 0">
|
<div v-if="filteredHistoryDevices.length > 0">
|
||||||
<div class="px-3 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
<div class="px-3 py-2 text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||||
<Clock class="h-3 w-3" />
|
<Clock class="h-3 w-3"/>
|
||||||
最近使用
|
最近使用
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-for="device in filteredHistoryDevices.slice(0, 5)"
|
v-for="device in filteredHistoryDevices.slice(0, 5)"
|
||||||
:key="device.uuid"
|
:key="device.uuid"
|
||||||
@click="switchToDevice(device)"
|
class="cursor-pointer"
|
||||||
class="cursor-pointer"
|
@click="switchToDevice(device)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
<Monitor class="h-4 w-4 text-muted-foreground" />
|
<Monitor class="h-4 w-4 text-muted-foreground"/>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-medium text-sm truncate">
|
<div class="font-medium text-sm truncate">
|
||||||
{{ device.name || '未命名设备' }}
|
{{ device.name || '未命名设备' }}
|
||||||
@ -312,40 +312,41 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<Separator class="my-1" />
|
<Separator class="my-1"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 操作按钮 -->
|
||||||
<div class="p-2 border-t bg-muted/20 space-y-1">
|
<div class="p-2 border-t bg-muted/20 space-y-1">
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
v-if="!accountStore.isAuthenticated"
|
v-if="!accountStore.isAuthenticated"
|
||||||
@click="showLoginDialog = true"
|
class="cursor-pointer text-primary"
|
||||||
class="cursor-pointer text-primary"
|
@click="showLoginDialog = true"
|
||||||
>
|
>
|
||||||
<User class="h-4 w-4" />
|
<User class="h-4 w-4"/>
|
||||||
登录账户
|
登录账户
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@click="showManualInputDialog = true"
|
class="cursor-pointer"
|
||||||
class="cursor-pointer"
|
@click="showManualInputDialog = true"
|
||||||
>
|
>
|
||||||
<Settings class="h-4 w-4" />
|
<Settings class="h-4 w-4"/>
|
||||||
手动输入UUID
|
手动输入UUID
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
@click="showRegisterDialog = true"
|
class="cursor-pointer text-primary"
|
||||||
class="cursor-pointer text-primary"
|
@click="showRegisterDialog = true"
|
||||||
>
|
>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4"/>
|
||||||
注册新设备
|
注册新设备
|
||||||
</DropdownItem> <DropdownItem
|
</DropdownItem>
|
||||||
@click="showRegisterDialog = true"
|
<DropdownItem
|
||||||
class="cursor-pointer text-primary"
|
class="cursor-pointer text-primary"
|
||||||
|
@click="showRegisterDialog = true"
|
||||||
>
|
>
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4"/>
|
||||||
高级选项
|
高级选项
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</div>
|
</div>
|
||||||
@ -364,9 +365,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="space-y-4 py-4">
|
<div class="space-y-4 py-4">
|
||||||
<Input
|
<Input
|
||||||
v-model="manualUuid"
|
v-model="manualUuid"
|
||||||
placeholder="输入设备UUID"
|
placeholder="输入设备UUID"
|
||||||
@keyup.enter="handleManualInput"
|
@keyup.enter="handleManualInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -374,7 +375,7 @@ onMounted(() => {
|
|||||||
<Button variant="outline" @click="showManualInputDialog = false">
|
<Button variant="outline" @click="showManualInputDialog = false">
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="handleManualInput" :disabled="!manualUuid.trim()">
|
<Button :disabled="!manualUuid.trim()" @click="handleManualInput">
|
||||||
确定
|
确定
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -383,14 +384,14 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 登录对话框 -->
|
<!-- 登录对话框 -->
|
||||||
<LoginDialog
|
<LoginDialog
|
||||||
v-model="showLoginDialog"
|
v-model="showLoginDialog"
|
||||||
:on-success="handleLoginSuccess"
|
:on-success="handleLoginSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 设备注册对话框 -->
|
<!-- 设备注册对话框 -->
|
||||||
<DeviceRegisterDialog
|
<DeviceRegisterDialog
|
||||||
v-model="showRegisterDialog"
|
v-model="showRegisterDialog"
|
||||||
@confirm="handleDeviceRegistered"
|
@confirm="handleDeviceRegistered"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -1,13 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import {ref, computed} from 'vue'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Edit } from 'lucide-vue-next'
|
import {Edit} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@ -44,7 +44,6 @@ const isOpen = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const updateDeviceName = async () => {
|
const updateDeviceName = async () => {
|
||||||
if (!deviceName.value.trim()) {
|
if (!deviceName.value.trim()) {
|
||||||
toast.error('请输入设备名称')
|
toast.error('请输入设备名称')
|
||||||
@ -54,9 +53,9 @@ const updateDeviceName = async () => {
|
|||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
await apiClient.setDeviceName(
|
await apiClient.setDeviceName(
|
||||||
props.deviceUuid,
|
props.deviceUuid,
|
||||||
deviceName.value.trim(),
|
deviceName.value.trim(),
|
||||||
accountStore.isAuthenticated ? accountStore.token : null
|
accountStore.isAuthenticated ? accountStore.token : null
|
||||||
)
|
)
|
||||||
|
|
||||||
toast.success('设备名称已更新')
|
toast.success('设备名称已更新')
|
||||||
@ -76,7 +75,7 @@ const updateDeviceName = async () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Edit class="h-5 w-5" />
|
<Edit class="h-5 w-5"/>
|
||||||
编辑设备名称
|
编辑设备名称
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@ -89,10 +88,10 @@ const updateDeviceName = async () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="deviceName">设备名称</Label>
|
<Label for="deviceName">设备名称</Label>
|
||||||
<Input
|
<Input
|
||||||
id="deviceName"
|
id="deviceName"
|
||||||
v-model="deviceName"
|
v-model="deviceName"
|
||||||
placeholder="输入设备名称"
|
placeholder="输入设备名称"
|
||||||
@keyup.enter="updateDeviceName"
|
@keyup.enter="updateDeviceName"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -100,10 +99,10 @@ const updateDeviceName = async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="isOpen = false" :disabled="isSubmitting">
|
<Button :disabled="isSubmitting" variant="outline" @click="isOpen = false">
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="updateDeviceName" :disabled="isSubmitting || !deviceName.trim()">
|
<Button :disabled="isSubmitting || !deviceName.trim()" @click="updateDeviceName">
|
||||||
{{ isSubmitting ? '更新中...' : '确认' }}
|
{{ isSubmitting ? '更新中...' : '确认' }}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import {ref, watch} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -9,11 +9,11 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Loader2 } from 'lucide-vue-next'
|
import {Loader2} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
modelValue: Boolean,
|
||||||
@ -58,9 +58,9 @@ const saveNamespace = async () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
await apiClient.updateDeviceNamespace(
|
await apiClient.updateDeviceNamespace(
|
||||||
props.deviceUuid,
|
props.deviceUuid,
|
||||||
props.accountToken,
|
props.accountToken,
|
||||||
trimmedNamespace
|
trimmedNamespace
|
||||||
)
|
)
|
||||||
toast.success('命名空间更新成功')
|
toast.success('命名空间更新成功')
|
||||||
emit('success', trimmedNamespace)
|
emit('success', trimmedNamespace)
|
||||||
@ -96,11 +96,11 @@ const saveNamespace = async () => {
|
|||||||
</span>
|
</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="namespace"
|
id="namespace"
|
||||||
type="text"
|
v-model="namespace"
|
||||||
v-model="namespace"
|
autocomplete="off"
|
||||||
placeholder="例如: class-2024-grade1"
|
placeholder="例如: class-2024-grade1"
|
||||||
autocomplete="off"
|
type="text"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
命名空间用于自动授权接口,必须全局唯一
|
命名空间用于自动授权接口,必须全局唯一
|
||||||
@ -120,19 +120,19 @@ const saveNamespace = async () => {
|
|||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
:disabled="isLoading"
|
||||||
variant="outline"
|
type="button"
|
||||||
@click="closeDialog"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
:disabled="isLoading"
|
||||||
@click="saveNamespace"
|
type="button"
|
||||||
:disabled="isLoading"
|
@click="saveNamespace"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="isLoading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
TestTube2,
|
TestTube2,
|
||||||
@ -32,7 +32,7 @@ const features = [
|
|||||||
color: 'from-purple-500 to-pink-500',
|
color: 'from-purple-500 to-pink-500',
|
||||||
iconBg: 'bg-purple-500/10',
|
iconBg: 'bg-purple-500/10',
|
||||||
iconColor: 'text-purple-600 dark:text-purple-400',
|
iconColor: 'text-purple-600 dark:text-purple-400',
|
||||||
}, {
|
}, {
|
||||||
title: 'KV 管理器',
|
title: 'KV 管理器',
|
||||||
description: '浏览和管理键值存储数据,支持批量操作',
|
description: '浏览和管理键值存储数据,支持批量操作',
|
||||||
icon: Database,
|
icon: Database,
|
||||||
@ -68,17 +68,18 @@ const navigateTo = (path) => {
|
|||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="feature in features"
|
v-for="feature in features"
|
||||||
:key="feature.path"
|
:key="feature.path"
|
||||||
class="group cursor-pointer hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
|
class="group cursor-pointer hover:shadow-lg transition-all duration-300 hover:-translate-y-1"
|
||||||
@click="navigateTo(feature.path)"
|
@click="navigateTo(feature.path)"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between mb-3">
|
<div class="flex items-start justify-between mb-3">
|
||||||
<div :class="[feature.iconBg, 'p-3 rounded-lg']">
|
<div :class="[feature.iconBg, 'p-3 rounded-lg']">
|
||||||
<component :is="feature.icon" :class="[feature.iconColor, 'h-6 w-6']" />
|
<component :is="feature.icon" :class="[feature.iconColor, 'h-6 w-6']"/>
|
||||||
</div>
|
</div>
|
||||||
<ArrowRight class="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all" />
|
<ArrowRight
|
||||||
|
class="h-5 w-5 text-muted-foreground group-hover:text-primary group-hover:translate-x-1 transition-all"/>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-lg">{{ feature.title }}</CardTitle>
|
<CardTitle class="text-lg">{{ feature.title }}</CardTitle>
|
||||||
<CardDescription class="text-xs line-clamp-2">
|
<CardDescription class="text-xs line-clamp-2">
|
||||||
@ -86,9 +87,9 @@ const navigateTo = (path) => {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Button variant="ghost" size="sm" class="w-full group-hover:bg-primary/10">
|
<Button class="w-full group-hover:bg-primary/10" size="sm" variant="ghost">
|
||||||
前往
|
前往
|
||||||
<ArrowRight class="ml-2 h-3 w-3" />
|
<ArrowRight class="ml-2 h-3 w-3"/>
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue'
|
import {ref} from 'vue'
|
||||||
|
|
||||||
defineProps<{ msg: string }>()
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
@ -20,15 +20,15 @@ const count = ref(0)
|
|||||||
<p>
|
<p>
|
||||||
Check out
|
Check out
|
||||||
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
|
||||||
>create-vue</a
|
>create-vue</a
|
||||||
>, the official Vue + Vite starter
|
>, the official Vue + Vite starter
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Learn more about IDE Support for Vue in the
|
Learn more about IDE Support for Vue in the
|
||||||
<a
|
<a
|
||||||
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>Vue Docs Scaling up Guide</a
|
>Vue Docs Scaling up Guide</a
|
||||||
>.
|
>.
|
||||||
</p>
|
</p>
|
||||||
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
|
||||||
|
|||||||
@ -2,8 +2,8 @@
|
|||||||
<div>
|
<div>
|
||||||
<!-- 登录弹框 -->
|
<!-- 登录弹框 -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:open="isOpen"
|
v-model:open="isOpen"
|
||||||
:default-open="false"
|
:default-open="false"
|
||||||
>
|
>
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@ -17,22 +17,22 @@
|
|||||||
正在加载登录方式...
|
正在加载登录方式...
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-for="provider in providers"
|
v-for="provider in providers"
|
||||||
:key="provider.id"
|
:key="provider.id"
|
||||||
@click="handleLogin(provider)"
|
:style="{
|
||||||
class="w-full flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors hover:bg-accent"
|
|
||||||
:style="{
|
|
||||||
borderColor: (provider.color || '#666')
|
borderColor: (provider.color || '#666')
|
||||||
}"
|
}"
|
||||||
|
class="w-full flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors hover:bg-accent"
|
||||||
|
@click="handleLogin(provider)"
|
||||||
>
|
>
|
||||||
<div class="w-10 h-10 flex items-center justify-center rounded-lg bg-muted">
|
<div class="w-10 h-10 flex items-center justify-center rounded-lg bg-muted">
|
||||||
<component :is="getProviderIcon(provider.icon)" class="w-6 h-6" />
|
<component :is="getProviderIcon(provider.icon)" class="w-6 h-6"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 text-left">
|
<div class="flex-1 text-left">
|
||||||
<div class="font-medium">{{ provider.displayName || provider.name }}</div>
|
<div class="font-medium">{{ provider.displayName || provider.name }}</div>
|
||||||
<div class="text-sm text-muted-foreground">{{ provider.description }}</div>
|
<div class="text-sm text-muted-foreground">{{ provider.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight class="w-5 h-5 text-muted-foreground" />
|
<ChevronRight class="w-5 h-5 text-muted-foreground"/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
@ -42,7 +42,7 @@
|
|||||||
<div v-if="isAuthenticating" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<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="bg-background p-6 rounded-lg shadow-xl">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Loader2 class="w-5 h-5 animate-spin" />
|
<Loader2 class="w-5 h-5 animate-spin"/>
|
||||||
<span>正在进行身份验证...</span>
|
<span>正在进行身份验证...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,7 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import {ref, onMounted, watch} from 'vue'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -59,9 +59,9 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Github, Globe, ChevronRight, Loader2 } from 'lucide-vue-next'
|
import {Github, Globe, ChevronRight, Loader2} from 'lucide-vue-next'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: Boolean,
|
modelValue: Boolean,
|
||||||
@ -121,9 +121,9 @@ const handleLogin = (provider) => {
|
|||||||
const top = (window.screen.height - height) / 2
|
const top = (window.screen.height - height) / 2
|
||||||
|
|
||||||
authWindow = window.open(
|
authWindow = window.open(
|
||||||
authUrl,
|
authUrl,
|
||||||
`oauth_${provider.id}`,
|
`oauth_${provider.id}`,
|
||||||
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`
|
`width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`
|
||||||
)
|
)
|
||||||
|
|
||||||
isAuthenticating.value = true
|
isAuthenticating.value = true
|
||||||
@ -150,7 +150,7 @@ const handleLogin = (provider) => {
|
|||||||
const color = event.data.providerColor
|
const color = event.data.providerColor
|
||||||
toast.success('登录成功', {
|
toast.success('登录成功', {
|
||||||
description: `已通过 ${display} 登录`,
|
description: `已通过 ${display} 登录`,
|
||||||
style: color ? { borderLeft: `4px solid ${color}` } : undefined
|
style: color ? {borderLeft: `4px solid ${color}`} : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// 调用成功回调
|
// 调用成功回调
|
||||||
@ -193,7 +193,7 @@ const handleLogin = (provider) => {
|
|||||||
if (token) {
|
if (token) {
|
||||||
toast.success('登录成功', {
|
toast.success('登录成功', {
|
||||||
description: `已通过 ${authProvider || '账户'} 登录`,
|
description: `已通过 ${authProvider || '账户'} 登录`,
|
||||||
style: providerColor ? { borderLeft: `4px solid ${providerColor}` } : undefined
|
style: providerColor ? {borderLeft: `4px solid ${providerColor}`} : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
// 调用成功回调
|
// 调用成功回调
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import {ref, computed, watch, onMounted} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Info,
|
Info,
|
||||||
@ -81,7 +81,6 @@ const effectiveDeviceUuid = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 验证状态
|
// 验证状态
|
||||||
const validationState = computed(() => {
|
const validationState = computed(() => {
|
||||||
const errors = []
|
const errors = []
|
||||||
@ -136,7 +135,6 @@ const loadPasswordHint = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 处理输入变化
|
// 处理输入变化
|
||||||
const handleInput = (event) => {
|
const handleInput = (event) => {
|
||||||
localValue.value = event.target.value
|
localValue.value = event.target.value
|
||||||
@ -171,24 +169,24 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 密码提示按钮 -->
|
<!-- 密码提示按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="showHint && passwordHint"
|
v-if="showHint && passwordHint"
|
||||||
type="button"
|
class="group relative"
|
||||||
@click="showHintPopup = !showHintPopup"
|
type="button"
|
||||||
class="group relative"
|
@click="showHintPopup = !showHintPopup"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors">
|
<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" />
|
<HelpCircle class="h-3.5 w-3.5"/>
|
||||||
<span>密码提示</span>
|
<span>密码提示</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 密码提示弹出框 -->
|
<!-- 密码提示弹出框 -->
|
||||||
<div
|
<div
|
||||||
v-if="showHintPopup"
|
v-if="showHintPopup"
|
||||||
class="absolute right-0 top-6 z-50 w-64 animate-in fade-in slide-in-from-top-1"
|
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="rounded-lg border bg-popover p-3 shadow-lg">
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
<Info class="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0"/>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<p class="text-xs font-medium">密码提示</p>
|
<p class="text-xs font-medium">密码提示</p>
|
||||||
<p class="text-xs text-muted-foreground">{{ passwordHint }}</p>
|
<p class="text-xs text-muted-foreground">{{ passwordHint }}</p>
|
||||||
@ -203,15 +201,15 @@ onMounted(() => {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
:id="id"
|
:id="id"
|
||||||
type="text"
|
:class="{
|
||||||
:value="localValue"
|
|
||||||
@input="handleInput"
|
|
||||||
:placeholder="placeholder"
|
|
||||||
:disabled="disabled"
|
|
||||||
:class="{
|
|
||||||
'border-red-500': !validationState.isValid && localValue
|
'border-red-500': !validationState.isValid && localValue
|
||||||
}"
|
}"
|
||||||
|
:disabled="disabled"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:value="localValue"
|
||||||
|
type="text"
|
||||||
|
@input="handleInput"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 可见性切换按钮(已移除) -->
|
<!-- 可见性切换按钮(已移除) -->
|
||||||
@ -219,24 +217,23 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 内联密码提示(紧凑模式) -->
|
<!-- 内联密码提示(紧凑模式) -->
|
||||||
<div
|
<div
|
||||||
v-if="showHint && passwordHint && !showHintPopup && !localValue"
|
v-if="showHint && passwordHint && !showHintPopup && !localValue"
|
||||||
class="absolute left-0 -bottom-5 text-xs text-muted-foreground flex items-center gap-1"
|
class="absolute left-0 -bottom-5 text-xs text-muted-foreground flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<HelpCircle class="h-3 w-3" />
|
<HelpCircle class="h-3 w-3"/>
|
||||||
<span class="truncate max-w-[200px]">{{ passwordHint }}</span>
|
<span class="truncate max-w-[200px]">{{ passwordHint }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 错误信息 -->
|
<!-- 错误信息 -->
|
||||||
<div v-if="!validationState.isValid && localValue" class="space-y-1">
|
<div v-if="!validationState.isValid && localValue" class="space-y-1">
|
||||||
<div
|
<div
|
||||||
v-for="(error, index) in validationState.errors"
|
v-for="(error, index) in validationState.errors"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center gap-1.5 text-xs text-red-500"
|
class="flex items-center gap-1.5 text-xs text-red-500"
|
||||||
>
|
>
|
||||||
<AlertCircle class="h-3 w-3" />
|
<AlertCircle class="h-3 w-3"/>
|
||||||
<span>{{ error }}</span>
|
<span>{{ error }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onBeforeUnmount, ref, watch, computed } from 'vue'
|
import {onMounted, onBeforeUnmount, ref, watch, computed} from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
date: { type: [String, Number, Date], required: true },
|
date: {type: [String, Number, Date], required: true},
|
||||||
refreshMs: { type: Number, default: 60_000 }, // 默认每分钟刷新
|
refreshMs: {type: Number, default: 60_000}, // 默认每分钟刷新
|
||||||
locale: { type: String, default: 'zh-CN' },
|
locale: {type: String, default: 'zh-CN'},
|
||||||
prefix: { type: String, default: '' }, // 可选前缀,如 "于"
|
prefix: {type: String, default: ''}, // 可选前缀,如 "于"
|
||||||
suffix: { type: String, default: '' }, // 可选后缀,如 "前"
|
suffix: {type: String, default: ''}, // 可选后缀,如 "前"
|
||||||
showTooltip: { type: Boolean, default: true }, // 鼠标悬浮显示绝对时间
|
showTooltip: {type: Boolean, default: true}, // 鼠标悬浮显示绝对时间
|
||||||
})
|
})
|
||||||
|
|
||||||
const now = ref(Date.now())
|
const now = ref(Date.now())
|
||||||
@ -17,7 +17,7 @@ const dateObj = computed(() => new Date(props.date))
|
|||||||
const absText = computed(() => dateObj.value.toLocaleString(props.locale))
|
const absText = computed(() => dateObj.value.toLocaleString(props.locale))
|
||||||
|
|
||||||
function formatRelative(from, to) {
|
function formatRelative(from, to) {
|
||||||
const rtf = new Intl.RelativeTimeFormat(props.locale, { numeric: 'auto' })
|
const rtf = new Intl.RelativeTimeFormat(props.locale, {numeric: 'auto'})
|
||||||
const diff = to - from
|
const diff = to - from
|
||||||
const sec = Math.round(diff / 1000)
|
const sec = Math.round(diff / 1000)
|
||||||
const min = Math.round(sec / 60)
|
const min = Math.round(sec / 60)
|
||||||
@ -40,7 +40,9 @@ const relText = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
timer = setInterval(() => { now.value = Date.now() }, Math.max(5_000, props.refreshMs))
|
timer = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, Math.max(5_000, props.refreshMs))
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@ -49,7 +51,9 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
watch(() => props.refreshMs, (v) => {
|
watch(() => props.refreshMs, (v) => {
|
||||||
if (timer) clearInterval(timer)
|
if (timer) clearInterval(timer)
|
||||||
timer = setInterval(() => { now.value = Date.now() }, Math.max(5_000, v || 60_000))
|
timer = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, Math.max(5_000, v || 60_000))
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,27 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import {computed} from 'vue'
|
||||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, TableEmpty } from '@/components/ui/table'
|
import {
|
||||||
import { Button } from '@/components/ui/button'
|
Table,
|
||||||
import { Badge } from '@/components/ui/badge'
|
TableBody,
|
||||||
import { Copy, CheckCircle2, Key, Clock, Trash2 } from 'lucide-vue-next'
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
TableEmpty
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {Button} from '@/components/ui/button'
|
||||||
|
import {Badge} from '@/components/ui/badge'
|
||||||
|
import {Copy, CheckCircle2, Key, Clock, Trash2} from 'lucide-vue-next'
|
||||||
import RelativeTime from '@/components/RelativeTime.vue'
|
import RelativeTime from '@/components/RelativeTime.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
items: { type: Array, default: () => [] }, // [{ id, token, appId, appName?, note, installedAt }]
|
items: {type: Array, default: () => []}, // [{ id, token, appId, appName?, note, installedAt }]
|
||||||
loading: { type: Boolean, default: false },
|
loading: {type: Boolean, default: false},
|
||||||
copiedId: { type: [String, Number, null], default: null }, // 用于显示已复制状态
|
copiedId: {type: [String, Number, null], default: null}, // 用于显示已复制状态
|
||||||
showAppColumn: { type: Boolean, default: true }, // 是否显示“应用”列,嵌在应用卡片下方时可隐藏
|
showAppColumn: {type: Boolean, default: true}, // 是否显示“应用”列,嵌在应用卡片下方时可隐藏
|
||||||
compact: { type: Boolean, default: false }, // 仅显示备注(或时间),点击展开查看详情
|
compact: {type: Boolean, default: false}, // 仅显示备注(或时间),点击展开查看详情
|
||||||
sortByTime: { type: Boolean, default: false }, // 按时间倒序排序
|
sortByTime: {type: Boolean, default: false}, // 按时间倒序排序
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['copy', 'revoke'])
|
const emit = defineEmits(['copy', 'revoke'])
|
||||||
@ -63,7 +72,7 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow v-else-if="rows.length === 0">
|
<TableRow v-else-if="rows.length === 0">
|
||||||
<TableCell :colspan="colCount">
|
<TableCell :colspan="colCount">
|
||||||
<TableEmpty icon="package" description="暂无数据" />
|
<TableEmpty description="暂无数据" icon="package"/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<!-- 非紧凑模式:完整列集 -->
|
<!-- 非紧凑模式:完整列集 -->
|
||||||
@ -71,7 +80,7 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
|||||||
<TableRow v-for="item in rows" :key="item.id">
|
<TableRow v-for="item in rows" :key="item.id">
|
||||||
<TableCell v-if="props.showAppColumn">
|
<TableCell v-if="props.showAppColumn">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant="secondary" class="shrink-0">{{ item.appId }}</Badge>
|
<Badge class="shrink-0" variant="secondary">{{ item.appId }}</Badge>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-medium truncate">{{ item.appName || `应用 ${item.appId}` }}</div>
|
<div class="font-medium truncate">{{ item.appName || `应用 ${item.appId}` }}</div>
|
||||||
<div class="text-xs text-muted-foreground truncate">ID: {{ item.appId }}</div>
|
<div class="text-xs text-muted-foreground truncate">ID: {{ item.appId }}</div>
|
||||||
@ -80,17 +89,17 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Key class="h-3.5 w-3.5 text-muted-foreground" />
|
<Key class="h-3.5 w-3.5 text-muted-foreground"/>
|
||||||
<code class="text-xs font-mono truncate">{{ item.token }}</code>
|
<code class="text-xs font-mono truncate">{{ item.token }}</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
:title="props.copiedId === item.token ? '已复制' : '复制令牌'"
|
||||||
size="sm"
|
class="h-7 w-7 ml-auto"
|
||||||
class="h-7 w-7 ml-auto"
|
size="sm"
|
||||||
@click="emit('copy', item)"
|
variant="ghost"
|
||||||
:title="props.copiedId === item.token ? '已复制' : '复制令牌'"
|
@click="emit('copy', item)"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="props.copiedId === item.token" class="h-3.5 w-3.5 text-green-500" />
|
<CheckCircle2 v-if="props.copiedId === item.token" class="h-3.5 w-3.5 text-green-500"/>
|
||||||
<Copy v-else class="h-3.5 w-3.5" />
|
<Copy v-else class="h-3.5 w-3.5"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -99,20 +108,21 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="text-right">
|
||||||
<div class="flex items-center justify-end gap-1 text-sm text-muted-foreground">
|
<div class="flex items-center justify-end gap-1 text-sm text-muted-foreground">
|
||||||
<Clock class="h-3.5 w-3.5" />
|
<Clock class="h-3.5 w-3.5"/>
|
||||||
<span>
|
<span>
|
||||||
<RelativeTime :date="item.installedAt" />
|
<RelativeTime :date="item.installedAt"/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="text-right">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
variant="ghost"
|
||||||
@click="emit('revoke', item)"
|
@click="emit('revoke', item)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3.5 w-3.5 mr-1" /> 撤销
|
<Trash2 class="h-3.5 w-3.5 mr-1"/>
|
||||||
|
撤销
|
||||||
</Button>
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -121,16 +131,16 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
|||||||
<!-- 紧凑模式:仅显示备注(无备注显示时间),点击触发 open 事件 -->
|
<!-- 紧凑模式:仅显示备注(无备注显示时间),点击触发 open 事件 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<TableRow
|
<TableRow
|
||||||
v-for="item in rows"
|
v-for="item in rows"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="cursor-pointer hover:bg-muted/50"
|
class="cursor-pointer hover:bg-muted/50"
|
||||||
@click="emit('open', item)"
|
@click="emit('open', item)"
|
||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div class="text-sm font-medium truncate">
|
<div class="text-sm font-medium truncate">
|
||||||
{{ item.note || '' }}
|
{{ item.note || '' }}
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
<RelativeTime :date="item.installedAt" />
|
<RelativeTime :date="item.installedAt"/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -143,5 +153,10 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.truncate { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.truncate {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,134 +1,142 @@
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import {onMounted, onUnmounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理OAuth回调
|
* 处理OAuth回调
|
||||||
* 检查URL参数中是否有OAuth回调信息
|
* 检查URL参数中是否有OAuth回调信息
|
||||||
*/
|
*/
|
||||||
export function useOAuthCallback() {
|
export function useOAuthCallback() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const accountStore = useAccountStore()
|
const accountStore = useAccountStore()
|
||||||
|
|
||||||
const handleOAuthCallback = async () => {
|
const handleOAuthCallback = async () => {
|
||||||
const { token, provider, color, success, error } = route.query
|
const {token, provider, color, success, error} = route.query
|
||||||
// 新版参数:access_token / refresh_token / expires_in / providerName / providerColor
|
// 新版参数:access_token / refresh_token / expires_in / providerName / providerColor
|
||||||
const access_token = route.query.access_token || token
|
const access_token = route.query.access_token || token
|
||||||
const refresh_token = route.query.refresh_token || null
|
const refresh_token = route.query.refresh_token || null
|
||||||
const providerName = route.query.providerName || provider
|
const providerName = route.query.providerName || provider
|
||||||
const providerColor = route.query.providerColor || color
|
const providerColor = route.query.providerColor || color
|
||||||
|
|
||||||
// 检查是否是OAuth回调
|
// 检查是否是OAuth回调
|
||||||
if (!success && !error) {
|
if (!success && !error) {
|
||||||
return
|
return
|
||||||
}
|
|
||||||
|
|
||||||
// 处理成功回调
|
|
||||||
if (success === 'true' && access_token) {
|
|
||||||
try {
|
|
||||||
// 保存到 Store(同时写入 localStorage)
|
|
||||||
accountStore.setTokens(access_token, refresh_token)
|
|
||||||
if (providerName) localStorage.setItem('auth_provider', providerName)
|
|
||||||
if (providerColor) localStorage.setItem('auth_provider_color', providerColor)
|
|
||||||
|
|
||||||
// 登录到store(加载资料、设备)
|
|
||||||
await accountStore.login(access_token)
|
|
||||||
|
|
||||||
// 显示成功提示
|
|
||||||
toast.success('登录成功', {
|
|
||||||
description: `已通过 ${provider} 登录`,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 清除URL参数
|
|
||||||
router.replace({ query: {} })
|
|
||||||
|
|
||||||
// 触发storage事件,通知其他窗口
|
|
||||||
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_token', newValue: access_token, url: window.location.href }))
|
|
||||||
if (refresh_token) {
|
|
||||||
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_refresh_token', newValue: refresh_token, url: window.location.href }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
// 处理成功回调
|
||||||
if (window.opener) {
|
if (success === 'true' && access_token) {
|
||||||
// 通知父窗口登录成功
|
try {
|
||||||
window.opener.postMessage({
|
// 保存到 Store(同时写入 localStorage)
|
||||||
type: 'oauth_success',
|
accountStore.setTokens(access_token, refresh_token)
|
||||||
token: access_token,
|
if (providerName) localStorage.setItem('auth_provider', providerName)
|
||||||
provider: providerName,
|
if (providerColor) localStorage.setItem('auth_provider_color', providerColor)
|
||||||
}, window.location.origin)
|
|
||||||
|
|
||||||
// 延迟关闭窗口,确保消息已发送
|
// 登录到store(加载资料、设备)
|
||||||
setTimeout(() => {
|
await accountStore.login(access_token)
|
||||||
window.close()
|
|
||||||
}, 1000)
|
// 显示成功提示
|
||||||
|
toast.success('登录成功', {
|
||||||
|
description: `已通过 ${provider} 登录`,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 清除URL参数
|
||||||
|
router.replace({query: {}})
|
||||||
|
|
||||||
|
// 触发storage事件,通知其他窗口
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', {
|
||||||
|
key: 'auth_token',
|
||||||
|
newValue: access_token,
|
||||||
|
url: window.location.href
|
||||||
|
}))
|
||||||
|
if (refresh_token) {
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', {
|
||||||
|
key: 'auth_refresh_token',
|
||||||
|
newValue: refresh_token,
|
||||||
|
url: window.location.href
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
||||||
|
if (window.opener) {
|
||||||
|
// 通知父窗口登录成功
|
||||||
|
window.opener.postMessage({
|
||||||
|
type: 'oauth_success',
|
||||||
|
token: access_token,
|
||||||
|
provider: providerName,
|
||||||
|
}, window.location.origin)
|
||||||
|
|
||||||
|
// 延迟关闭窗口,确保消息已发送
|
||||||
|
setTimeout(() => {
|
||||||
|
window.close()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('登录失败', {
|
||||||
|
description: err.message || '处理登录信息时出错'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err) {
|
// 处理错误回调
|
||||||
toast.error('登录失败', {
|
if (success === 'false' || error) {
|
||||||
description: err.message || '处理登录信息时出错'
|
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(() => {
|
||||||
if (success === 'false' || error) {
|
handleOAuthCallback()
|
||||||
const errorMessages = {
|
})
|
||||||
'invalid_state': 'State验证失败,可能存在安全风险',
|
|
||||||
'access_denied': '用户拒绝了授权请求',
|
|
||||||
'temporarily_unavailable': '服务暂时不可用,请稍后重试'
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMsg = errorMessages[error] || error || '登录过程中出现错误'
|
// 监听storage事件,处理其他标签页的登录
|
||||||
|
const handleStorageChange = (e) => {
|
||||||
toast.error('登录失败', {
|
if (e.key === 'auth_token' && e.newValue) {
|
||||||
description: errorMsg
|
// 其他标签页已登录,刷新当前页面的状态
|
||||||
})
|
accountStore.login(e.newValue)
|
||||||
|
}
|
||||||
// 清除URL参数
|
if (e.key === 'auth_refresh_token' && e.newValue) {
|
||||||
router.replace({ query: {} })
|
accountStore.setTokens(accountStore.token, e.newValue)
|
||||||
|
}
|
||||||
// 如果是在新窗口中打开的OAuth回调,自动关闭窗口
|
|
||||||
if (window.opener) {
|
|
||||||
// 通知父窗口登录失败
|
|
||||||
window.opener.postMessage({
|
|
||||||
type: 'oauth_error',
|
|
||||||
error: errorMsg
|
|
||||||
}, window.location.origin)
|
|
||||||
|
|
||||||
// 延迟关闭窗口
|
|
||||||
setTimeout(() => {
|
|
||||||
window.close()
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
handleOAuthCallback()
|
window.addEventListener('storage', handleStorageChange)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听storage事件,处理其他标签页的登录
|
onUnmounted(() => {
|
||||||
const handleStorageChange = (e) => {
|
window.removeEventListener('storage', handleStorageChange)
|
||||||
if (e.key === 'auth_token' && e.newValue) {
|
})
|
||||||
// 其他标签页已登录,刷新当前页面的状态
|
|
||||||
accountStore.login(e.newValue)
|
return {
|
||||||
|
handleOAuthCallback
|
||||||
}
|
}
|
||||||
if (e.key === 'auth_refresh_token' && e.newValue) {
|
|
||||||
accountStore.setTokens(accountStore.token, e.newValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
window.addEventListener('storage', handleStorageChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
window.removeEventListener('storage', handleStorageChange)
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
handleOAuthCallback
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
838
src/lib/api.js
838
src/lib/api.js
@ -4,453 +4,447 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030
|
|||||||
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
|
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
constructor(baseUrl, siteKey) {
|
constructor(baseUrl, siteKey) {
|
||||||
this.baseUrl = baseUrl
|
this.baseUrl = baseUrl
|
||||||
this.siteKey = siteKey
|
this.siteKey = siteKey
|
||||||
}
|
|
||||||
|
|
||||||
async fetch(endpoint, options = {}) {
|
|
||||||
const method = options.method || 'GET'
|
|
||||||
const headers = { ...options.headers }
|
|
||||||
const data = options.body
|
|
||||||
const params = options.params
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 通过 axios 实例发起请求(已内置 baseURL 与 x-site-key)
|
|
||||||
const result = await axiosInstance.request({
|
|
||||||
url: endpoint,
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
data,
|
|
||||||
params,
|
|
||||||
})
|
|
||||||
|
|
||||||
// axios 响应拦截器已返回 response.data,这里做空值统一
|
|
||||||
if (result === '' || result === undefined || result === null) return {}
|
|
||||||
return result
|
|
||||||
} catch (err) {
|
|
||||||
// 某些后端会在非 2xx 状态下直接返回有效数据,这里兜底返回 body
|
|
||||||
const resp = err?.response
|
|
||||||
if (resp && resp.data !== undefined) {
|
|
||||||
return resp.data
|
|
||||||
}
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 带认证的fetch
|
|
||||||
async authenticatedFetch(endpoint, options = {}, token = null) {
|
|
||||||
const headers = {
|
|
||||||
...options.headers,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果提供了token,添加Authorization头
|
async fetch(endpoint, options = {}) {
|
||||||
if (token) {
|
const method = options.method || 'GET'
|
||||||
headers['Authorization'] = `Bearer ${token}`
|
const headers = {...options.headers}
|
||||||
|
const data = options.body
|
||||||
|
const params = options.params
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 通过 axios 实例发起请求(已内置 baseURL 与 x-site-key)
|
||||||
|
const result = await axiosInstance.request({
|
||||||
|
url: endpoint,
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
data,
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
|
||||||
|
// axios 响应拦截器已返回 response.data,这里做空值统一
|
||||||
|
if (result === '' || result === undefined || result === null) return {}
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
// 某些后端会在非 2xx 状态下直接返回有效数据,这里兜底返回 body
|
||||||
|
const resp = err?.response
|
||||||
|
if (resp && resp.data !== undefined) {
|
||||||
|
return resp.data
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fetch(endpoint, {
|
// 带认证的fetch
|
||||||
...options,
|
async authenticatedFetch(endpoint, options = {}, token = null) {
|
||||||
headers,
|
const headers = {
|
||||||
})
|
...options.headers,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用相关 API
|
// 如果提供了token,添加Authorization头
|
||||||
async getApps(params = {}) {
|
if (token) {
|
||||||
const query = new URLSearchParams(params).toString()
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
return this.fetch(`/apps${query ? `?${query}` : ''}`)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
async getApp(appId) {
|
return this.fetch(endpoint, {
|
||||||
return this.fetch(`/apps/info/${appId}`)
|
...options,
|
||||||
}
|
headers,
|
||||||
|
})
|
||||||
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, usePathParam = true, bearerToken } = authOptions;
|
|
||||||
|
|
||||||
if (usePathParam) {
|
|
||||||
// 使用路径参数方式 (推荐)
|
|
||||||
const headers = {};
|
|
||||||
|
|
||||||
if (bearerToken) {
|
|
||||||
headers['Authorization'] = `Bearer ${bearerToken}`;
|
|
||||||
} else if (deviceUuid) {
|
|
||||||
headers['x-device-uuid'] = deviceUuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.fetch(`/apps/tokens?${params}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId)
|
|
||||||
async authorizeApp(appId, deviceUuid, options = {}) {
|
|
||||||
const { note, token } = options;
|
|
||||||
|
|
||||||
const headers = {
|
|
||||||
'x-device-uuid': deviceUuid,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用新的安装接口
|
// 应用相关 API
|
||||||
return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}`, {
|
async getApps(params = {}) {
|
||||||
method: 'POST',
|
const query = new URLSearchParams(params).toString()
|
||||||
headers,
|
return this.fetch(`/apps${query ? `?${query}` : ''}`)
|
||||||
body: JSON.stringify({ note: note || '应用授权' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备级别的应用卸载,使用新的 uninstall 接口
|
|
||||||
async revokeDeviceToken(deviceUuid, installId, token = null) {
|
|
||||||
const params = new URLSearchParams({ uuid: deviceUuid });
|
|
||||||
const headers = {};
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fetch(`/apps/devices/${deviceUuid}/uninstall/${installId}?${params}`, {
|
async getApp(appId) {
|
||||||
method: 'DELETE',
|
return this.fetch(`/apps/info/${appId}`)
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 设备授权相关 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(Authorization 由 axios 拦截器统一注入)
|
|
||||||
async getOAuthProviders() {
|
|
||||||
return this.fetch('/accounts/oauth/providers')
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAccountProfile() {
|
|
||||||
return this.fetch('/accounts/profile')
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAccountDevices() {
|
|
||||||
return this.fetch('/accounts/devices')
|
|
||||||
}
|
|
||||||
|
|
||||||
async bindDevice(deviceUuid) {
|
|
||||||
return this.fetch('/accounts/devices/bind', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async unbindDevice(deviceUuid) {
|
|
||||||
return this.fetch('/accounts/devices/unbind', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDeviceAccount(deviceUuid) {
|
|
||||||
return this.fetch(`/accounts/device/${deviceUuid}/account`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 账户 Token 刷新与信息 =====
|
|
||||||
async refreshAccessToken(refreshToken) {
|
|
||||||
return this.fetch('/accounts/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
// 刷新接口不应由请求拦截器附加旧的 Authorization
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async getTokenInfo(accessToken) {
|
|
||||||
return this.fetch('/accounts/token-info', {
|
|
||||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 绑定设备到当前账户
|
|
||||||
async bindDeviceToAccount(deviceUuid) {
|
|
||||||
return this.fetch('/accounts/devices/bind', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解绑设备
|
|
||||||
async unbindDeviceFromAccount(deviceUuid) {
|
|
||||||
return this.fetch('/accounts/devices/unbind', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ uuid: deviceUuid }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 批量解绑设备
|
|
||||||
async batchUnbindDevices(deviceUuids) {
|
|
||||||
return this.fetch('/accounts/devices/unbind', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ uuids: deviceUuids }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备名称管理 API
|
|
||||||
async setDeviceName(deviceUuid, name, token = null) {
|
|
||||||
const headers = {
|
|
||||||
'x-device-uuid': deviceUuid,
|
|
||||||
};
|
|
||||||
if (token) {
|
|
||||||
headers['Authorization'] = `Bearer ${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.fetch(`/devices/${deviceUuid}/name`, {
|
async getAppInstallations(appId, deviceUuid, params = {}) {
|
||||||
method: 'PUT',
|
const query = new URLSearchParams(params).toString()
|
||||||
headers,
|
return this.fetch(`/apps/info/${appId}/device-installations${query ? `?${query}` : ''}`, {
|
||||||
body: JSON.stringify({ name }),
|
headers: {
|
||||||
});
|
'x-device-uuid': deviceUuid,
|
||||||
}
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
// 设备注册 API
|
|
||||||
async registerDevice(uuid, deviceName, token = null) {
|
|
||||||
return this.authenticatedFetch('/devices', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ uuid, deviceName }),
|
|
||||||
}, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 兼容性方法 - 保持旧的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) {
|
|
||||||
return this.revokeToken(targetToken, {
|
|
||||||
deviceUuid,
|
|
||||||
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 });
|
// Token 管理 API
|
||||||
}
|
async getDeviceTokens(deviceUuid, options = {}) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
uuid: deviceUuid,
|
||||||
|
});
|
||||||
|
|
||||||
async revokeTokenWithAuth(targetToken, authType, authValue) {
|
return this.fetch(`/apps/tokens?${params}`);
|
||||||
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}`, {
|
async revokeToken(targetToken, authOptions = {}) {
|
||||||
method: 'DELETE',
|
const {deviceUuid, usePathParam = true, bearerToken} = authOptions;
|
||||||
headers,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ AutoAuth 管理 API ============
|
if (usePathParam) {
|
||||||
// 注意:所有 AutoAuth 管理接口现在需要 JWT Token 认证
|
// 使用路径参数方式 (推荐)
|
||||||
// 只有已绑定账户的设备才能使用这些接口
|
const headers = {};
|
||||||
|
|
||||||
// 获取设备的自动授权配置列表
|
if (bearerToken) {
|
||||||
async getAutoAuthConfigs(deviceUuid, token) {
|
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||||
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs`, {
|
} else if (deviceUuid) {
|
||||||
method: 'GET',
|
headers['x-device-uuid'] = deviceUuid;
|
||||||
}, token);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 创建自动授权配置
|
return this.fetch(`/apps/tokens/${targetToken}`, {
|
||||||
async createAutoAuthConfig(deviceUuid, token, config) {
|
method: 'DELETE',
|
||||||
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs`, {
|
headers,
|
||||||
method: 'POST',
|
});
|
||||||
body: JSON.stringify(config),
|
} else {
|
||||||
}, token);
|
// 使用查询参数方式 (向后兼容)
|
||||||
}
|
const params = new URLSearchParams({token: targetToken});
|
||||||
|
const headers = {};
|
||||||
|
|
||||||
// 更新自动授权配置
|
if (bearerToken) {
|
||||||
async updateAutoAuthConfig(deviceUuid, token, configId, updates) {
|
headers['Authorization'] = `Bearer ${bearerToken}`;
|
||||||
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs/${configId}`, {
|
} else if (deviceUuid) {
|
||||||
method: 'PUT',
|
headers['x-device-uuid'] = deviceUuid;
|
||||||
body: JSON.stringify(updates),
|
}
|
||||||
}, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 删除自动授权配置
|
return this.fetch(`/apps/tokens?${params}`, {
|
||||||
async deleteAutoAuthConfig(deviceUuid, token, configId) {
|
method: 'DELETE',
|
||||||
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs/${configId}`, {
|
headers,
|
||||||
method: 'DELETE',
|
});
|
||||||
}, token);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 修改设备的 namespace
|
// 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId)
|
||||||
async updateDeviceNamespace(deviceUuid, token, namespace) {
|
async authorizeApp(appId, deviceUuid, options = {}) {
|
||||||
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/namespace`, {
|
const {note, token} = options;
|
||||||
method: 'PUT',
|
|
||||||
body: JSON.stringify({ namespace }),
|
|
||||||
}, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通过 namespace 和密码获取 token (Apps API)
|
const headers = {
|
||||||
async getTokenByNamespace(namespace, password, appId) {
|
'x-device-uuid': deviceUuid,
|
||||||
return this.fetch('/apps/auth/token', {
|
};
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ namespace, password, appId }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置学生名称 (Apps API)
|
if (token) {
|
||||||
async setStudentName(token, name) {
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
return this.fetch(`/apps/tokens/${token}/set-student-name`, {
|
}
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ name }),
|
// 使用新的安装接口
|
||||||
});
|
return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}`, {
|
||||||
}
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({note: note || '应用授权'}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备级别的应用卸载,使用新的 uninstall 接口
|
||||||
|
async revokeDeviceToken(deviceUuid, installId, token = null) {
|
||||||
|
const params = new URLSearchParams({uuid: deviceUuid});
|
||||||
|
const headers = {};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fetch(`/apps/devices/${deviceUuid}/uninstall/${installId}?${params}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 设备授权相关 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(Authorization 由 axios 拦截器统一注入)
|
||||||
|
async getOAuthProviders() {
|
||||||
|
return this.fetch('/accounts/oauth/providers')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountProfile() {
|
||||||
|
return this.fetch('/accounts/profile')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAccountDevices() {
|
||||||
|
return this.fetch('/accounts/devices')
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindDevice(deviceUuid) {
|
||||||
|
return this.fetch('/accounts/devices/bind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uuid: deviceUuid}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async unbindDevice(deviceUuid) {
|
||||||
|
return this.fetch('/accounts/devices/unbind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uuid: deviceUuid}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDeviceAccount(deviceUuid) {
|
||||||
|
return this.fetch(`/accounts/device/${deviceUuid}/account`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 账户 Token 刷新与信息 =====
|
||||||
|
async refreshAccessToken(refreshToken) {
|
||||||
|
return this.fetch('/accounts/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
// 刷新接口不应由请求拦截器附加旧的 Authorization
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({refresh_token: refreshToken}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTokenInfo(accessToken) {
|
||||||
|
return this.fetch('/accounts/token-info', {
|
||||||
|
headers: {'Authorization': `Bearer ${accessToken}`}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定设备到当前账户
|
||||||
|
async bindDeviceToAccount(deviceUuid) {
|
||||||
|
return this.fetch('/accounts/devices/bind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uuid: deviceUuid}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解绑设备
|
||||||
|
async unbindDeviceFromAccount(deviceUuid) {
|
||||||
|
return this.fetch('/accounts/devices/unbind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uuid: deviceUuid}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量解绑设备
|
||||||
|
async batchUnbindDevices(deviceUuids) {
|
||||||
|
return this.fetch('/accounts/devices/unbind', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uuids: deviceUuids}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设备名称管理 API
|
||||||
|
async setDeviceName(deviceUuid, name, token = null) {
|
||||||
|
const headers = {
|
||||||
|
'x-device-uuid': deviceUuid,
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.fetch(`/devices/${deviceUuid}/name`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({name}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 设备注册 API
|
||||||
|
async registerDevice(uuid, deviceName, token = null) {
|
||||||
|
return this.authenticatedFetch('/devices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({uuid, deviceName}),
|
||||||
|
}, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 兼容性方法 - 保持旧的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) {
|
||||||
|
return this.revokeToken(targetToken, {
|
||||||
|
deviceUuid,
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ AutoAuth 管理 API ============
|
||||||
|
// 注意:所有 AutoAuth 管理接口现在需要 JWT Token 认证
|
||||||
|
// 只有已绑定账户的设备才能使用这些接口
|
||||||
|
|
||||||
|
// 获取设备的自动授权配置列表
|
||||||
|
async getAutoAuthConfigs(deviceUuid, token) {
|
||||||
|
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs`, {
|
||||||
|
method: 'GET',
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建自动授权配置
|
||||||
|
async createAutoAuthConfig(deviceUuid, token, config) {
|
||||||
|
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新自动授权配置
|
||||||
|
async updateAutoAuthConfig(deviceUuid, token, configId, updates) {
|
||||||
|
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs/${configId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除自动授权配置
|
||||||
|
async deleteAutoAuthConfig(deviceUuid, token, configId) {
|
||||||
|
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/auth-configs/${configId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 修改设备的 namespace
|
||||||
|
async updateDeviceNamespace(deviceUuid, token, namespace) {
|
||||||
|
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/namespace`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({namespace}),
|
||||||
|
}, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过 namespace 和密码获取 token (Apps API)
|
||||||
|
async getTokenByNamespace(namespace, password, appId) {
|
||||||
|
return this.fetch('/apps/auth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({namespace, password, appId}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置学生名称 (Apps API)
|
||||||
|
async setStudentName(token, name) {
|
||||||
|
return this.fetch(`/apps/tokens/${token}/set-student-name`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({name}),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)
|
export const apiClient = new ApiClient(API_BASE_URL, SITE_KEY)
|
||||||
|
|||||||
389
src/lib/axios.js
389
src/lib/axios.js
@ -1,132 +1,139 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { deviceStore } from './deviceStore'
|
import {deviceStore} from './deviceStore'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3030'
|
||||||
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
|
const SITE_KEY = import.meta.env.VITE_SITE_KEY || ''
|
||||||
|
|
||||||
// 创建 axios 实例
|
// 创建 axios 实例
|
||||||
const axiosInstance = axios.create({
|
const axiosInstance = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'x-site-key': SITE_KEY,
|
'x-site-key': SITE_KEY,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// ===== 认证相关 Hooks(由账户 Store 注入)=====
|
// ===== 认证相关 Hooks(由账户 Store 注入)=====
|
||||||
let authHandlers = {
|
let authHandlers = {
|
||||||
// 可选:返回访问令牌
|
// 可选:返回访问令牌
|
||||||
getAccessToken: () => null,
|
getAccessToken: () => null,
|
||||||
// 可选:返回刷新令牌
|
// 可选:返回刷新令牌
|
||||||
getRefreshToken: () => null,
|
getRefreshToken: () => null,
|
||||||
// 可选:仅更新访问令牌
|
// 可选:仅更新访问令牌
|
||||||
setAccessToken: (_t) => {},
|
setAccessToken: (_t) => {
|
||||||
// 可选:执行刷新动作,返回新的访问令牌
|
},
|
||||||
refreshAccessToken: null,
|
// 可选:执行刷新动作,返回新的访问令牌
|
||||||
// 可选:当刷新失败时回调(例如触发登出)
|
refreshAccessToken: null,
|
||||||
onAuthFailure: () => {},
|
// 可选:当刷新失败时回调(例如触发登出)
|
||||||
|
onAuthFailure: () => {
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对外方法:由外部(如 Pinia store)注入实际的处理函数
|
// 对外方法:由外部(如 Pinia store)注入实际的处理函数
|
||||||
export function setAuthHandlers(handlers) {
|
export function setAuthHandlers(handlers) {
|
||||||
authHandlers = { ...authHandlers, ...(handlers || {}) }
|
authHandlers = {...authHandlers, ...(handlers || {})}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
axiosInstance.interceptors.request.use(
|
axiosInstance.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
try {
|
try {
|
||||||
// 为非刷新接口自动附加 Authorization 头
|
// 为非刷新接口自动附加 Authorization 头
|
||||||
const isRefreshRequest = typeof config.url === 'string' && /\/accounts\/refresh(\b|\/|\?)/.test(config.url)
|
const isRefreshRequest = typeof config.url === 'string' && /\/accounts\/refresh(\b|\/|\?)/.test(config.url)
|
||||||
const skipAuth = config.__skipAuth === true || isRefreshRequest
|
const skipAuth = config.__skipAuth === true || isRefreshRequest
|
||||||
if (!skipAuth && authHandlers?.getAccessToken) {
|
if (!skipAuth && authHandlers?.getAccessToken) {
|
||||||
const token = authHandlers.getAccessToken()
|
const token = authHandlers.getAccessToken()
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers = config.headers || {}
|
config.headers = config.headers || {}
|
||||||
config.headers['Authorization'] = `Bearer ${token}`
|
config.headers['Authorization'] = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
return config
|
||||||
} catch {
|
},
|
||||||
// ignore
|
(error) => {
|
||||||
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
return config
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 设备注册去重(避免并发重复注册)
|
// 设备注册去重(避免并发重复注册)
|
||||||
const registrationLocks = new Map()
|
const registrationLocks = new Map()
|
||||||
|
|
||||||
function getHeaderIgnoreCase(headers = {}, key) {
|
function getHeaderIgnoreCase(headers = {}, key) {
|
||||||
if (!headers) return undefined
|
if (!headers) return undefined
|
||||||
const lowerKey = key.toLowerCase()
|
const lowerKey = key.toLowerCase()
|
||||||
for (const k of Object.keys(headers)) {
|
for (const k of Object.keys(headers)) {
|
||||||
if (k.toLowerCase() === lowerKey) return headers[k]
|
if (k.toLowerCase() === lowerKey) return headers[k]
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUuidFromUrl(url = '') {
|
function extractUuidFromUrl(url = '') {
|
||||||
try {
|
try {
|
||||||
const path = url || ''
|
const path = url || ''
|
||||||
// /apps/devices/{uuid}/...
|
// /apps/devices/{uuid}/...
|
||||||
let m = path.match(/\/apps\/devices\/([0-9a-fA-F-]{8,})/)
|
let m = path.match(/\/apps\/devices\/([0-9a-fA-F-]{8,})/)
|
||||||
if (m) return m[1]
|
if (m) return m[1]
|
||||||
// /devices/{uuid}/...
|
// /devices/{uuid}/...
|
||||||
m = path.match(/\/devices\/([0-9a-fA-F-]{8,})/)
|
m = path.match(/\/devices\/([0-9a-fA-F-]{8,})/)
|
||||||
if (m) return m[1]
|
if (m) return m[1]
|
||||||
// /accounts/device/{uuid}
|
// /accounts/device/{uuid}
|
||||||
m = path.match(/\/accounts\/device\/([0-9a-fA-F-]{8,})/)
|
m = path.match(/\/accounts\/device\/([0-9a-fA-F-]{8,})/)
|
||||||
if (m) return m[1]
|
if (m) return m[1]
|
||||||
|
|
||||||
// query ?uuid=...
|
// query ?uuid=...
|
||||||
const qIndex = path.indexOf('?')
|
const qIndex = path.indexOf('?')
|
||||||
if (qIndex >= 0) {
|
if (qIndex >= 0) {
|
||||||
const usp = new URLSearchParams(path.slice(qIndex + 1))
|
const usp = new URLSearchParams(path.slice(qIndex + 1))
|
||||||
const q = usp.get('uuid')
|
const q = usp.get('uuid')
|
||||||
if (q) return q
|
if (q) return q
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
} catch (e) {
|
return undefined
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureDeviceRegistered(uuid, authHeader) {
|
async function ensureDeviceRegistered(uuid, authHeader) {
|
||||||
if (!uuid) return false
|
if (!uuid) return false
|
||||||
if (registrationLocks.has(uuid)) {
|
if (registrationLocks.has(uuid)) {
|
||||||
try {
|
try {
|
||||||
await registrationLocks.get(uuid)
|
await registrationLocks.get(uuid)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const deviceName = '未命名设备'
|
||||||
const deviceName = '未命名设备'
|
const headers = {}
|
||||||
const headers = {}
|
if (authHeader) headers['Authorization'] = authHeader
|
||||||
if (authHeader) headers['Authorization'] = authHeader
|
|
||||||
|
|
||||||
const p = axiosInstance.post(
|
const p = axiosInstance.post(
|
||||||
'/devices',
|
'/devices',
|
||||||
{ uuid, deviceName },
|
{uuid, deviceName},
|
||||||
{ headers, // 避免递归触发注册重试
|
{
|
||||||
skipDeviceRegistrationRetry: true,
|
headers, // 避免递归触发注册重试
|
||||||
__isRegistrationRequest: true }
|
skipDeviceRegistrationRetry: true,
|
||||||
)
|
__isRegistrationRequest: true
|
||||||
registrationLocks.set(uuid, p)
|
}
|
||||||
try {
|
)
|
||||||
await p
|
registrationLocks.set(uuid, p)
|
||||||
// 保存UUID到本地存储,确保后续可用
|
try {
|
||||||
try { deviceStore.setDeviceUuid(uuid) } catch {}
|
await p
|
||||||
return true
|
// 保存UUID到本地存储,确保后续可用
|
||||||
} catch (e) {
|
try {
|
||||||
return false
|
deviceStore.setDeviceUuid(uuid)
|
||||||
} finally {
|
} catch {
|
||||||
registrationLocks.delete(uuid)
|
}
|
||||||
}
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
registrationLocks.delete(uuid)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新中的 Promise(用于合并并发 401 刷新)
|
// 刷新中的 Promise(用于合并并发 401 刷新)
|
||||||
@ -134,101 +141,115 @@ let refreshingPromise = null
|
|||||||
|
|
||||||
// 响应拦截器(含自动换发、自动注册并重试、401 刷新)
|
// 响应拦截器(含自动换发、自动注册并重试、401 刷新)
|
||||||
axiosInstance.interceptors.response.use(
|
axiosInstance.interceptors.response.use(
|
||||||
(response) => {
|
(response) => {
|
||||||
// 如果服务端主动通过响应头下发新访问令牌,更新本地
|
// 如果服务端主动通过响应头下发新访问令牌,更新本地
|
||||||
try {
|
|
||||||
const headers = response?.headers || {}
|
|
||||||
const newToken = getHeaderIgnoreCase(headers, 'X-New-Access-Token')
|
|
||||||
if (newToken && authHandlers?.setAccessToken) {
|
|
||||||
authHandlers.setAccessToken(newToken)
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
async (error) => {
|
|
||||||
const config = error.config || {}
|
|
||||||
const resp = error.response
|
|
||||||
const status = resp?.status
|
|
||||||
const skip = config.skipDeviceRegistrationRetry || config.__isRegistrationRequest
|
|
||||||
const backendMessage = resp?.data?.message
|
|
||||||
const message = backendMessage || error.message || 'Unknown error'
|
|
||||||
|
|
||||||
// 优先处理 401:尝试使用刷新令牌换发
|
|
||||||
if (status === 401 && !config.__retriedAfterRefresh) {
|
|
||||||
try {
|
|
||||||
// 若没有刷新能力或没有刷新令牌,则直接走失败逻辑
|
|
||||||
if (!authHandlers?.refreshAccessToken || !authHandlers?.getRefreshToken || !authHandlers.getRefreshToken()) {
|
|
||||||
// 无法刷新,触发认证失败回调并退出
|
|
||||||
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error('NO_REFRESH_TOKEN')) } catch {}
|
|
||||||
throw new Error('NO_REFRESH_TOKEN')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 合并并发刷新
|
|
||||||
if (!refreshingPromise) {
|
|
||||||
refreshingPromise = authHandlers.refreshAccessToken()
|
|
||||||
.catch((e) => {
|
|
||||||
// 刷新失败,触发失败处理
|
|
||||||
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(e) } catch {}
|
|
||||||
throw e
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
refreshingPromise = null
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
await refreshingPromise
|
|
||||||
// 刷新成功后重试一次原请求
|
|
||||||
config.__retriedAfterRefresh = true
|
|
||||||
// 由请求拦截器负责附加新 Authorization,无需手动改 headers
|
|
||||||
return await axiosInstance.request(config)
|
|
||||||
} catch (refreshErr) {
|
|
||||||
// 刷新失败,触发认证失败并返回原始错误信息
|
|
||||||
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(refreshErr) } catch {}
|
|
||||||
return Promise.reject(new Error(message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 明确的权限问题同样触发登出(例如服务端使用 403 表示 Token 无效或权限已失效)
|
|
||||||
if (status === 403&& resp?.data?.code === 'AUTH_JWT_EXPIRED') {
|
|
||||||
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error(message || 'FORBIDDEN')) } catch {}
|
|
||||||
return Promise.reject(new Error(message || 'FORBIDDEN'))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 仅在后端提示设备不存在时尝试注册并重试,且保证只重试一次
|
|
||||||
if (!skip && !config.__retriedAfterRegistration && typeof backendMessage === 'string' && backendMessage.startsWith('设备不存在')) {
|
|
||||||
// 从 headers / url / body 提取 uuid
|
|
||||||
const uuidFromHeader = getHeaderIgnoreCase(config.headers, 'x-device-uuid')
|
|
||||||
const uuidFromUrl = extractUuidFromUrl(config.url)
|
|
||||||
let uuid = uuidFromHeader || uuidFromUrl || deviceStore.getDeviceUuid()
|
|
||||||
if (!uuid && config.data) {
|
|
||||||
try {
|
try {
|
||||||
const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data
|
const headers = response?.headers || {}
|
||||||
if (body && typeof body === 'object' && body.uuid) uuid = body.uuid
|
const newToken = getHeaderIgnoreCase(headers, 'X-New-Access-Token')
|
||||||
} catch {}
|
if (newToken && authHandlers?.setAccessToken) {
|
||||||
}
|
authHandlers.setAccessToken(newToken)
|
||||||
|
}
|
||||||
// 可能需要账户授权头
|
} catch {
|
||||||
const authHeader = getHeaderIgnoreCase(config.headers, 'Authorization')
|
|
||||||
|
|
||||||
if (uuid) {
|
|
||||||
const ok = await ensureDeviceRegistered(uuid, authHeader)
|
|
||||||
if (ok) {
|
|
||||||
try {
|
|
||||||
config.__retriedAfterRegistration = true
|
|
||||||
// 原样重试
|
|
||||||
const retryResp = await axiosInstance.request(config)
|
|
||||||
return retryResp
|
|
||||||
} catch (retryErr) {
|
|
||||||
const retryMsg = retryErr?.response?.data?.message || retryErr.message || message
|
|
||||||
return Promise.reject(new Error(retryMsg))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
return response.data
|
||||||
}
|
},
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config || {}
|
||||||
|
const resp = error.response
|
||||||
|
const status = resp?.status
|
||||||
|
const skip = config.skipDeviceRegistrationRetry || config.__isRegistrationRequest
|
||||||
|
const backendMessage = resp?.data?.message
|
||||||
|
const message = backendMessage || error.message || 'Unknown error'
|
||||||
|
|
||||||
// 其他错误:附带信息抛出
|
// 优先处理 401:尝试使用刷新令牌换发
|
||||||
return Promise.reject(new Error(message))
|
if (status === 401 && !config.__retriedAfterRefresh) {
|
||||||
}
|
try {
|
||||||
|
// 若没有刷新能力或没有刷新令牌,则直接走失败逻辑
|
||||||
|
if (!authHandlers?.refreshAccessToken || !authHandlers?.getRefreshToken || !authHandlers.getRefreshToken()) {
|
||||||
|
// 无法刷新,触发认证失败回调并退出
|
||||||
|
try {
|
||||||
|
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error('NO_REFRESH_TOKEN'))
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
throw new Error('NO_REFRESH_TOKEN')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 合并并发刷新
|
||||||
|
if (!refreshingPromise) {
|
||||||
|
refreshingPromise = authHandlers.refreshAccessToken()
|
||||||
|
.catch((e) => {
|
||||||
|
// 刷新失败,触发失败处理
|
||||||
|
try {
|
||||||
|
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(e)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshingPromise = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshingPromise
|
||||||
|
// 刷新成功后重试一次原请求
|
||||||
|
config.__retriedAfterRefresh = true
|
||||||
|
// 由请求拦截器负责附加新 Authorization,无需手动改 headers
|
||||||
|
return await axiosInstance.request(config)
|
||||||
|
} catch (refreshErr) {
|
||||||
|
// 刷新失败,触发认证失败并返回原始错误信息
|
||||||
|
try {
|
||||||
|
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(refreshErr)
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 明确的权限问题同样触发登出(例如服务端使用 403 表示 Token 无效或权限已失效)
|
||||||
|
if (status === 403 && resp?.data?.code === 'AUTH_JWT_EXPIRED') {
|
||||||
|
try {
|
||||||
|
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error(message || 'FORBIDDEN'))
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(message || 'FORBIDDEN'))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅在后端提示设备不存在时尝试注册并重试,且保证只重试一次
|
||||||
|
if (!skip && !config.__retriedAfterRegistration && typeof backendMessage === 'string' && backendMessage.startsWith('设备不存在')) {
|
||||||
|
// 从 headers / url / body 提取 uuid
|
||||||
|
const uuidFromHeader = getHeaderIgnoreCase(config.headers, 'x-device-uuid')
|
||||||
|
const uuidFromUrl = extractUuidFromUrl(config.url)
|
||||||
|
let uuid = uuidFromHeader || uuidFromUrl || deviceStore.getDeviceUuid()
|
||||||
|
if (!uuid && config.data) {
|
||||||
|
try {
|
||||||
|
const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data
|
||||||
|
if (body && typeof body === 'object' && body.uuid) uuid = body.uuid
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 可能需要账户授权头
|
||||||
|
const authHeader = getHeaderIgnoreCase(config.headers, 'Authorization')
|
||||||
|
|
||||||
|
if (uuid) {
|
||||||
|
const ok = await ensureDeviceRegistered(uuid, authHeader)
|
||||||
|
if (ok) {
|
||||||
|
try {
|
||||||
|
config.__retriedAfterRegistration = true
|
||||||
|
// 原样重试
|
||||||
|
const retryResp = await axiosInstance.request(config)
|
||||||
|
return retryResp
|
||||||
|
} catch (retryErr) {
|
||||||
|
const retryMsg = retryErr?.response?.data?.message || retryErr.message || message
|
||||||
|
return Promise.reject(new Error(retryMsg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他错误:附带信息抛出
|
||||||
|
return Promise.reject(new Error(message))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
export default axiosInstance
|
export default axiosInstance
|
||||||
|
|||||||
@ -1,253 +1,253 @@
|
|||||||
// 生成 UUID v4
|
// 生成 UUID v4
|
||||||
export function generateUUID() {
|
export function generateUUID() {
|
||||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
||||||
const r = Math.random() * 16 | 0
|
const r = Math.random() * 16 | 0
|
||||||
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
const v = c === 'x' ? r : (r & 0x3 | 0x8)
|
||||||
return v.toString(16)
|
return v.toString(16)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失
|
// 设备 UUID 管理 - 使用多种缓存策略确保UUID不丢失
|
||||||
export const deviceStore = {
|
export const deviceStore = {
|
||||||
// 存储键名
|
// 存储键名
|
||||||
STORAGE_KEY: 'device_uuid',
|
STORAGE_KEY: 'device_uuid',
|
||||||
BACKUP_KEY: 'device_uuid_backup',
|
BACKUP_KEY: 'device_uuid_backup',
|
||||||
SESSION_KEY: 'device_uuid_session',
|
SESSION_KEY: 'device_uuid_session',
|
||||||
HISTORY_KEY: 'device_history', // 本地历史设备记录
|
HISTORY_KEY: 'device_history', // 本地历史设备记录
|
||||||
|
|
||||||
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
||||||
getDeviceUuid() {
|
getDeviceUuid() {
|
||||||
// 1. 首先从 localStorage 获取
|
// 1. 首先从 localStorage 获取
|
||||||
let uuid = localStorage.getItem(this.STORAGE_KEY)
|
let uuid = localStorage.getItem(this.STORAGE_KEY)
|
||||||
|
|
||||||
// 2. 如果没有,尝试从备份位置获取
|
// 2. 如果没有,尝试从备份位置获取
|
||||||
if (!uuid) {
|
if (!uuid) {
|
||||||
uuid = localStorage.getItem(this.BACKUP_KEY)
|
uuid = localStorage.getItem(this.BACKUP_KEY)
|
||||||
if (uuid) {
|
if (uuid) {
|
||||||
// 恢复到主存储位置
|
// 恢复到主存储位置
|
||||||
this.setDeviceUuid(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(在初始化时调用)
|
// 3. 如果还没有,尝试从 sessionStorage 获取
|
||||||
async tryRestoreFromIndexedDB() {
|
if (!uuid) {
|
||||||
const uuid = await this.getFromIndexedDB()
|
uuid = sessionStorage.getItem(this.SESSION_KEY)
|
||||||
if (uuid && !this.getDeviceUuid()) {
|
if (uuid) {
|
||||||
this.setDeviceUuid(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 恢复
|
// 在页面加载时尝试从 IndexedDB 恢复
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
deviceStore.tryRestoreFromIndexedDB()
|
deviceStore.tryRestoreFromIndexedDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 为 deviceStore 扩展历史设备管理功能
|
// 为 deviceStore 扩展历史设备管理功能
|
||||||
// 记录结构:{ uuid: string, name?: string, lastUsedAt: number }
|
// 记录结构:{ uuid: string, name?: string, lastUsedAt: number }
|
||||||
deviceStore.getDeviceHistory = function () {
|
deviceStore.getDeviceHistory = function () {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this.HISTORY_KEY)
|
const raw = localStorage.getItem(this.HISTORY_KEY)
|
||||||
const list = raw ? JSON.parse(raw) : []
|
const list = raw ? JSON.parse(raw) : []
|
||||||
if (!Array.isArray(list)) return []
|
if (!Array.isArray(list)) return []
|
||||||
// 排序:最近使用在前
|
// 排序:最近使用在前
|
||||||
return list.sort((a, b) => (b.lastUsedAt || 0) - (a.lastUsedAt || 0))
|
return list.sort((a, b) => (b.lastUsedAt || 0) - (a.lastUsedAt || 0))
|
||||||
} catch {
|
} catch {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceStore.addDeviceToHistory = function (device) {
|
deviceStore.addDeviceToHistory = function (device) {
|
||||||
try {
|
try {
|
||||||
if (!device || !device.uuid) return
|
if (!device || !device.uuid) return
|
||||||
const maxItems = 20
|
const maxItems = 20
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const list = this.getDeviceHistory()
|
const list = this.getDeviceHistory()
|
||||||
const idx = list.findIndex(d => d.uuid === device.uuid)
|
const idx = list.findIndex(d => d.uuid === device.uuid)
|
||||||
const entry = {
|
const entry = {
|
||||||
uuid: device.uuid,
|
uuid: device.uuid,
|
||||||
name: device.name || device.deviceName || '',
|
name: device.name || device.deviceName || '',
|
||||||
lastUsedAt: now
|
lastUsedAt: now
|
||||||
|
}
|
||||||
|
if (idx >= 0) {
|
||||||
|
// 更新名称和时间
|
||||||
|
list[idx] = {...list[idx], ...entry}
|
||||||
|
} else {
|
||||||
|
list.unshift(entry)
|
||||||
|
}
|
||||||
|
// 去重(按 uuid)并截断
|
||||||
|
const uniqMap = new Map()
|
||||||
|
for (const item of list) {
|
||||||
|
if (!uniqMap.has(item.uuid)) uniqMap.set(item.uuid, item)
|
||||||
|
}
|
||||||
|
const next = Array.from(uniqMap.values()).slice(0, maxItems)
|
||||||
|
localStorage.setItem(this.HISTORY_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
}
|
}
|
||||||
if (idx >= 0) {
|
|
||||||
// 更新名称和时间
|
|
||||||
list[idx] = { ...list[idx], ...entry }
|
|
||||||
} else {
|
|
||||||
list.unshift(entry)
|
|
||||||
}
|
|
||||||
// 去重(按 uuid)并截断
|
|
||||||
const uniqMap = new Map()
|
|
||||||
for (const item of list) {
|
|
||||||
if (!uniqMap.has(item.uuid)) uniqMap.set(item.uuid, item)
|
|
||||||
}
|
|
||||||
const next = Array.from(uniqMap.values()).slice(0, maxItems)
|
|
||||||
localStorage.setItem(this.HISTORY_KEY, JSON.stringify(next))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceStore.removeDeviceFromHistory = function (uuid) {
|
deviceStore.removeDeviceFromHistory = function (uuid) {
|
||||||
try {
|
try {
|
||||||
const list = this.getDeviceHistory().filter(d => d.uuid !== uuid)
|
const list = this.getDeviceHistory().filter(d => d.uuid !== uuid)
|
||||||
localStorage.setItem(this.HISTORY_KEY, JSON.stringify(list))
|
localStorage.setItem(this.HISTORY_KEY, JSON.stringify(list))
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceStore.clearDeviceHistory = function () {
|
deviceStore.clearDeviceHistory = function () {
|
||||||
try {
|
try {
|
||||||
localStorage.removeItem(this.HISTORY_KEY)
|
localStorage.removeItem(this.HISTORY_KEY)
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,66 +1,66 @@
|
|||||||
// 生成随机设备码
|
// 生成随机设备码
|
||||||
export function generateDeviceCode() {
|
export function generateDeviceCode() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
const segments = []
|
const segments = []
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
let segment = ''
|
let segment = ''
|
||||||
for (let j = 0; j < 4; j++) {
|
for (let j = 0; j < 4; j++) {
|
||||||
segment += chars[Math.floor(Math.random() * chars.length)]
|
segment += chars[Math.floor(Math.random() * chars.length)]
|
||||||
|
}
|
||||||
|
segments.push(segment)
|
||||||
}
|
}
|
||||||
segments.push(segment)
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments.join('-')
|
return segments.join('-')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Token 管理
|
// Token 管理
|
||||||
export const tokenStore = {
|
export const tokenStore = {
|
||||||
// 获取所有 token
|
// 获取所有 token
|
||||||
getTokens() {
|
getTokens() {
|
||||||
const tokens = localStorage.getItem('kv_tokens')
|
const tokens = localStorage.getItem('kv_tokens')
|
||||||
return tokens ? JSON.parse(tokens) : []
|
return tokens ? JSON.parse(tokens) : []
|
||||||
},
|
},
|
||||||
|
|
||||||
// 添加 token
|
// 添加 token
|
||||||
addToken(token, appName = '') {
|
addToken(token, appName = '') {
|
||||||
const tokens = this.getTokens()
|
const tokens = this.getTokens()
|
||||||
const newToken = {
|
const newToken = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
token,
|
token,
|
||||||
appName,
|
appName,
|
||||||
deviceCode: generateDeviceCode(),
|
deviceCode: generateDeviceCode(),
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
lastUsed: 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)
|
||||||
}
|
}
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { clsx } from "clsx";
|
import {clsx} from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import {twMerge} from "tailwind-merge"
|
||||||
|
|
||||||
export function cn(...inputs) {
|
export function cn(...inputs) {
|
||||||
return twMerge(clsx(inputs));
|
return twMerge(clsx(inputs));
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/main.js
44
src/main.js
@ -1,9 +1,9 @@
|
|||||||
import { createApp } from 'vue'
|
import {createApp} from 'vue'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import {createRouter, createWebHistory} from 'vue-router'
|
||||||
import { createPinia } from 'pinia'
|
import {createPinia} from 'pinia'
|
||||||
import { routes } from 'vue-router/auto-routes'
|
import {routes} from 'vue-router/auto-routes'
|
||||||
import { tokenStore } from './lib/tokenStore'
|
import {tokenStore} from './lib/tokenStore'
|
||||||
import { deviceStore } from './lib/deviceStore'
|
import {deviceStore} from './lib/deviceStore'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
@ -11,32 +11,32 @@ import App from './App.vue'
|
|||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const urlUuid = urlParams.get('uuid')
|
const urlUuid = urlParams.get('uuid')
|
||||||
if (urlUuid) {
|
if (urlUuid) {
|
||||||
deviceStore.setDeviceUuid(urlUuid)
|
deviceStore.setDeviceUuid(urlUuid)
|
||||||
// 清除 URL 中的 uuid 参数
|
// 清除 URL 中的 uuid 参数
|
||||||
urlParams.delete('uuid')
|
urlParams.delete('uuid')
|
||||||
const newUrl = urlParams.toString()
|
const newUrl = urlParams.toString()
|
||||||
? `${window.location.pathname}?${urlParams.toString()}`
|
? `${window.location.pathname}?${urlParams.toString()}`
|
||||||
: window.location.pathname
|
: window.location.pathname
|
||||||
window.history.replaceState({}, '', newUrl)
|
window.history.replaceState({}, '', newUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Navigation guard for authentication
|
// Navigation guard for authentication
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const requiresAuth = to.meta?.requiresAuth
|
const requiresAuth = to.meta?.requiresAuth
|
||||||
const activeToken = tokenStore.getActiveToken()
|
const activeToken = tokenStore.getActiveToken()
|
||||||
|
|
||||||
if (requiresAuth && !activeToken) {
|
if (requiresAuth && !activeToken) {
|
||||||
next({ path: '/' })
|
next({path: '/'})
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import {onMounted} from 'vue'
|
||||||
import { useRoute, RouterLink } from 'vue-router'
|
import {useRoute, RouterLink} from 'vue-router'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
@ -10,7 +10,7 @@ import {
|
|||||||
CardContent,
|
CardContent,
|
||||||
CardFooter,
|
CardFooter,
|
||||||
} from '@/components/ui/card'
|
} from '@/components/ui/card'
|
||||||
import { AlertCircle, Home, ArrowLeft } from 'lucide-vue-next'
|
import {AlertCircle, Home, ArrowLeft} from 'lucide-vue-next'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@ -26,20 +26,20 @@ onMounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main
|
<main
|
||||||
class="relative min-h-dvh grid place-items-center px-6 py-16 bg-gradient-to-b from-background to-muted/40"
|
class="relative min-h-dvh grid place-items-center px-6 py-16 bg-gradient-to-b from-background to-muted/40"
|
||||||
>
|
>
|
||||||
<!-- 背景点缀 -->
|
<!-- 背景点缀 -->
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="pointer-events-none absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-primary/10 to-transparent blur-2xl"
|
class="pointer-events-none absolute inset-x-0 top-0 h-40 bg-gradient-to-b from-primary/10 to-transparent blur-2xl"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card class="w-full max-w-xl">
|
<Card class="w-full max-w-xl">
|
||||||
<CardHeader class="text-center">
|
<CardHeader class="text-center">
|
||||||
<div
|
<div
|
||||||
class="mx-auto flex size-14 items-center justify-center rounded-full border bg-background/60 shadow-sm"
|
class="mx-auto flex size-14 items-center justify-center rounded-full border bg-background/60 shadow-sm"
|
||||||
>
|
>
|
||||||
<AlertCircle class="size-7 text-primary" />
|
<AlertCircle class="size-7 text-primary"/>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="mt-3 text-2xl">页面未找到</CardTitle>
|
<CardTitle class="mt-3 text-2xl">页面未找到</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@ -57,12 +57,12 @@ onMounted(() => {
|
|||||||
<CardFooter class="flex items-center justify-center gap-3">
|
<CardFooter class="flex items-center justify-center gap-3">
|
||||||
<RouterLink to="/">
|
<RouterLink to="/">
|
||||||
<Button size="lg">
|
<Button size="lg">
|
||||||
<Home class="size-4" />
|
<Home class="size-4"/>
|
||||||
返回首页
|
返回首页
|
||||||
</Button>
|
</Button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<Button variant="outline" size="lg" @click="goBack">
|
<Button size="lg" variant="outline" @click="goBack">
|
||||||
<ArrowLeft class="size-4" />
|
<ArrowLeft class="size-4"/>
|
||||||
返回上一页
|
返回上一页
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
|
|||||||
@ -1,27 +1,27 @@
|
|||||||
<route lang="json">
|
<route lang="json">
|
||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
"requiresAuth": false
|
"requiresAuth": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</route>
|
</route>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import {useRoute, useRouter} from 'vue-router'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import {Badge} from '@/components/ui/badge'
|
||||||
import { CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, Check } from 'lucide-vue-next'
|
import {CheckCircle2, XCircle, Loader2, Shield, Key, AlertCircle, User, Plus, Check} from 'lucide-vue-next'
|
||||||
import AppCard from '@/components/AppCard.vue'
|
import AppCard from '@/components/AppCard.vue'
|
||||||
import LoginDialog from '@/components/LoginDialog.vue'
|
import LoginDialog from '@/components/LoginDialog.vue'
|
||||||
import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
|
import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -269,14 +269,14 @@ onMounted(async () => {
|
|||||||
<CardHeader class="space-y-4">
|
<CardHeader class="space-y-4">
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div class="rounded-full bg-primary/10 p-3">
|
<div class="rounded-full bg-primary/10 p-3">
|
||||||
<Key class="h-8 w-8 text-primary" />
|
<Key class="h-8 w-8 text-primary"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2 text-center">
|
<div class="space-y-2 text-center">
|
||||||
<CardTitle class="text-2xl">应用授权</CardTitle>
|
<CardTitle class="text-2xl">应用授权</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
|
|
||||||
授权应用访问您的 KV 存储
|
授权应用访问您的 KV 存储
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -284,7 +284,7 @@ onMounted(async () => {
|
|||||||
<CardContent class="space-y-6">
|
<CardContent class="space-y-6">
|
||||||
<!-- 应用信息 -->
|
<!-- 应用信息 -->
|
||||||
<div>
|
<div>
|
||||||
<AppCard :app-id="appId" class="mb-4" />
|
<AppCard :app-id="appId" class="mb-4"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 设备信息 -->
|
<!-- 设备信息 -->
|
||||||
@ -295,9 +295,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 当前设备UUID显示 -->
|
<!-- 当前设备UUID显示 -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code class="text-xs font-mono bg-muted px-3 py-2 rounded flex-1 truncate">
|
<code class="text-xs font-mono bg-muted px-3 py-2 rounded flex-1 truncate">
|
||||||
{{ deviceUuid }}
|
{{ deviceUuid }}
|
||||||
</code>
|
</code>
|
||||||
@ -306,17 +305,17 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 设备绑定状态 -->
|
<!-- 设备绑定状态 -->
|
||||||
<div v-if="deviceAccount" class="text-xs text-muted-foreground flex items-center gap-2">
|
<div v-if="deviceAccount" class="text-xs text-muted-foreground flex items-center gap-2">
|
||||||
<User class="h-3 w-3" />
|
<User class="h-3 w-3"/>
|
||||||
已绑定至: {{ deviceAccount.name }}
|
已绑定至: {{ deviceAccount.name }}
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="accountStore.isAuthenticated && !showDeviceList" class="flex items-center gap-2">
|
<div v-else-if="accountStore.isAuthenticated && !showDeviceList" class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@click="bindCurrentDevice"
|
class="text-xs"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="text-xs"
|
@click="bindCurrentDevice"
|
||||||
>
|
>
|
||||||
<Plus class="h-3 w-3 mr-1" />
|
<Plus class="h-3 w-3 mr-1"/>
|
||||||
绑定到我的账户
|
绑定到我的账户
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -335,10 +334,10 @@ onMounted(async () => {
|
|||||||
<div v-if="isDeviceCodeMode && !deviceCode" class="space-y-2">
|
<div v-if="isDeviceCodeMode && !deviceCode" class="space-y-2">
|
||||||
<Label for="device-code">设备代码</Label>
|
<Label for="device-code">设备代码</Label>
|
||||||
<Input
|
<Input
|
||||||
id="device-code"
|
id="device-code"
|
||||||
v-model="inputDeviceCode"
|
v-model="inputDeviceCode"
|
||||||
placeholder="例如:1234-ABCD"
|
class="font-mono"
|
||||||
class="font-mono"
|
placeholder="例如:1234-ABCD"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -356,9 +355,9 @@ onMounted(async () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="note">备注(可选)</Label>
|
<Label for="note">备注(可选)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="note"
|
id="note"
|
||||||
v-model="authNote"
|
v-model="authNote"
|
||||||
placeholder="例如:CLI 工具访问"
|
placeholder="例如:CLI 工具访问"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -368,17 +367,17 @@ onMounted(async () => {
|
|||||||
<!-- 授权按钮 -->
|
<!-- 授权按钮 -->
|
||||||
<div class="space-y-3 pt-2">
|
<div class="space-y-3 pt-2">
|
||||||
<Button
|
<Button
|
||||||
@click="handleSubmit"
|
:disabled="(isDeviceCodeMode && !currentDeviceCode)"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
:disabled="(isDeviceCodeMode && !currentDeviceCode)"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
<Key class="mr-2 h-4 w-4" />
|
<Key class="mr-2 h-4 w-4"/>
|
||||||
确认授权
|
确认授权
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- 返回首页 -->
|
<!-- 返回首页 -->
|
||||||
<Button @click="goHome" variant="ghost" class="w-full">
|
<Button class="w-full" variant="ghost" @click="goHome">
|
||||||
返回管理页面
|
返回管理页面
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -387,7 +386,7 @@ onMounted(async () => {
|
|||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-else-if="step === 'loading'" class="py-8">
|
<div v-else-if="step === 'loading'" class="py-8">
|
||||||
<div class="flex flex-col items-center justify-center space-y-4">
|
<div class="flex flex-col items-center justify-center space-y-4">
|
||||||
<Loader2 class="h-12 w-12 animate-spin text-primary" />
|
<Loader2 class="h-12 w-12 animate-spin text-primary"/>
|
||||||
<div class="text-center space-y-1">
|
<div class="text-center space-y-1">
|
||||||
<div class="font-medium">正在授权...</div>
|
<div class="font-medium">正在授权...</div>
|
||||||
<div class="text-sm text-muted-foreground">请稍候</div>
|
<div class="text-sm text-muted-foreground">请稍候</div>
|
||||||
@ -399,7 +398,7 @@ onMounted(async () => {
|
|||||||
<div v-else-if="step === 'success'" class="space-y-4">
|
<div v-else-if="step === 'success'" class="space-y-4">
|
||||||
<div class="flex flex-col items-center justify-center py-8 space-y-4">
|
<div class="flex flex-col items-center justify-center py-8 space-y-4">
|
||||||
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
|
<div class="rounded-full bg-green-100 dark:bg-green-900/20 p-4">
|
||||||
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-500" />
|
<CheckCircle2 class="h-12 w-12 text-green-600 dark:text-green-500"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2">
|
<div class="text-center space-y-2">
|
||||||
<div class="text-lg font-semibold">授权成功!</div>
|
<div class="text-lg font-semibold">授权成功!</div>
|
||||||
@ -414,7 +413,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button @click="goHome" class="w-full" size="lg">
|
<Button class="w-full" size="lg" @click="goHome">
|
||||||
返回管理页面
|
返回管理页面
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -423,7 +422,7 @@ onMounted(async () => {
|
|||||||
<div v-else-if="step === 'error'" class="space-y-4">
|
<div v-else-if="step === 'error'" class="space-y-4">
|
||||||
<div class="flex flex-col items-center justify-center py-8 space-y-4">
|
<div class="flex flex-col items-center justify-center py-8 space-y-4">
|
||||||
<div class="rounded-full bg-red-100 dark:bg-red-900/20 p-4">
|
<div class="rounded-full bg-red-100 dark:bg-red-900/20 p-4">
|
||||||
<XCircle class="h-12 w-12 text-red-600 dark:text-red-500" />
|
<XCircle class="h-12 w-12 text-red-600 dark:text-red-500"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2">
|
<div class="text-center space-y-2">
|
||||||
<div class="text-lg font-semibold">授权失败</div>
|
<div class="text-lg font-semibold">授权失败</div>
|
||||||
@ -432,19 +431,20 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Button @click="retry" class="w-full" size="lg">
|
<Button class="w-full" size="lg" @click="retry">
|
||||||
重试
|
重试
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="goHome" variant="ghost" class="w-full">
|
<Button class="w-full" variant="ghost" @click="goHome">
|
||||||
返回管理页面
|
返回管理页面
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提示信息 -->
|
<!-- 提示信息 -->
|
||||||
<div v-if="step === 'input'" class="rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 p-4">
|
<div v-if="step === 'input'"
|
||||||
|
class="rounded-lg bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 p-4">
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<AlertCircle class="h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
|
<AlertCircle class="h-5 w-5 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5"/>
|
||||||
<div class="space-y-1.5 text-sm">
|
<div class="space-y-1.5 text-sm">
|
||||||
<div class="font-medium text-blue-900 dark:text-blue-100">授权说明</div>
|
<div class="font-medium text-blue-900 dark:text-blue-100">授权说明</div>
|
||||||
<div class="text-blue-700 dark:text-blue-300 leading-relaxed">
|
<div class="text-blue-700 dark:text-blue-300 leading-relaxed">
|
||||||
@ -462,13 +462,13 @@ onMounted(async () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- 登录弹框 -->
|
<!-- 登录弹框 -->
|
||||||
<LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess" />
|
<LoginDialog v-model="showLoginDialog" :on-success="handleLoginSuccess"/>
|
||||||
|
|
||||||
<!-- 设备注册弹框 -->
|
<!-- 设备注册弹框 -->
|
||||||
<DeviceRegisterDialog
|
<DeviceRegisterDialog
|
||||||
v-model="showRegisterDialog"
|
v-model="showRegisterDialog"
|
||||||
@confirm="updateUuid"
|
:required="deviceRequired"
|
||||||
:required="deviceRequired"
|
@confirm="updateUuid"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import {Badge} from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -33,7 +33,7 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
Copy,
|
Copy,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
import AutoAuthConfigDialog from '@/components/AutoAuthConfigDialog.vue'
|
import AutoAuthConfigDialog from '@/components/AutoAuthConfigDialog.vue'
|
||||||
import EditNamespaceDialog from '@/components/EditNamespaceDialog.vue'
|
import EditNamespaceDialog from '@/components/EditNamespaceDialog.vue'
|
||||||
import LoginDialog from '@/components/LoginDialog.vue'
|
import LoginDialog from '@/components/LoginDialog.vue'
|
||||||
@ -167,9 +167,9 @@ const deleteConfig = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.deleteAutoAuthConfig(
|
await apiClient.deleteAutoAuthConfig(
|
||||||
deviceUuid.value,
|
deviceUuid.value,
|
||||||
accountStore.token,
|
accountStore.token,
|
||||||
currentConfig.value.id
|
currentConfig.value.id
|
||||||
)
|
)
|
||||||
toast.success('配置已删除')
|
toast.success('配置已删除')
|
||||||
showDeleteDialog.value = false
|
showDeleteDialog.value = false
|
||||||
@ -242,15 +242,15 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="h-5 w-5" />
|
<ArrowLeft class="h-5 w-5"/>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||||
<Shield class="h-6 w-6" />
|
<Shield class="h-6 w-6"/>
|
||||||
自动授权配置
|
自动授权配置
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted-foreground">管理设备的自动授权规则</p>
|
<p class="text-sm text-muted-foreground">管理设备的自动授权规则</p>
|
||||||
@ -258,13 +258,13 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-if="isAuthenticated"
|
v-if="isAuthenticated"
|
||||||
variant="outline"
|
:disabled="isLoading"
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="loadConfigs"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
@click="loadConfigs"
|
||||||
>
|
>
|
||||||
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -276,14 +276,14 @@ onMounted(async () => {
|
|||||||
<!-- 未登录状态提示 -->
|
<!-- 未登录状态提示 -->
|
||||||
<Card v-if="!isAuthenticated" class="border-yellow-200 dark:border-yellow-800">
|
<Card v-if="!isAuthenticated" class="border-yellow-200 dark:border-yellow-800">
|
||||||
<CardContent class="flex flex-col items-center justify-center py-12">
|
<CardContent class="flex flex-col items-center justify-center py-12">
|
||||||
<AlertCircle class="h-16 w-16 text-yellow-600 dark:text-yellow-400 mb-4" />
|
<AlertCircle class="h-16 w-16 text-yellow-600 dark:text-yellow-400 mb-4"/>
|
||||||
<p class="text-lg font-medium mb-2">需要账户登录</p>
|
<p class="text-lg font-medium mb-2">需要账户登录</p>
|
||||||
<p class="text-sm text-muted-foreground mb-4 text-center max-w-md">
|
<p class="text-sm text-muted-foreground mb-4 text-center max-w-md">
|
||||||
管理自动授权配置需要登录账户,并且设备必须绑定到您的账户
|
管理自动授权配置需要登录账户,并且设备必须绑定到您的账户
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button @click="showLoginDialog = true">
|
<Button @click="showLoginDialog = true">
|
||||||
<User class="h-4 w-4 mr-2" />
|
<User class="h-4 w-4 mr-2"/>
|
||||||
登录账户
|
登录账户
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" @click="goBack">
|
<Button variant="outline" @click="goBack">
|
||||||
@ -298,7 +298,7 @@ onMounted(async () => {
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-lg">当前设备</CardTitle>
|
<CardTitle class="text-lg">当前设备</CardTitle>
|
||||||
<CardDescription class="flex items-center gap-2 mt-2">
|
<CardDescription class="flex items-center gap-2 mt-2">
|
||||||
<User class="h-3 w-3" />
|
<User class="h-3 w-3"/>
|
||||||
已绑定到账户:{{ accountStore.userName }}
|
已绑定到账户:{{ accountStore.userName }}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -313,13 +313,13 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code class="text-xs bg-muted px-2 py-1 rounded">{{ deviceInfo.namespace }}</code>
|
<code class="text-xs bg-muted px-2 py-1 rounded">{{ deviceInfo.namespace }}</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-6 w-6"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-6 w-6"
|
title="编辑命名空间"
|
||||||
@click="editNamespace"
|
variant="ghost"
|
||||||
title="编辑命名空间"
|
@click="editNamespace"
|
||||||
>
|
>
|
||||||
<Edit class="h-3 w-3" />
|
<Edit class="h-3 w-3"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -341,25 +341,25 @@ onMounted(async () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button @click="createConfig">
|
<Button @click="createConfig">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
添加配置
|
添加配置
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="isLoading" class="text-center py-12">
|
<div v-if="isLoading" class="text-center py-12">
|
||||||
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground"/>
|
||||||
<p class="mt-4 text-muted-foreground">加载中...</p>
|
<p class="mt-4 text-muted-foreground">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<Card v-else-if="configs.length === 0" class="border-dashed">
|
<Card v-else-if="configs.length === 0" class="border-dashed">
|
||||||
<CardContent class="flex flex-col items-center justify-center py-12">
|
<CardContent class="flex flex-col items-center justify-center py-12">
|
||||||
<Shield class="h-16 w-16 text-muted-foreground/50 mb-4" />
|
<Shield class="h-16 w-16 text-muted-foreground/50 mb-4"/>
|
||||||
<p class="text-lg font-medium text-muted-foreground mb-2">暂无自动授权配置</p>
|
<p class="text-lg font-medium text-muted-foreground mb-2">暂无自动授权配置</p>
|
||||||
<p class="text-sm text-muted-foreground mb-4">创建配置以允许设备自动授权访问</p>
|
<p class="text-sm text-muted-foreground mb-4">创建配置以允许设备自动授权访问</p>
|
||||||
<Button @click="createConfig" variant="outline">
|
<Button variant="outline" @click="createConfig">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
创建第一个配置
|
创建第一个配置
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -368,16 +368,16 @@ onMounted(async () => {
|
|||||||
<!-- 配置列表 -->
|
<!-- 配置列表 -->
|
||||||
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="config in configs"
|
v-for="config in configs"
|
||||||
:key="config.id"
|
:key="config.id"
|
||||||
class="hover:shadow-lg transition-shadow"
|
class="hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between mb-2">
|
<div class="flex items-start justify-between mb-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<component
|
<component
|
||||||
:is="getDeviceTypeIcon(config.deviceType)"
|
:is="getDeviceTypeIcon(config.deviceType)"
|
||||||
class="h-5 w-5 text-primary"
|
class="h-5 w-5 text-primary"
|
||||||
/>
|
/>
|
||||||
<CardTitle class="text-lg">
|
<CardTitle class="text-lg">
|
||||||
{{ getDeviceTypeLabel(config.deviceType) }}
|
{{ getDeviceTypeLabel(config.deviceType) }}
|
||||||
@ -390,8 +390,8 @@ onMounted(async () => {
|
|||||||
<CardDescription>
|
<CardDescription>
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<div class="flex items-center gap-2 text-xs">
|
||||||
<component
|
<component
|
||||||
:is="config.password || config.isLegacyHash ? Lock : LockOpen"
|
:is="config.password || config.isLegacyHash ? Lock : LockOpen"
|
||||||
class="h-3 w-3"
|
class="h-3 w-3"
|
||||||
/>
|
/>
|
||||||
{{ config.password ? '需要密码' : config.isLegacyHash ? '需要密码(旧格式)' : '无密码' }}
|
{{ config.password ? '需要密码' : config.isLegacyHash ? '需要密码(旧格式)' : '无密码' }}
|
||||||
</div>
|
</div>
|
||||||
@ -402,7 +402,7 @@ onMounted(async () => {
|
|||||||
<div v-if="config.password || config.isLegacyHash" class="rounded-lg border bg-muted/50 p-3 space-y-2">
|
<div v-if="config.password || config.isLegacyHash" class="rounded-lg border bg-muted/50 p-3 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs font-medium text-muted-foreground">授权密码</span>
|
<span class="text-xs font-medium text-muted-foreground">授权密码</span>
|
||||||
<Badge v-if="config.isLegacyHash" variant="secondary" class="text-xs">
|
<Badge v-if="config.isLegacyHash" class="text-xs" variant="secondary">
|
||||||
旧格式
|
旧格式
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@ -411,12 +411,12 @@ onMounted(async () => {
|
|||||||
{{ config.password }}
|
{{ config.password }}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-7 w-7"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-7 w-7"
|
variant="ghost"
|
||||||
@click="copyPassword(config.password)"
|
@click="copyPassword(config.password)"
|
||||||
>
|
>
|
||||||
<Copy class="h-3 w-3" />
|
<Copy class="h-3 w-3"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p v-else class="text-xs text-muted-foreground">
|
<p v-else class="text-xs text-muted-foreground">
|
||||||
@ -433,21 +433,21 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
class="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="editConfig(config)"
|
variant="outline"
|
||||||
class="flex-1"
|
@click="editConfig(config)"
|
||||||
>
|
>
|
||||||
<Edit class="h-3 w-3 mr-1" />
|
<Edit class="h-3 w-3 mr-1"/>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
class="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="confirmDelete(config)"
|
variant="destructive"
|
||||||
class="flex-1"
|
@click="confirmDelete(config)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3 w-3 mr-1" />
|
<Trash2 class="h-3 w-3 mr-1"/>
|
||||||
删除
|
删除
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -459,28 +459,28 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- 登录对话框 -->
|
<!-- 登录对话框 -->
|
||||||
<LoginDialog
|
<LoginDialog
|
||||||
v-model="showLoginDialog"
|
v-model="showLoginDialog"
|
||||||
:on-success="handleLoginSuccess"
|
:on-success="handleLoginSuccess"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 配置编辑对话框 -->
|
<!-- 配置编辑对话框 -->
|
||||||
<AutoAuthConfigDialog
|
<AutoAuthConfigDialog
|
||||||
v-if="isAuthenticated"
|
v-if="isAuthenticated"
|
||||||
v-model="showConfigDialog"
|
v-model="showConfigDialog"
|
||||||
:device-uuid="deviceUuid"
|
:account-token="accountStore.token"
|
||||||
:account-token="accountStore.token"
|
:config="editingConfig"
|
||||||
:config="editingConfig"
|
:device-uuid="deviceUuid"
|
||||||
@success="handleConfigSaved"
|
@success="handleConfigSaved"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 编辑命名空间对话框 -->
|
<!-- 编辑命名空间对话框 -->
|
||||||
<EditNamespaceDialog
|
<EditNamespaceDialog
|
||||||
v-if="isAuthenticated && deviceInfo"
|
v-if="isAuthenticated && deviceInfo"
|
||||||
v-model="showNamespaceDialog"
|
v-model="showNamespaceDialog"
|
||||||
:device-uuid="deviceUuid"
|
:account-token="accountStore.token"
|
||||||
:current-namespace="deviceInfo.namespace"
|
:current-namespace="deviceInfo.namespace"
|
||||||
:account-token="accountStore.token"
|
:device-uuid="deviceUuid"
|
||||||
@success="handleNamespaceUpdated"
|
@success="handleNamespaceUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 删除确认对话框 -->
|
<!-- 删除确认对话框 -->
|
||||||
@ -495,7 +495,8 @@ onMounted(async () => {
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction @click="deleteConfig" class="bg-destructive text-destructive-foreground hover:bg-destructive/90">
|
<AlertDialogAction class="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
@click="deleteConfig">
|
||||||
确认删除
|
确认删除
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import {ref, computed} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import {Badge} from '@/components/ui/badge'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||||
import {
|
import {
|
||||||
TestTube2,
|
TestTube2,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -21,7 +21,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@ -65,9 +65,9 @@ const testGetToken = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.getTokenByNamespace(
|
const response = await apiClient.getTokenByNamespace(
|
||||||
tab1Form.value.namespace,
|
tab1Form.value.namespace,
|
||||||
tab1Form.value.password || undefined,
|
tab1Form.value.password || undefined,
|
||||||
tab1Form.value.appId
|
tab1Form.value.appId
|
||||||
)
|
)
|
||||||
|
|
||||||
tab1Result.value = {
|
tab1Result.value = {
|
||||||
@ -108,8 +108,8 @@ const testSetStudentName = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.setStudentName(
|
const response = await apiClient.setStudentName(
|
||||||
tab2Form.value.token,
|
tab2Form.value.token,
|
||||||
tab2Form.value.name
|
tab2Form.value.name
|
||||||
)
|
)
|
||||||
|
|
||||||
tab2Result.value = {
|
tab2Result.value = {
|
||||||
@ -135,7 +135,7 @@ const testKVOperation = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { operation, key, value } = tab3Form.value
|
const {operation, key, value} = tab3Form.value
|
||||||
|
|
||||||
if (operation !== 'list' && !key) {
|
if (operation !== 'list' && !key) {
|
||||||
toast.error('请输入 key')
|
toast.error('请输入 key')
|
||||||
@ -213,15 +213,15 @@ const goBack = () => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="h-5 w-5" />
|
<ArrowLeft class="h-5 w-5"/>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold flex items-center gap-2">
|
<h1 class="text-2xl font-bold flex items-center gap-2">
|
||||||
<TestTube2 class="h-6 w-6" />
|
<TestTube2 class="h-6 w-6"/>
|
||||||
AutoAuth API 测试
|
AutoAuth API 测试
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted-foreground">测试自动授权和相关 API 功能</p>
|
<p class="text-sm text-muted-foreground">测试自动授权和相关 API 功能</p>
|
||||||
@ -233,24 +233,24 @@ const goBack = () => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
||||||
<Tabs default-value="token" class="w-full">
|
<Tabs class="w-full" default-value="token">
|
||||||
<TabsList class="grid w-full grid-cols-3">
|
<TabsList class="grid w-full grid-cols-3">
|
||||||
<TabsTrigger value="token">
|
<TabsTrigger value="token">
|
||||||
<Key class="h-4 w-4 mr-2" />
|
<Key class="h-4 w-4 mr-2"/>
|
||||||
获取 Token
|
获取 Token
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="student">
|
<TabsTrigger value="student">
|
||||||
<User class="h-4 w-4 mr-2" />
|
<User class="h-4 w-4 mr-2"/>
|
||||||
学生名称
|
学生名称
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="kv">
|
<TabsTrigger value="kv">
|
||||||
<Database class="h-4 w-4 mr-2" />
|
<Database class="h-4 w-4 mr-2"/>
|
||||||
KV 操作
|
KV 操作
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Tab 1: 获取 Token -->
|
<!-- Tab 1: 获取 Token -->
|
||||||
<TabsContent value="token" class="space-y-4">
|
<TabsContent class="space-y-4" value="token">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>通过 Namespace 获取 Token</CardTitle>
|
<CardTitle>通过 Namespace 获取 Token</CardTitle>
|
||||||
@ -262,9 +262,9 @@ const goBack = () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="namespace">Namespace *</Label>
|
<Label for="namespace">Namespace *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="namespace"
|
id="namespace"
|
||||||
v-model="tab1Form.namespace"
|
v-model="tab1Form.namespace"
|
||||||
placeholder="例如: class-2024-1"
|
placeholder="例如: class-2024-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -272,21 +272,21 @@ const goBack = () => {
|
|||||||
<Label for="password">Password</Label>
|
<Label for="password">Password</Label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
:type="tab1ShowPassword ? 'text' : 'password'"
|
v-model="tab1Form.password"
|
||||||
v-model="tab1Form.password"
|
:type="tab1ShowPassword ? 'text' : 'password'"
|
||||||
placeholder="留空表示无密码授权"
|
placeholder="留空表示无密码授权"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
class="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
tabindex="-1"
|
||||||
class="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
type="button"
|
||||||
@click="tab1ShowPassword = !tab1ShowPassword"
|
variant="ghost"
|
||||||
tabindex="-1"
|
@click="tab1ShowPassword = !tab1ShowPassword"
|
||||||
>
|
>
|
||||||
<Eye v-if="!tab1ShowPassword" class="h-4 w-4 text-muted-foreground" />
|
<Eye v-if="!tab1ShowPassword" class="h-4 w-4 text-muted-foreground"/>
|
||||||
<EyeOff v-else class="h-4 w-4 text-muted-foreground" />
|
<EyeOff v-else class="h-4 w-4 text-muted-foreground"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -294,19 +294,19 @@ const goBack = () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="appId">App ID</Label>
|
<Label for="appId">App ID</Label>
|
||||||
<Input
|
<Input
|
||||||
id="appId"
|
id="appId"
|
||||||
v-model="tab1Form.appId"
|
v-model="tab1Form.appId"
|
||||||
placeholder="应用标识符"
|
placeholder="应用标识符"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@click="testGetToken"
|
:disabled="tab1Loading"
|
||||||
:disabled="tab1Loading"
|
class="w-full"
|
||||||
class="w-full"
|
@click="testGetToken"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="tab1Loading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="tab1Loading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
<Play v-else class="mr-2 h-4 w-4" />
|
<Play v-else class="mr-2 h-4 w-4"/>
|
||||||
执行测试
|
执行测试
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -315,8 +315,8 @@ const goBack = () => {
|
|||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<Badge :variant="tab1Result.success ? 'default' : 'destructive'">
|
<Badge :variant="tab1Result.success ? 'default' : 'destructive'">
|
||||||
<component
|
<component
|
||||||
:is="tab1Result.success ? CheckCircle2 : XCircle"
|
:is="tab1Result.success ? CheckCircle2 : XCircle"
|
||||||
class="h-3 w-3 mr-1"
|
class="h-3 w-3 mr-1"
|
||||||
/>
|
/>
|
||||||
{{ tab1Result.success ? '成功' : '失败' }}
|
{{ tab1Result.success ? '成功' : '失败' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -330,7 +330,7 @@ const goBack = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Tab 2: 设置学生名称 -->
|
<!-- Tab 2: 设置学生名称 -->
|
||||||
<TabsContent value="student" class="space-y-4">
|
<TabsContent class="space-y-4" value="student">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>设置学生名称</CardTitle>
|
<CardTitle>设置学生名称</CardTitle>
|
||||||
@ -342,18 +342,18 @@ const goBack = () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="token2">Token *</Label>
|
<Label for="token2">Token *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="token2"
|
id="token2"
|
||||||
v-model="tab2Form.token"
|
v-model="tab2Form.token"
|
||||||
placeholder="从上一步获取的 token"
|
placeholder="从上一步获取的 token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="studentName">学生姓名 *</Label>
|
<Label for="studentName">学生姓名 *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="studentName"
|
id="studentName"
|
||||||
v-model="tab2Form.name"
|
v-model="tab2Form.name"
|
||||||
placeholder="例如: 张三"
|
placeholder="例如: 张三"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
名称必须在设备的学生列表中(存储在 classworks-list-main 键中)
|
名称必须在设备的学生列表中(存储在 classworks-list-main 键中)
|
||||||
@ -361,12 +361,12 @@ const goBack = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@click="testSetStudentName"
|
:disabled="tab2Loading"
|
||||||
:disabled="tab2Loading"
|
class="w-full"
|
||||||
class="w-full"
|
@click="testSetStudentName"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="tab2Loading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="tab2Loading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
<Play v-else class="mr-2 h-4 w-4" />
|
<Play v-else class="mr-2 h-4 w-4"/>
|
||||||
执行测试
|
执行测试
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -375,8 +375,8 @@ const goBack = () => {
|
|||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<Badge :variant="tab2Result.success ? 'default' : 'destructive'">
|
<Badge :variant="tab2Result.success ? 'default' : 'destructive'">
|
||||||
<component
|
<component
|
||||||
:is="tab2Result.success ? CheckCircle2 : XCircle"
|
:is="tab2Result.success ? CheckCircle2 : XCircle"
|
||||||
class="h-3 w-3 mr-1"
|
class="h-3 w-3 mr-1"
|
||||||
/>
|
/>
|
||||||
{{ tab2Result.success ? '成功' : '失败' }}
|
{{ tab2Result.success ? '成功' : '失败' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -390,7 +390,7 @@ const goBack = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Tab 3: KV 操作 -->
|
<!-- Tab 3: KV 操作 -->
|
||||||
<TabsContent value="kv" class="space-y-4">
|
<TabsContent class="space-y-4" value="kv">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>KV 存储操作测试</CardTitle>
|
<CardTitle>KV 存储操作测试</CardTitle>
|
||||||
@ -402,18 +402,18 @@ const goBack = () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="token3">Token *</Label>
|
<Label for="token3">Token *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="token3"
|
id="token3"
|
||||||
v-model="tab3Form.token"
|
v-model="tab3Form.token"
|
||||||
placeholder="从第一步获取的 token"
|
placeholder="从第一步获取的 token"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="operation">操作类型</Label>
|
<Label for="operation">操作类型</Label>
|
||||||
<select
|
<select
|
||||||
id="operation"
|
id="operation"
|
||||||
v-model="tab3Form.operation"
|
v-model="tab3Form.operation"
|
||||||
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<option value="list">列出所有键值 (LIST)</option>
|
<option value="list">列出所有键值 (LIST)</option>
|
||||||
<option value="get">读取值 (GET)</option>
|
<option value="get">读取值 (GET)</option>
|
||||||
@ -425,29 +425,29 @@ const goBack = () => {
|
|||||||
<div v-if="tab3Form.operation !== 'list'" class="space-y-2">
|
<div v-if="tab3Form.operation !== 'list'" class="space-y-2">
|
||||||
<Label for="key">Key *</Label>
|
<Label for="key">Key *</Label>
|
||||||
<Input
|
<Input
|
||||||
id="key"
|
id="key"
|
||||||
v-model="tab3Form.key"
|
v-model="tab3Form.key"
|
||||||
placeholder="例如: test-key"
|
placeholder="例如: test-key"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tab3Form.operation === 'set'" class="space-y-2">
|
<div v-if="tab3Form.operation === 'set'" class="space-y-2">
|
||||||
<Label for="value">Value (JSON) *</Label>
|
<Label for="value">Value (JSON) *</Label>
|
||||||
<textarea
|
<textarea
|
||||||
id="value"
|
id="value"
|
||||||
v-model="tab3Form.value"
|
v-model="tab3Form.value"
|
||||||
placeholder='例如: {"message": "Hello World"}'
|
class="flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
class="flex min-h-[100px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
placeholder='例如: {"message": "Hello World"}'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@click="testKVOperation"
|
:disabled="tab3Loading"
|
||||||
:disabled="tab3Loading"
|
class="w-full"
|
||||||
class="w-full"
|
@click="testKVOperation"
|
||||||
>
|
>
|
||||||
<Loader2 v-if="tab3Loading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="tab3Loading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
<Play v-else class="mr-2 h-4 w-4" />
|
<Play v-else class="mr-2 h-4 w-4"/>
|
||||||
执行测试
|
执行测试
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@ -456,8 +456,8 @@ const goBack = () => {
|
|||||||
<div class="flex items-center gap-2 mb-2">
|
<div class="flex items-center gap-2 mb-2">
|
||||||
<Badge :variant="tab3Result.success ? 'default' : 'destructive'">
|
<Badge :variant="tab3Result.success ? 'default' : 'destructive'">
|
||||||
<component
|
<component
|
||||||
:is="tab3Result.success ? CheckCircle2 : XCircle"
|
:is="tab3Result.success ? CheckCircle2 : XCircle"
|
||||||
class="h-3 w-3 mr-1"
|
class="h-3 w-3 mr-1"
|
||||||
/>
|
/>
|
||||||
{{ tab3Result.success ? '成功' : '失败' }}
|
{{ tab3Result.success ? '成功' : '失败' }}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import {Badge} from '@/components/ui/badge'
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@ -25,7 +25,7 @@ import {
|
|||||||
User,
|
User,
|
||||||
ArrowLeft
|
ArrowLeft
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
|
import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -105,11 +105,11 @@ onMounted(() => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
size="icon"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
@click="router.push('/')"
|
@click="router.push('/')"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="h-5 w-5" />
|
<ArrowLeft class="h-5 w-5"/>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">设备管理</h1>
|
<h1 class="text-2xl font-bold">设备管理</h1>
|
||||||
@ -117,17 +117,17 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Badge variant="secondary" class="px-3 py-1">
|
<Badge class="px-3 py-1" variant="secondary">
|
||||||
<User class="h-3 w-3 mr-1.5" />
|
<User class="h-3 w-3 mr-1.5"/>
|
||||||
{{ accountStore.userName }}
|
{{ accountStore.userName }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
:disabled="isLoading"
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="loadDevices"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
@click="loadDevices"
|
||||||
>
|
>
|
||||||
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -138,17 +138,17 @@ onMounted(() => {
|
|||||||
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div v-if="isLoading" class="text-center py-12">
|
<div v-if="isLoading" class="text-center py-12">
|
||||||
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground"/>
|
||||||
<p class="mt-4 text-muted-foreground">加载中...</p>
|
<p class="mt-4 text-muted-foreground">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<Card v-else-if="devices.length === 0" class="border-dashed">
|
<Card v-else-if="devices.length === 0" class="border-dashed">
|
||||||
<CardContent class="flex flex-col items-center justify-center py-12">
|
<CardContent class="flex flex-col items-center justify-center py-12">
|
||||||
<Smartphone class="h-16 w-16 text-muted-foreground/50 mb-4" />
|
<Smartphone class="h-16 w-16 text-muted-foreground/50 mb-4"/>
|
||||||
<p class="text-lg font-medium text-muted-foreground mb-2">暂无绑定设备</p>
|
<p class="text-lg font-medium text-muted-foreground mb-2">暂无绑定设备</p>
|
||||||
<p class="text-sm text-muted-foreground mb-4">您可以在主页面注册并绑定新设备</p>
|
<p class="text-sm text-muted-foreground mb-4">您可以在主页面注册并绑定新设备</p>
|
||||||
<Button @click="router.push('/')" variant="outline">
|
<Button variant="outline" @click="router.push('/')">
|
||||||
返回主页
|
返回主页
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -164,9 +164,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
v-for="device in devices"
|
v-for="device in devices"
|
||||||
:key="device.uuid"
|
:key="device.uuid"
|
||||||
class="hover:shadow-lg transition-shadow"
|
class="hover:shadow-lg transition-shadow"
|
||||||
>
|
>
|
||||||
<CardHeader class="pb-3">
|
<CardHeader class="pb-3">
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
@ -178,7 +178,7 @@ onMounted(() => {
|
|||||||
<code class="text-xs">{{ device.uuid }}</code>
|
<code class="text-xs">{{ device.uuid }}</code>
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
<Smartphone class="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
<Smartphone class="h-5 w-5 text-muted-foreground flex-shrink-0"/>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-3">
|
<CardContent class="space-y-3">
|
||||||
@ -188,24 +188,24 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
class="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="editDeviceName(device)"
|
variant="outline"
|
||||||
class="flex-1"
|
@click="editDeviceName(device)"
|
||||||
>
|
>
|
||||||
<Edit class="h-3 w-3 mr-1" />
|
<Edit class="h-3 w-3 mr-1"/>
|
||||||
重命名
|
重命名
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
class="w-full"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="confirmUnbind(device)"
|
variant="destructive"
|
||||||
class="w-full"
|
@click="confirmUnbind(device)"
|
||||||
>
|
>
|
||||||
<Trash2 class="h-3 w-3 mr-1" />
|
<Trash2 class="h-3 w-3 mr-1"/>
|
||||||
解绑设备
|
解绑设备
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -216,16 +216,15 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 编辑设备名称弹框 -->
|
<!-- 编辑设备名称弹框 -->
|
||||||
<EditDeviceNameDialog
|
<EditDeviceNameDialog
|
||||||
v-if="currentDevice"
|
v-if="currentDevice"
|
||||||
v-model="showEditNameDialog"
|
v-model="showEditNameDialog"
|
||||||
:device-uuid="currentDevice.uuid"
|
:current-name="currentDevice.name || ''"
|
||||||
:current-name="currentDevice.name || ''"
|
:device-uuid="currentDevice.uuid"
|
||||||
:has-password="false"
|
:has-password="false"
|
||||||
@success="handleDeviceNameUpdated"
|
@success="handleDeviceNameUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 解绑确认对话框 -->
|
<!-- 解绑确认对话框 -->
|
||||||
<AlertDialog v-model:open="showDeleteDialog">
|
<AlertDialog v-model:open="showDeleteDialog">
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
|
|||||||
@ -1,16 +1,36 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { useAccountStore } from '@/stores/account'
|
import {useAccountStore} from '@/stores/account'
|
||||||
import { useOAuthCallback } from '@/composables/useOAuthCallback'
|
import {useOAuthCallback} from '@/composables/useOAuthCallback'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import {Badge} from '@/components/ui/badge'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||||
import { Plus, Trash2, Key, Shield, RefreshCw, Copy, CheckCircle2, Settings, Package, Clock, AlertCircle, Lock, Info, User, LogOut, Layers, ChevronDown, TestTube2, Edit } from 'lucide-vue-next'
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Key,
|
||||||
|
Shield,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
CheckCircle2,
|
||||||
|
Settings,
|
||||||
|
Package,
|
||||||
|
Clock,
|
||||||
|
AlertCircle,
|
||||||
|
Lock,
|
||||||
|
Info,
|
||||||
|
User,
|
||||||
|
LogOut,
|
||||||
|
Layers,
|
||||||
|
ChevronDown,
|
||||||
|
TestTube2,
|
||||||
|
Edit
|
||||||
|
} from 'lucide-vue-next'
|
||||||
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
import DropdownMenu from '@/components/ui/dropdown-menu/DropdownMenu.vue'
|
||||||
import DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
|
import DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
|
||||||
import AppCard from '@/components/AppCard.vue'
|
import AppCard from '@/components/AppCard.vue'
|
||||||
@ -21,7 +41,7 @@ import DeviceSwitcher from '@/components/DeviceSwitcher.vue'
|
|||||||
import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
|
import EditDeviceNameDialog from '@/components/EditDeviceNameDialog.vue'
|
||||||
import EditNamespaceDialog from '@/components/EditNamespaceDialog.vue'
|
import EditNamespaceDialog from '@/components/EditNamespaceDialog.vue'
|
||||||
import FeatureNavigation from '@/components/FeatureNavigation.vue'
|
import FeatureNavigation from '@/components/FeatureNavigation.vue'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
|
|
||||||
const deviceUuid = ref('')
|
const deviceUuid = ref('')
|
||||||
const tokens = ref([])
|
const tokens = ref([])
|
||||||
@ -52,8 +72,7 @@ const authNote = ref('')
|
|||||||
|
|
||||||
|
|
||||||
// 使用OAuth回调处理
|
// 使用OAuth回调处理
|
||||||
const { handleOAuthCallback } = useOAuthCallback()
|
const {handleOAuthCallback} = useOAuthCallback()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 检查 namespace 是否等于 UUID(需要提示用户修改)
|
// 检查 namespace 是否等于 UUID(需要提示用户修改)
|
||||||
@ -74,7 +93,7 @@ const groupedTokens = computed(() => {
|
|||||||
const groups = {}
|
const groups = {}
|
||||||
for (const t of tokens.value) {
|
for (const t of tokens.value) {
|
||||||
const id = t.appId
|
const id = t.appId
|
||||||
if (!groups[id]) groups[id] = { appId: id, tokens: [] }
|
if (!groups[id]) groups[id] = {appId: id, tokens: []}
|
||||||
groups[id].tokens.push(t)
|
groups[id].tokens.push(t)
|
||||||
}
|
}
|
||||||
return Object.values(groups)
|
return Object.values(groups)
|
||||||
@ -92,7 +111,6 @@ const loadDeviceInfo = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const loadTokens = async () => {
|
const loadTokens = async () => {
|
||||||
if (!deviceUuid.value) return
|
if (!deviceUuid.value) return
|
||||||
|
|
||||||
@ -141,9 +159,9 @@ const authorizeApp = async () => {
|
|||||||
|
|
||||||
// 调用授权接口
|
// 调用授权接口
|
||||||
await apiClient.authorizeApp(
|
await apiClient.authorizeApp(
|
||||||
appIdToAuthorize.value,
|
appIdToAuthorize.value,
|
||||||
deviceUuid.value,
|
deviceUuid.value,
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
|
|
||||||
showAuthorizeDialog.value = false
|
showAuthorizeDialog.value = false
|
||||||
@ -168,9 +186,9 @@ const revokeToken = async () => {
|
|||||||
try {
|
try {
|
||||||
// 使用安装记录ID撤销授权
|
// 使用安装记录ID撤销授权
|
||||||
await apiClient.revokeDeviceToken(
|
await apiClient.revokeDeviceToken(
|
||||||
deviceUuid.value,
|
deviceUuid.value,
|
||||||
selectedToken.value.id,
|
selectedToken.value.id,
|
||||||
accountStore.isAuthenticated ? accountStore.token : null
|
accountStore.isAuthenticated ? accountStore.token : null
|
||||||
)
|
)
|
||||||
showRevokeDialog.value = false
|
showRevokeDialog.value = false
|
||||||
selectedToken.value = null
|
selectedToken.value = null
|
||||||
@ -214,7 +232,6 @@ const updateUuid = (newUuid = null) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const formatDate = (dateString) => {
|
const formatDate = (dateString) => {
|
||||||
return new Date(dateString).toLocaleString('zh-CN')
|
return new Date(dateString).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
@ -272,7 +289,7 @@ const bindCurrentDevice = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.bindDeviceToAccount(deviceUuid.value)
|
await apiClient.bindDeviceToAccount(deviceUuid.value)
|
||||||
await loadDeviceInfo()
|
await loadDeviceInfo()
|
||||||
toast.success('设备已绑定到您的账户')
|
toast.success('设备已绑定到您的账户')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -280,8 +297,8 @@ const bindCurrentDevice = async () => {
|
|||||||
if (error.message.includes('设备不存在')) {
|
if (error.message.includes('设备不存在')) {
|
||||||
try {
|
try {
|
||||||
await apiClient.registerDevice(
|
await apiClient.registerDevice(
|
||||||
deviceUuid.value,
|
deviceUuid.value,
|
||||||
deviceInfo.value?.deviceName || null
|
deviceInfo.value?.deviceName || null
|
||||||
)
|
)
|
||||||
await apiClient.bindDeviceToAccount(deviceUuid.value)
|
await apiClient.bindDeviceToAccount(deviceUuid.value)
|
||||||
await loadDeviceInfo()
|
await loadDeviceInfo()
|
||||||
@ -303,7 +320,7 @@ const unbindCurrentDevice = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.unbindDeviceFromAccount(deviceUuid.value)
|
await apiClient.unbindDeviceFromAccount(deviceUuid.value)
|
||||||
await loadDeviceInfo()
|
await loadDeviceInfo()
|
||||||
toast.success('设备已解绑')
|
toast.success('设备已解绑')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -347,7 +364,6 @@ onMounted(async () => {
|
|||||||
await loadDeviceAccount()
|
await loadDeviceAccount()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 加载tokens
|
// 加载tokens
|
||||||
await loadTokens()
|
await loadTokens()
|
||||||
}
|
}
|
||||||
@ -374,9 +390,9 @@ onMounted(async () => {
|
|||||||
<div class="h-8 w-px bg-border"></div>
|
<div class="h-8 w-px bg-border"></div>
|
||||||
<!-- Vercel风格设备切换器 -->
|
<!-- Vercel风格设备切换器 -->
|
||||||
<DeviceSwitcher
|
<DeviceSwitcher
|
||||||
:device-info="deviceInfo"
|
:device-info="deviceInfo"
|
||||||
:device-uuid="deviceUuid"
|
:device-uuid="deviceUuid"
|
||||||
@device-changed="updateUuid"
|
@device-changed="updateUuid"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -385,80 +401,79 @@ onMounted(async () => {
|
|||||||
<DropdownMenu v-model:open="showUserMenu" class="z-50">
|
<DropdownMenu v-model:open="showUserMenu" class="z-50">
|
||||||
<template #trigger="{ toggle, open }">
|
<template #trigger="{ toggle, open }">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="flex items-center gap-2"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="flex items-center gap-2"
|
variant="ghost"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="accountStore.userAvatar"
|
v-if="accountStore.userAvatar"
|
||||||
:src="accountStore.userAvatar"
|
:alt="accountStore.userName"
|
||||||
:alt="accountStore.userName"
|
:src="accountStore.userAvatar"
|
||||||
class="w-5 h-5 rounded-full"
|
class="w-5 h-5 rounded-full"
|
||||||
>
|
>
|
||||||
<User v-else class="h-4 w-4" />
|
<User v-else class="h-4 w-4"/>
|
||||||
<span class="hidden sm:inline">{{ accountStore.userName }}</span>
|
<span class="hidden sm:inline">{{ accountStore.userName }}</span>
|
||||||
<span v-if="accountStore.profile?.providerInfo"
|
<span v-if="accountStore.profile?.providerInfo"
|
||||||
class="hidden md:inline px-1.5 py-0.5 rounded text-[10px]"
|
|
||||||
:style="{
|
:style="{
|
||||||
backgroundColor: (accountStore.profile.providerInfo.color || '#999') + '22',
|
backgroundColor: (accountStore.profile.providerInfo.color || '#999') + '22',
|
||||||
color: accountStore.profile.providerInfo.color || 'inherit',
|
color: accountStore.profile.providerInfo.color || 'inherit',
|
||||||
border: `1px solid ${(accountStore.profile.providerInfo.color || '#999')}55`
|
border: `1px solid ${(accountStore.profile.providerInfo.color || '#999')}55`
|
||||||
}"
|
}"
|
||||||
|
class="hidden md:inline px-1.5 py-0.5 rounded text-[10px]"
|
||||||
>
|
>
|
||||||
{{ accountStore.profile.providerInfo.displayName }}
|
{{ accountStore.profile.providerInfo.displayName }}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown class="h-3.5 w-3.5" :class="{ 'rotate-180': open }" />
|
<ChevronDown :class="{ 'rotate-180': open }" class="h-3.5 w-3.5"/>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
<DropdownItem :href="accountStore.profile.providerInfo.website" target="_blank" :style="{
|
<DropdownItem :href="accountStore.profile.providerInfo.website" :style="{
|
||||||
backgroundColor: (accountStore.profile.providerInfo.color || '#999') + '22',
|
backgroundColor: (accountStore.profile.providerInfo.color || '#999') + '22',
|
||||||
color: accountStore.profile.providerInfo.color || 'inherit',
|
color: accountStore.profile.providerInfo.color || 'inherit',
|
||||||
border: `1px solid ${(accountStore.profile.providerInfo.color || '#999')}55`
|
border: `1px solid ${(accountStore.profile.providerInfo.color || '#999')}55`
|
||||||
}">
|
}" target="_blank">
|
||||||
<Layers class="h-4 w-4" />
|
<Layers class="h-4 w-4"/>
|
||||||
账户渠道:{{ accountStore.profile.providerInfo.displayName }}
|
账户渠道:{{ accountStore.profile.providerInfo.displayName }}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem @click="$router.push('/device-management')">
|
<DropdownItem @click="$router.push('/device-management')">
|
||||||
<Layers class="h-4 w-4" />
|
<Layers class="h-4 w-4"/>
|
||||||
设备管理
|
设备管理
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
|
||||||
<DropdownItem @click="$router.push('/auto-auth-management')">
|
<DropdownItem @click="$router.push('/auto-auth-management')">
|
||||||
<Shield class="h-4 w-4" />
|
<Shield class="h-4 w-4"/>
|
||||||
自动授权配置
|
自动授权配置
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem @click="$router.push('/auto-auth-test')">
|
<DropdownItem @click="$router.push('/auto-auth-test')">
|
||||||
<TestTube2 class="h-4 w-4" />
|
<TestTube2 class="h-4 w-4"/>
|
||||||
API 测试工具
|
API 测试工具
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem @click="handleLogout" class="text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300">
|
<DropdownItem class="text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-300"
|
||||||
<LogOut class="h-4 w-4" />
|
@click="handleLogout">
|
||||||
|
<LogOut class="h-4 w-4"/>
|
||||||
退出登录
|
退出登录
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
<Button
|
<Button
|
||||||
v-else
|
v-else
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
@click="showLoginDialog = true"
|
@click="showLoginDialog = true"
|
||||||
>
|
>
|
||||||
<User class="h-4 w-4 mr-2" />
|
<User class="h-4 w-4 mr-2"/>
|
||||||
登录
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
:disabled="isLoading"
|
||||||
size="icon"
|
size="icon"
|
||||||
@click="loadTokens"
|
variant="outline"
|
||||||
:disabled="isLoading"
|
@click="loadTokens"
|
||||||
>
|
>
|
||||||
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4" />
|
<RefreshCw :class="{ 'animate-spin': isLoading }" class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -468,10 +483,11 @@ onMounted(async () => {
|
|||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
<div class="container mx-auto px-6 py-8 max-w-7xl">
|
||||||
<!-- Namespace 提示卡片 - 如果 namespace 等于 UUID -->
|
<!-- Namespace 提示卡片 - 如果 namespace 等于 UUID -->
|
||||||
<Card v-if="namespaceEqualsUuid" class="mb-6 border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/20">
|
<Card v-if="namespaceEqualsUuid"
|
||||||
|
class="mb-6 border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/20">
|
||||||
<CardContent class="py-4">
|
<CardContent class="py-4">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<AlertCircle class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
|
<AlertCircle class="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0"/>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-100 mb-1">
|
<p class="text-sm font-medium text-yellow-900 dark:text-yellow-100 mb-1">
|
||||||
建议自定义命名空间
|
建议自定义命名空间
|
||||||
@ -480,13 +496,13 @@ onMounted(async () => {
|
|||||||
您的命名空间当前使用设备 UUID,建议修改为更有意义的名称(如班级名、房间号等),方便自动授权时识别。
|
您的命名空间当前使用设备 UUID,建议修改为更有意义的名称(如班级名、房间号等),方便自动授权时识别。
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
v-if="accountStore.isAuthenticated"
|
v-if="accountStore.isAuthenticated"
|
||||||
variant="outline"
|
class="bg-yellow-100 dark:bg-yellow-900/30 border-yellow-300 dark:border-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-900/50"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="showEditNamespaceDialog = true"
|
variant="outline"
|
||||||
class="bg-yellow-100 dark:bg-yellow-900/30 border-yellow-300 dark:border-yellow-700 hover:bg-yellow-200 dark:hover:bg-yellow-900/50"
|
@click="showEditNamespaceDialog = true"
|
||||||
>
|
>
|
||||||
<Settings class="h-3 w-3 mr-2" />
|
<Settings class="h-3 w-3 mr-2"/>
|
||||||
立即修改
|
立即修改
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -500,7 +516,7 @@ onMounted(async () => {
|
|||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-primary/10 p-2">
|
<div class="rounded-lg bg-primary/10 p-2">
|
||||||
<Layers class="h-5 w-5 text-primary" />
|
<Layers class="h-5 w-5 text-primary"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@ -508,13 +524,13 @@ onMounted(async () => {
|
|||||||
{{ deviceInfo?.name || '设备' }}
|
{{ deviceInfo?.name || '设备' }}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Button
|
<Button
|
||||||
v-if="accountStore.isAuthenticated"
|
v-if="accountStore.isAuthenticated"
|
||||||
variant="ghost"
|
class="h-6 w-6 p-0"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="showEditNameDialog = true"
|
variant="ghost"
|
||||||
class="h-6 w-6 p-0"
|
@click="showEditNameDialog = true"
|
||||||
>
|
>
|
||||||
<Edit class="h-3 w-3" />
|
<Edit class="h-3 w-3"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>设备命名空间标识符</CardDescription>
|
<CardDescription>设备命名空间标识符</CardDescription>
|
||||||
@ -524,26 +540,26 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
<!-- 设备账户绑定状态 -->
|
<!-- 设备账户绑定状态 -->
|
||||||
<Badge v-if="deviceInfo?.account" variant="secondary" class="px-3 py-1">
|
<Badge v-if="deviceInfo?.account" class="px-3 py-1" variant="secondary">
|
||||||
<User class="h-3 w-3 mr-1.5" />
|
<User class="h-3 w-3 mr-1.5"/>
|
||||||
{{ deviceInfo.account.name }}
|
{{ deviceInfo.account.name }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
v-else-if="accountStore.isAuthenticated"
|
v-else-if="accountStore.isAuthenticated"
|
||||||
variant="outline"
|
size="sm"
|
||||||
size="sm"
|
variant="outline"
|
||||||
@click="bindCurrentDevice"
|
@click="bindCurrentDevice"
|
||||||
>
|
>
|
||||||
<User class="h-4 w-4 mr-2" />
|
<User class="h-4 w-4 mr-2"/>
|
||||||
绑定到账户
|
绑定到账户
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@click="$router.push('/auto-auth-management')"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
@click="$router.push('/auto-auth-management')"
|
||||||
>
|
>
|
||||||
<Shield class="h-4 w-4 mr-1" />
|
<Shield class="h-4 w-4 mr-1"/>
|
||||||
自动授权
|
自动授权
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -553,18 +569,19 @@ onMounted(async () => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<!-- Namespace Display (主要显示) -->
|
<!-- Namespace Display (主要显示) -->
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div class="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-lg blur-xl group-hover:blur-2xl transition-all duration-300 opacity-50" />
|
<div
|
||||||
|
class="absolute inset-0 bg-gradient-to-r from-primary/20 to-primary/10 rounded-lg blur-xl group-hover:blur-2xl transition-all duration-300 opacity-50"/>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<Label class="text-sm font-medium">命名空间</Label>
|
<Label class="text-sm font-medium">命名空间</Label>
|
||||||
<Button
|
<Button
|
||||||
v-if="accountStore.isAuthenticated"
|
v-if="accountStore.isAuthenticated"
|
||||||
variant="ghost"
|
class="h-7"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="showEditNamespaceDialog = true"
|
variant="ghost"
|
||||||
class="h-7"
|
@click="showEditNamespaceDialog = true"
|
||||||
>
|
>
|
||||||
<Settings class="h-3 w-3 mr-1" />
|
<Settings class="h-3 w-3 mr-1"/>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -573,14 +590,14 @@ onMounted(async () => {
|
|||||||
{{ deviceInfo?.namespace || deviceUuid }}
|
{{ deviceInfo?.namespace || deviceUuid }}
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-8 w-8"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8"
|
title="复制命名空间"
|
||||||
@click="copyToClipboard(deviceInfo?.namespace || deviceUuid, 'namespace')"
|
variant="ghost"
|
||||||
title="复制命名空间"
|
@click="copyToClipboard(deviceInfo?.namespace || deviceUuid, 'namespace')"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="copied === 'namespace'" class="h-4 w-4 text-green-500 animate-in zoom-in-50" />
|
<CheckCircle2 v-if="copied === 'namespace'" class="h-4 w-4 text-green-500 animate-in zoom-in-50"/>
|
||||||
<Copy v-else class="h-4 w-4" />
|
<Copy v-else class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -606,41 +623,42 @@ onMounted(async () => {
|
|||||||
|
|
||||||
|
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h2 class="text-xl font-semibold">已授权应用</h2>
|
<h2 class="text-xl font-semibold">已授权应用</h2>
|
||||||
<Button @click="showAuthorizeDialog = true" class="gap-2">
|
<Button class="gap-2" @click="showAuthorizeDialog = true">
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4"/>
|
||||||
授权新应用
|
授权新应用
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div v-if="isLoading" class="text-center py-12">
|
<div v-if="isLoading" class="text-center py-12">
|
||||||
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
<RefreshCw class="h-8 w-8 animate-spin mx-auto text-muted-foreground"/>
|
||||||
<p class="mt-4 text-muted-foreground">加载中...</p>
|
<p class="mt-4 text-muted-foreground">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Card v-else-if="tokens.length === 0" class="border-dashed">
|
<Card v-else-if="tokens.length === 0" class="border-dashed">
|
||||||
<CardContent class="flex flex-col items-center justify-center py-12">
|
<CardContent class="flex flex-col items-center justify-center py-12">
|
||||||
<Package class="h-16 w-16 text-muted-foreground/50 mb-4" />
|
<Package class="h-16 w-16 text-muted-foreground/50 mb-4"/>
|
||||||
<p class="text-lg font-medium text-muted-foreground mb-2">暂无授权应用</p>
|
<p class="text-lg font-medium text-muted-foreground mb-2">暂无授权应用</p>
|
||||||
<p class="text-sm text-muted-foreground mb-4">点击上方按钮授权您的第一个应用</p>
|
<p class="text-sm text-muted-foreground mb-4">点击上方按钮授权您的第一个应用</p>
|
||||||
<Button @click="showAuthorizeDialog = true" variant="outline">
|
<Button variant="outline" @click="showAuthorizeDialog = true">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
授权应用
|
授权应用
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="group in groupedTokens"
|
v-for="group in groupedTokens"
|
||||||
:key="group.appId"
|
:key="group.appId"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<AppCard :app-id="group.appId" />
|
<AppCard :app-id="group.appId"/>
|
||||||
|
|
||||||
<TokenList
|
<TokenList
|
||||||
:items="group.tokens.map(t => ({
|
:copied-id="copied"
|
||||||
|
:items="group.tokens.map(t => ({
|
||||||
id: t.id,
|
id: t.id,
|
||||||
token: t.token,
|
token: t.token,
|
||||||
appId: t.appId,
|
appId: t.appId,
|
||||||
@ -648,22 +666,21 @@ onMounted(async () => {
|
|||||||
note: t.note,
|
note: t.note,
|
||||||
installedAt: t.installedAt,
|
installedAt: t.installedAt,
|
||||||
}))"
|
}))"
|
||||||
:loading="isLoading"
|
:loading="isLoading"
|
||||||
:copied-id="copied"
|
:show-app-column="false"
|
||||||
:show-app-column="false"
|
compact
|
||||||
compact
|
sort-by-time
|
||||||
sort-by-time
|
@copy="(item) => copyToClipboard(item.token, item.token)"
|
||||||
@copy="(item) => copyToClipboard(item.token, item.token)"
|
@open="(item) => { selectedToken = item; showTokenDialog = true }"
|
||||||
@revoke="confirmRevoke"
|
@revoke="confirmRevoke"
|
||||||
@open="(item) => { selectedToken = item; showTokenDialog = true }"
|
/>
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 功能导航 -->
|
<!-- 功能导航 -->
|
||||||
<div class="mt-12">
|
<div class="mt-12">
|
||||||
<FeatureNavigation />
|
<FeatureNavigation/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@ -679,17 +696,17 @@ onMounted(async () => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="appId">应用 ID</Label>
|
<Label for="appId">应用 ID</Label>
|
||||||
<Input
|
<Input
|
||||||
id="appId"
|
id="appId"
|
||||||
v-model="appIdToAuthorize"
|
v-model="appIdToAuthorize"
|
||||||
placeholder="输入应用 ID"
|
placeholder="输入应用 ID"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="note">备注(可选)</Label>
|
<Label for="note">备注(可选)</Label>
|
||||||
<Input
|
<Input
|
||||||
id="note"
|
id="note"
|
||||||
v-model="authNote"
|
v-model="authNote"
|
||||||
placeholder="为此授权添加备注"
|
placeholder="为此授权添加备注"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="accountStore.isAuthenticated" class="text-xs text-muted-foreground mt-2">
|
<p v-if="accountStore.isAuthenticated" class="text-xs text-muted-foreground mt-2">
|
||||||
@ -767,63 +784,64 @@ onMounted(async () => {
|
|||||||
<span class="font-medium">令牌:</span>
|
<span class="font-medium">令牌:</span>
|
||||||
<code class="text-xs font-mono break-all">{{ selectedToken.token.slice(0, 8) }}...</code>
|
<code class="text-xs font-mono break-all">{{ selectedToken.token.slice(0, 8) }}...</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-7 w-7 ml-auto"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-7 w-7 ml-auto"
|
variant="ghost"
|
||||||
@click="copyToClipboard(selectedToken.token, selectedToken.token)"
|
@click="copyToClipboard(selectedToken.token, selectedToken.token)"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="copied === selectedToken.token" class="h-3.5 w-3.5 text-green-500" />
|
<CheckCircle2 v-if="copied === selectedToken.token" class="h-3.5 w-3.5 text-green-500"/>
|
||||||
<Copy v-else class="h-3.5 w-3.5" />
|
<Copy v-else class="h-3.5 w-3.5"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-muted-foreground flex items-center gap-2">
|
<div class="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
<Clock class="h-4 w-4" />
|
<Clock class="h-4 w-4"/>
|
||||||
<span>{{ formatDate(selectedToken.installedAt) }}</span>
|
<span>{{ formatDate(selectedToken.installedAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="showTokenDialog = false">关闭</Button>
|
<Button variant="outline" @click="showTokenDialog = false">关闭</Button>
|
||||||
<Button variant="destructive" @click="() => { showTokenDialog = false; confirmRevoke(selectedToken) }">撤销</Button>
|
<Button variant="destructive" @click="() => { showTokenDialog = false; confirmRevoke(selectedToken) }">
|
||||||
|
撤销
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 登录弹框 -->
|
<!-- 登录弹框 -->
|
||||||
<LoginDialog
|
<LoginDialog
|
||||||
v-model="showLoginDialog"
|
v-model="showLoginDialog"
|
||||||
:on-success="handleLoginSuccess"
|
:on-success="handleLoginSuccess"
|
||||||
@update:modelValue="val => {
|
@update:modelValue="val => {
|
||||||
if (!val && deviceRequired.value) {
|
if (!val && deviceRequired.value) {
|
||||||
showRegisterDialog.value = true
|
showRegisterDialog.value = true
|
||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
/> <!-- 设备注册弹框 -->
|
/> <!-- 设备注册弹框 -->
|
||||||
<DeviceRegisterDialog
|
<DeviceRegisterDialog
|
||||||
v-model="showRegisterDialog"
|
v-model="showRegisterDialog"
|
||||||
@confirm="handleDeviceRegistered"
|
:required="deviceRequired"
|
||||||
:required="deviceRequired"
|
@confirm="handleDeviceRegistered"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 设备名称编辑弹框 -->
|
<!-- 设备名称编辑弹框 -->
|
||||||
<EditDeviceNameDialog
|
<EditDeviceNameDialog
|
||||||
v-model="showEditNameDialog"
|
v-model="showEditNameDialog"
|
||||||
:device-uuid="deviceUuid"
|
:current-name="deviceInfo?.deviceName || ''"
|
||||||
:current-name="deviceInfo?.deviceName || ''"
|
:device-uuid="deviceUuid"
|
||||||
@success="handleDeviceNameUpdated"
|
@success="handleDeviceNameUpdated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 命名空间编辑弹框 -->
|
<!-- 命名空间编辑弹框 -->
|
||||||
<EditNamespaceDialog
|
<EditNamespaceDialog
|
||||||
v-if="accountStore.isAuthenticated && deviceInfo"
|
v-if="accountStore.isAuthenticated && deviceInfo"
|
||||||
v-model="showEditNamespaceDialog"
|
v-model="showEditNamespaceDialog"
|
||||||
:device-uuid="deviceUuid"
|
:account-token="accountStore.token"
|
||||||
:current-namespace="deviceInfo.namespace"
|
:current-namespace="deviceInfo.namespace"
|
||||||
:account-token="accountStore.token"
|
:device-uuid="deviceUuid"
|
||||||
@success="handleNamespaceUpdated"
|
@success="handleNamespaceUpdated"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -1,18 +1,43 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import {ref, computed, onMounted} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { toast } from 'vue-sonner'
|
import {toast} from 'vue-sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import {Input} from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import {Label} from '@/components/ui/label'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import {Checkbox} from '@/components/ui/checkbox'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import {Separator} from '@/components/ui/separator'
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||||
import { Table, TableBody, TableCaption, TableCell, TableEmpty, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import {
|
||||||
import { Download, Upload, Trash2, Plus, Loader2, Search, RefreshCw, Copy, Edit, Check, X, Key, ShieldCheck, Database } from 'lucide-vue-next'
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCaption,
|
||||||
|
TableCell,
|
||||||
|
TableEmpty,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '@/components/ui/table'
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Copy,
|
||||||
|
Edit,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Key,
|
||||||
|
ShieldCheck,
|
||||||
|
Database
|
||||||
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
// Token 与自动授权
|
// Token 与自动授权
|
||||||
const token = ref(localStorage.getItem('kv_token') || '')
|
const token = ref(localStorage.getItem('kv_token') || '')
|
||||||
@ -87,9 +112,9 @@ const acquireToken = async () => {
|
|||||||
autoAuthLoading.value = true
|
autoAuthLoading.value = true
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.getTokenByNamespace(
|
const res = await apiClient.getTokenByNamespace(
|
||||||
autoAuth.value.namespace,
|
autoAuth.value.namespace,
|
||||||
autoAuth.value.password || undefined,
|
autoAuth.value.password || undefined,
|
||||||
autoAuth.value.appId
|
autoAuth.value.appId
|
||||||
)
|
)
|
||||||
if (res?.token) {
|
if (res?.token) {
|
||||||
token.value = res.token
|
token.value = res.token
|
||||||
@ -263,7 +288,7 @@ const exportAll = async () => {
|
|||||||
// 忽略单项失败
|
// 忽略单项失败
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'})
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
@ -310,10 +335,17 @@ const previewValue = (v) => {
|
|||||||
if (v === undefined) return '点击查看'
|
if (v === undefined) return '点击查看'
|
||||||
try {
|
try {
|
||||||
return typeof v === 'string' ? v : JSON.stringify(v, null, 2)
|
return typeof v === 'string' ? v : JSON.stringify(v, null, 2)
|
||||||
} catch { return String(v) }
|
} catch {
|
||||||
|
return String(v)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const copy = async (text) => {
|
const copy = async (text) => {
|
||||||
try { await navigator.clipboard.writeText(text); toast.success('已复制') } catch { toast.error('复制失败') }
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
toast.success('已复制')
|
||||||
|
} catch {
|
||||||
|
toast.error('复制失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -323,7 +355,7 @@ const copy = async (text) => {
|
|||||||
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10">
|
<div class="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 sticky top-0 z-10">
|
||||||
<div class="container mx-auto px-6 py-4">
|
<div class="container mx-auto px-6 py-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Database class="h-6 w-6" />
|
<Database class="h-6 w-6"/>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">KV 数据管理器</h1>
|
<h1 class="text-2xl font-bold">KV 数据管理器</h1>
|
||||||
<p class="text-sm text-muted-foreground">自动授权获取 Token,使用现代表格进行数据管理</p>
|
<p class="text-sm text-muted-foreground">自动授权获取 Token,使用现代表格进行数据管理</p>
|
||||||
@ -337,7 +369,8 @@ const copy = async (text) => {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="flex items-center gap-2">
|
<CardTitle class="flex items-center gap-2">
|
||||||
<ShieldCheck class="h-5 w-5" /> 自动授权 / Token
|
<ShieldCheck class="h-5 w-5"/>
|
||||||
|
自动授权 / Token
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>通过命名空间快速获取 Token,或手动填写 Token</CardDescription>
|
<CardDescription>通过命名空间快速获取 Token,或手动填写 Token</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -345,28 +378,28 @@ const copy = async (text) => {
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="ns">命名空间</Label>
|
<Label for="ns">命名空间</Label>
|
||||||
<Input id="ns" v-model="autoAuth.namespace" placeholder="例如: class-2024-1" />
|
<Input id="ns" v-model="autoAuth.namespace" placeholder="例如: class-2024-1"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="pwd">授权密码(可选)</Label>
|
<Label for="pwd">授权密码(可选)</Label>
|
||||||
<Input id="pwd" type="password" v-model="autoAuth.password" placeholder="留空表示无密码" />
|
<Input id="pwd" v-model="autoAuth.password" placeholder="留空表示无密码" type="password"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="appid">App ID</Label>
|
<Label for="appid">App ID</Label>
|
||||||
<Input id="appid" disabled v-model="autoAuth.appId" placeholder="应用标识符" />
|
<Input id="appid" v-model="autoAuth.appId" disabled placeholder="应用标识符"/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<Button @click="acquireToken" :disabled="autoAuthLoading">
|
<Button :disabled="autoAuthLoading" @click="acquireToken">
|
||||||
<Loader2 v-if="autoAuthLoading" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="autoAuthLoading" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
<Key v-else class="mr-2 h-4 w-4" />
|
<Key v-else class="mr-2 h-4 w-4"/>
|
||||||
自动授权获取 Token
|
自动授权获取 Token
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex-1" />
|
<div class="flex-1"/>
|
||||||
<div class="flex items-center gap-2 min-w-[280px]">
|
<div class="flex items-center gap-2 min-w-[280px]">
|
||||||
<Input v-model="token" placeholder="或手动粘贴 Token" />
|
<Input v-model="token" placeholder="或手动粘贴 Token"/>
|
||||||
<Button variant="outline" @click="clearToken" :disabled="!isTokenSet">清除</Button>
|
<Button :disabled="!isTokenSet" variant="outline" @click="clearToken">清除</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -378,31 +411,39 @@ const copy = async (text) => {
|
|||||||
<div class="flex flex-col md:flex-row gap-3 md:items-center">
|
<div class="flex flex-col md:flex-row gap-3 md:items-center">
|
||||||
<div class="flex items-center gap-2 md:w-[700px] w-full flex-wrap">
|
<div class="flex items-center gap-2 md:w-[700px] w-full flex-wrap">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground"/>
|
||||||
<Input class="pl-8" v-model="searchText" placeholder="本地过滤关键字" />
|
<Input v-model="searchText" class="pl-8" placeholder="本地过滤关键字"/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" @click="loadKeys" :disabled="!isTokenSet">
|
<Button :disabled="!isTokenSet" variant="outline" @click="loadKeys">
|
||||||
<RefreshCw class="h-4 w-4 mr-2" /> 刷新
|
<RefreshCw class="h-4 w-4 mr-2"/>
|
||||||
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<div class="flex items-center gap-2 min-w-[300px]">
|
<div class="flex items-center gap-2 min-w-[300px]">
|
||||||
<Input v-model="specificKey" placeholder="输入完整键名(加载单项)" />
|
<Input v-model="specificKey" placeholder="输入完整键名(加载单项)"/>
|
||||||
<Button variant="outline" @click="loadSpecificKey" :disabled="!isTokenSet || !specificKey.trim()">加载项</Button>
|
<Button :disabled="!isTokenSet || !specificKey.trim()" variant="outline" @click="loadSpecificKey">
|
||||||
|
加载项
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:ml-auto flex items-center gap-2">
|
<div class="md:ml-auto flex items-center gap-2">
|
||||||
<Button variant="outline" @click="startImport" :disabled="!isTokenSet">
|
<Button :disabled="!isTokenSet" variant="outline" @click="startImport">
|
||||||
<Upload class="h-4 w-4 mr-2" /> 导入
|
<Upload class="h-4 w-4 mr-2"/>
|
||||||
|
导入
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" @click="exportAll" :disabled="!isTokenSet || keys.length===0">
|
<Button :disabled="!isTokenSet || keys.length===0" variant="outline" @click="exportAll">
|
||||||
<Download class="h-4 w-4 mr-2" /> 导出
|
<Download class="h-4 w-4 mr-2"/>
|
||||||
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
<Separator orientation="vertical" class="h-6" />
|
<Separator class="h-6" orientation="vertical"/>
|
||||||
<Button variant="secondary" @click="openCreate" :disabled="!isTokenSet">
|
<Button :disabled="!isTokenSet" variant="secondary" @click="openCreate">
|
||||||
<Plus class="h-4 w-4 mr-2" /> 新建
|
<Plus class="h-4 w-4 mr-2"/>
|
||||||
|
新建
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" @click="bulkDelete" :disabled="!isTokenSet || selected.size===0 || bulkDeleting">
|
<Button :disabled="!isTokenSet || selected.size===0 || bulkDeleting" variant="destructive"
|
||||||
<Loader2 v-if="bulkDeleting" class="mr-2 h-4 w-4 animate-spin" />
|
@click="bulkDelete">
|
||||||
<Trash2 v-else class="h-4 w-4 mr-2" /> 删除所选
|
<Loader2 v-if="bulkDeleting" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
|
<Trash2 v-else class="h-4 w-4 mr-2"/>
|
||||||
|
删除所选
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -425,7 +466,9 @@ const copy = async (text) => {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-10">
|
<TableHead class="w-10">
|
||||||
<Checkbox :checked="selected.size>0 && selected.size===pagedKeys.length" :indeterminate="selected.size>0 && selected.size<pagedKeys.length" @update:checked="val => { if(val){ pagedKeys.forEach(k=>selected.add(k)) } else { pagedKeys.forEach(k=>selected.delete(k)) } }" />
|
<Checkbox :checked="selected.size>0 && selected.size===pagedKeys.length"
|
||||||
|
:indeterminate="selected.size>0 && selected.size<pagedKeys.length"
|
||||||
|
@update:checked="val => { if(val){ pagedKeys.forEach(k=>selected.add(k)) } else { pagedKeys.forEach(k=>selected.delete(k)) } }"/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="min-w-[260px]">键名</TableHead>
|
<TableHead class="min-w-[260px]">键名</TableHead>
|
||||||
<TableHead>值预览</TableHead>
|
<TableHead>值预览</TableHead>
|
||||||
@ -435,32 +478,42 @@ const copy = async (text) => {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-for="k in pagedKeys" :key="k">
|
<TableRow v-for="k in pagedKeys" :key="k">
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Checkbox :checked="selected.has(k)" @update:checked="val => { val ? selected.add(k) : selected.delete(k) }" />
|
<Checkbox :checked="selected.has(k)"
|
||||||
|
@update:checked="val => { val ? selected.add(k) : selected.delete(k) }"/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
class="font-mono text-sm break-all cursor-pointer hover:underline"
|
class="font-mono text-sm break-all cursor-pointer hover:underline"
|
||||||
@click="openEdit(k)"
|
title="点击查看/编辑"
|
||||||
title="点击查看/编辑"
|
@click="openEdit(k)"
|
||||||
>
|
>
|
||||||
{{ k }}
|
{{ k }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div
|
<div
|
||||||
class="text-xs whitespace-pre-wrap max-h-40 overflow-auto rounded-md bg-muted p-2 cursor-pointer hover:bg-muted/70 transition-colors"
|
class="text-xs whitespace-pre-wrap max-h-40 overflow-auto rounded-md bg-muted p-2 cursor-pointer hover:bg-muted/70 transition-colors"
|
||||||
@click="openEdit(k)"
|
title="点击查看/编辑"
|
||||||
title="点击查看/编辑"
|
@click="openEdit(k)"
|
||||||
>
|
>
|
||||||
{{ previewValue(values[k]) }}
|
{{ previewValue(values[k]) }}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="space-x-1 whitespace-nowrap">
|
<TableCell class="space-x-1 whitespace-nowrap">
|
||||||
<Button size="sm" variant="outline" @click="copy(k)"><Copy class="h-3.5 w-3.5 mr-1" />复制键</Button>
|
<Button size="sm" variant="outline" @click="copy(k)">
|
||||||
<Button size="sm" variant="outline" @click="openEdit(k)"><Edit class="h-3.5 w-3.5 mr-1" />编辑</Button>
|
<Copy class="h-3.5 w-3.5 mr-1"/>
|
||||||
<Button size="sm" variant="destructive" @click="deleteKey(k)"><Trash2 class="h-3.5 w-3.5 mr-1" />删除</Button>
|
复制键
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" @click="openEdit(k)">
|
||||||
|
<Edit class="h-3.5 w-3.5 mr-1"/>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="destructive" @click="deleteKey(k)">
|
||||||
|
<Trash2 class="h-3.5 w-3.5 mr-1"/>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
<TableRow v-if="!pagedKeys.length">
|
<TableRow v-if="!pagedKeys.length">
|
||||||
<TableCell colspan="4" class="text-center text-muted-foreground py-10">暂无数据</TableCell>
|
<TableCell class="text-center text-muted-foreground py-10" colspan="4">暂无数据</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@ -469,7 +522,8 @@ const copy = async (text) => {
|
|||||||
<div class="flex items-center justify-between mt-4 text-sm">
|
<div class="flex items-center justify-between mt-4 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>每页</span>
|
<span>每页</span>
|
||||||
<select v-model.number="pageSize" class="h-9 w-[80px] rounded-md border border-input bg-background px-2">
|
<select v-model.number="pageSize"
|
||||||
|
class="h-9 w-[80px] rounded-md border border-input bg-background px-2">
|
||||||
<option :value="10">10</option>
|
<option :value="10">10</option>
|
||||||
<option :value="20">20</option>
|
<option :value="20">20</option>
|
||||||
<option :value="50">50</option>
|
<option :value="50">50</option>
|
||||||
@ -477,9 +531,11 @@ const copy = async (text) => {
|
|||||||
<span class="text-muted-foreground">共 {{ totalPages }} 页</span>
|
<span class="text-muted-foreground">共 {{ totalPages }} 页</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button variant="outline" :disabled="page===1" @click="page=Math.max(1,page-1)">上一页</Button>
|
<Button :disabled="page===1" variant="outline" @click="page=Math.max(1,page-1)">上一页</Button>
|
||||||
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
<span>第 {{ page }} / {{ totalPages }} 页</span>
|
||||||
<Button variant="outline" :disabled="page===totalPages" @click="page=Math.min(totalPages,page+1)">下一页</Button>
|
<Button :disabled="page===totalPages" variant="outline" @click="page=Math.min(totalPages,page+1)">
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -497,18 +553,19 @@ const copy = async (text) => {
|
|||||||
<div class="space-y-3 py-2">
|
<div class="space-y-3 py-2">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="kv-key">键名</Label>
|
<Label for="kv-key">键名</Label>
|
||||||
<Input id="kv-key" v-model="formKey" :disabled="isEditing" placeholder="请输入键名" />
|
<Input id="kv-key" v-model="formKey" :disabled="isEditing" placeholder="请输入键名"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<Label for="kv-val">值(JSON 或文本)</Label>
|
<Label for="kv-val">值(JSON 或文本)</Label>
|
||||||
<textarea id="kv-val" v-model="formValue" rows="10" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"></textarea>
|
<textarea id="kv-val" v-model="formValue" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||||
|
rows="10"></textarea>
|
||||||
<p v-if="formError" class="text-sm text-red-500">{{ formError }}</p>
|
<p v-if="formError" class="text-sm text-red-500">{{ formError }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="editOpen=false" :disabled="saving">取消</Button>
|
<Button :disabled="saving" variant="outline" @click="editOpen=false">取消</Button>
|
||||||
<Button @click="saveKeyValue" :disabled="saving">
|
<Button :disabled="saving" @click="saveKeyValue">
|
||||||
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="saving" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
保存
|
保存
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@ -523,13 +580,15 @@ const copy = async (text) => {
|
|||||||
<DialogDescription>JSON 对象的每个键会写入为一个 KV 项</DialogDescription>
|
<DialogDescription>JSON 对象的每个键会写入为一个 KV 项</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-3 py-2">
|
<div class="space-y-3 py-2">
|
||||||
<textarea v-model="importJson" rows="12" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono" placeholder='{"key":"value"}'></textarea>
|
<textarea v-model="importJson" class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono"
|
||||||
|
placeholder='{"key":"value"}'
|
||||||
|
rows="12"></textarea>
|
||||||
<p v-if="importError" class="text-sm text-red-500">{{ importError }}</p>
|
<p v-if="importError" class="text-sm text-red-500">{{ importError }}</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" @click="importOpen=false" :disabled="importing">取消</Button>
|
<Button :disabled="importing" variant="outline" @click="importOpen=false">取消</Button>
|
||||||
<Button @click="doImport" :disabled="importing">
|
<Button :disabled="importing" @click="doImport">
|
||||||
<Loader2 v-if="importing" class="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 v-if="importing" class="mr-2 h-4 w-4 animate-spin"/>
|
||||||
开始导入
|
开始导入
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import {ref, onMounted} from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import {useRouter} from 'vue-router'
|
||||||
import { deviceStore } from '@/lib/deviceStore'
|
import {deviceStore} from '@/lib/deviceStore'
|
||||||
import { Button } from '@/components/ui/button'
|
import {Button} from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@ -59,8 +59,8 @@ onMounted(async () => {
|
|||||||
<div class="container mx-auto px-6 py-4">
|
<div class="container mx-auto px-6 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Button variant="ghost" size="icon" @click="goBack">
|
<Button size="icon" variant="ghost" @click="goBack">
|
||||||
<ArrowLeft class="h-5 w-5" />
|
<ArrowLeft class="h-5 w-5"/>
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-xl font-bold">高级设置</h1>
|
<h1 class="text-xl font-bold">高级设置</h1>
|
||||||
@ -75,15 +75,17 @@ onMounted(async () => {
|
|||||||
<div class="container mx-auto px-6 py-8 max-w-4xl">
|
<div class="container mx-auto px-6 py-8 max-w-4xl">
|
||||||
<!-- Success/Error Messages -->
|
<!-- Success/Error Messages -->
|
||||||
<div v-if="successMessage" class="mb-6">
|
<div v-if="successMessage" class="mb-6">
|
||||||
<div class="flex items-center gap-2 p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
|
<div
|
||||||
<CheckCircle2 class="h-5 w-5 text-green-600 dark:text-green-400" />
|
class="flex items-center gap-2 p-4 rounded-lg bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900">
|
||||||
|
<CheckCircle2 class="h-5 w-5 text-green-600 dark:text-green-400"/>
|
||||||
<span class="text-green-800 dark:text-green-200">{{ successMessage }}</span>
|
<span class="text-green-800 dark:text-green-200">{{ successMessage }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="errorMessage" class="mb-6">
|
<div v-if="errorMessage" class="mb-6">
|
||||||
<div class="flex items-center gap-2 p-4 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900">
|
<div
|
||||||
<AlertTriangle class="h-5 w-5 text-red-600 dark:text-red-400" />
|
class="flex items-center gap-2 p-4 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-red-600 dark:text-red-400"/>
|
||||||
<span class="text-red-800 dark:text-red-200">{{ errorMessage }}</span>
|
<span class="text-red-800 dark:text-red-200">{{ errorMessage }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -94,7 +96,7 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="rounded-lg bg-primary/10 p-2">
|
<div class="rounded-lg bg-primary/10 p-2">
|
||||||
<Smartphone class="h-6 w-6 text-primary" />
|
<Smartphone class="h-6 w-6 text-primary"/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>设备信息</CardTitle>
|
<CardTitle>设备信息</CardTitle>
|
||||||
@ -110,12 +112,12 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center justify-between mb-2">
|
<div class="flex items-center justify-between mb-2">
|
||||||
<Label class="text-sm font-medium">设备名称</Label>
|
<Label class="text-sm font-medium">设备名称</Label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-7"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="showEditNameDialog = true"
|
variant="ghost"
|
||||||
class="h-7"
|
@click="showEditNameDialog = true"
|
||||||
>
|
>
|
||||||
<Edit class="h-3 w-3 mr-1" />
|
<Edit class="h-3 w-3 mr-1"/>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -130,14 +132,14 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
<div class="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
<code class="flex-1 text-sm font-mono break-all">{{ deviceUuid }}</code>
|
<code class="flex-1 text-sm font-mono break-all">{{ deviceUuid }}</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-8 w-8 flex-shrink-0"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 flex-shrink-0"
|
title="复制 UUID"
|
||||||
@click="copyToClipboard(deviceUuid, 'uuid')"
|
variant="ghost"
|
||||||
title="复制 UUID"
|
@click="copyToClipboard(deviceUuid, 'uuid')"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="copied === 'uuid'" class="h-4 w-4 text-green-500" />
|
<CheckCircle2 v-if="copied === 'uuid'" class="h-4 w-4 text-green-500"/>
|
||||||
<Copy v-else class="h-4 w-4" />
|
<Copy v-else class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground mt-1">设备的唯一标识符,用于系统识别</p>
|
<p class="text-xs text-muted-foreground mt-1">设备的唯一标识符,用于系统识别</p>
|
||||||
@ -149,14 +151,14 @@ onMounted(async () => {
|
|||||||
<div class="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
<div class="flex items-center gap-2 p-3 rounded-lg bg-muted/50">
|
||||||
<code class="flex-1 text-sm font-mono break-all">{{ deviceInfo?.namespace || deviceUuid }}</code>
|
<code class="flex-1 text-sm font-mono break-all">{{ deviceInfo?.namespace || deviceUuid }}</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
class="h-8 w-8 flex-shrink-0"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 flex-shrink-0"
|
title="复制命名空间"
|
||||||
@click="copyToClipboard(deviceInfo?.namespace || deviceUuid, 'namespace')"
|
variant="ghost"
|
||||||
title="复制命名空间"
|
@click="copyToClipboard(deviceInfo?.namespace || deviceUuid, 'namespace')"
|
||||||
>
|
>
|
||||||
<CheckCircle2 v-if="copied === 'namespace'" class="h-4 w-4 text-green-500" />
|
<CheckCircle2 v-if="copied === 'namespace'" class="h-4 w-4 text-green-500"/>
|
||||||
<Copy v-else class="h-4 w-4" />
|
<Copy v-else class="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground mt-1">用于自动授权登录的设备标识</p>
|
<p class="text-xs text-muted-foreground mt-1">用于自动授权登录的设备标识</p>
|
||||||
@ -166,7 +168,6 @@ onMounted(async () => {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 设备管理部分 -->
|
<!-- 设备管理部分 -->
|
||||||
<h2 class="text-xl font-semibold mt-12 mb-4">设备管理</h2>
|
<h2 class="text-xl font-semibold mt-12 mb-4">设备管理</h2>
|
||||||
|
|
||||||
@ -174,7 +175,7 @@ onMounted(async () => {
|
|||||||
<Card class="mb-6 ">
|
<Card class="mb-6 ">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-lg flex items-center gap-2">
|
<CardTitle class="text-lg flex items-center gap-2">
|
||||||
<Smartphone class="h-5 w-5" />
|
<Smartphone class="h-5 w-5"/>
|
||||||
更换设备
|
更换设备
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
@ -185,11 +186,11 @@ onMounted(async () => {
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
class="w-full flex items-center justify-center gap-2"
|
||||||
class="w-full flex items-center justify-center gap-2"
|
variant="destructive"
|
||||||
@click="showResetDeviceDialog = true"
|
@click="showResetDeviceDialog = true"
|
||||||
>
|
>
|
||||||
<RefreshCw class="h-4 w-4" />
|
<RefreshCw class="h-4 w-4"/>
|
||||||
更换设备
|
更换设备
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -199,31 +200,26 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- 设备重置弹框 -->
|
<!-- 设备重置弹框 -->
|
||||||
<DeviceRegisterDialog
|
<DeviceRegisterDialog
|
||||||
v-model="showResetDeviceDialog"
|
v-model="showResetDeviceDialog"
|
||||||
@confirm="handleDeviceReset"
|
@confirm="handleDeviceReset"
|
||||||
@update:modelValue="val => showResetDeviceDialog = val"
|
@update:modelValue="val => showResetDeviceDialog = val"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 必需注册弹框 -->
|
<!-- 必需注册弹框 -->
|
||||||
<DeviceRegisterDialog
|
<DeviceRegisterDialog
|
||||||
v-model="showRegisterDialog"
|
v-model="showRegisterDialog"
|
||||||
@confirm="updateUuid"
|
:required="deviceRequired"
|
||||||
:required="deviceRequired"
|
@confirm="updateUuid"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 设备名称编辑弹框 -->
|
<!-- 设备名称编辑弹框 -->
|
||||||
<EditDeviceNameDialog
|
<EditDeviceNameDialog
|
||||||
v-model="showEditNameDialog"
|
v-model="showEditNameDialog"
|
||||||
:device-uuid="deviceUuid"
|
:current-name="deviceInfo?.deviceName || ''"
|
||||||
:current-name="deviceInfo?.deviceName || ''"
|
:device-uuid="deviceUuid"
|
||||||
@success="handleDeviceNameUpdated"
|
@success="handleDeviceNameUpdated"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -1,219 +1,220 @@
|
|||||||
import { defineStore } from 'pinia'
|
import {defineStore} from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
import {ref, computed} from 'vue'
|
||||||
import { apiClient } from '@/lib/api'
|
import {apiClient} from '@/lib/api'
|
||||||
import { setAuthHandlers } from '@/lib/axios'
|
import {setAuthHandlers} from '@/lib/axios'
|
||||||
|
|
||||||
export const useAccountStore = defineStore('account', () => {
|
export const useAccountStore = defineStore('account', () => {
|
||||||
// 状态
|
|
||||||
// 访问令牌(兼容旧 key: auth_token)
|
|
||||||
const token = ref(localStorage.getItem('auth_token') || localStorage.getItem('auth_access_token') || null)
|
|
||||||
const refreshToken = ref(localStorage.getItem('auth_refresh_token') || null)
|
|
||||||
const accessExpiresAt = ref(Number(localStorage.getItem('auth_access_exp') || 0) || 0) // ms 时间戳
|
|
||||||
const profile = ref(null)
|
|
||||||
const devices = ref([])
|
|
||||||
const loading = ref(false)
|
|
||||||
const providerName = ref(localStorage.getItem('auth_provider') || '')
|
|
||||||
const providerColor = ref(localStorage.getItem('auth_provider_color') || '')
|
|
||||||
let proactiveTimer = null
|
|
||||||
|
|
||||||
// 计算属性
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
|
||||||
const userName = computed(() => profile.value?.name || '')
|
|
||||||
const userAvatar = computed(() => profile.value?.avatarUrl || '')
|
|
||||||
const userId = computed(() => profile.value?.id || null)
|
|
||||||
const userProviderDisplay = computed(() => profile.value?.providerInfo?.displayName || profile.value?.providerInfo?.name || providerName.value || profile.value?.provider || '')
|
|
||||||
const userProviderColor = computed(() => profile.value?.providerInfo?.color || providerColor.value || '')
|
|
||||||
|
|
||||||
// 工具:解析 JWT 过期时间(秒时间戳),返回 ms
|
|
||||||
function decodeJwtExpMs(jwt) {
|
|
||||||
try {
|
|
||||||
const [, payload] = jwt.split('.')
|
|
||||||
if (!payload) return 0
|
|
||||||
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
|
|
||||||
if (!json?.exp) return 0
|
|
||||||
return Number(json.exp) * 1000
|
|
||||||
} catch {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearProactiveTimer() {
|
|
||||||
if (proactiveTimer) {
|
|
||||||
clearTimeout(proactiveTimer)
|
|
||||||
proactiveTimer = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleProactiveRefresh() {
|
|
||||||
clearProactiveTimer()
|
|
||||||
if (!token.value) return
|
|
||||||
const expMs = accessExpiresAt.value || decodeJwtExpMs(token.value)
|
|
||||||
if (!expMs) return
|
|
||||||
const now = Date.now()
|
|
||||||
// 提前 2 分钟刷新,最小 5 秒
|
|
||||||
const lead = 2 * 60 * 1000
|
|
||||||
let delay = expMs - now - lead
|
|
||||||
if (delay < 5000) delay = 5000
|
|
||||||
proactiveTimer = setTimeout(() => {
|
|
||||||
refreshNow().catch(() => {})
|
|
||||||
}, delay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方法
|
|
||||||
const setToken = (newToken) => {
|
|
||||||
token.value = newToken
|
|
||||||
if (newToken) {
|
|
||||||
localStorage.setItem('auth_token', newToken)
|
|
||||||
localStorage.setItem('auth_access_token', newToken)
|
|
||||||
accessExpiresAt.value = decodeJwtExpMs(newToken) || 0
|
|
||||||
if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value))
|
|
||||||
scheduleProactiveRefresh()
|
|
||||||
} else {
|
|
||||||
localStorage.removeItem('auth_token')
|
|
||||||
localStorage.removeItem('auth_access_token')
|
|
||||||
localStorage.removeItem('auth_access_exp')
|
|
||||||
localStorage.removeItem('auth_refresh_token')
|
|
||||||
localStorage.removeItem('auth_provider')
|
|
||||||
localStorage.removeItem('auth_provider_color')
|
|
||||||
providerName.value = ''
|
|
||||||
providerColor.value = ''
|
|
||||||
refreshToken.value = null
|
|
||||||
accessExpiresAt.value = 0
|
|
||||||
clearProactiveTimer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setTokens = (access, refresh) => {
|
|
||||||
if (access) setToken(access)
|
|
||||||
if (refresh) {
|
|
||||||
refreshToken.value = refresh
|
|
||||||
localStorage.setItem('auth_refresh_token', refresh)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadProfile = async () => {
|
|
||||||
if (!token.value) return
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const response = await apiClient.getAccountProfile()
|
|
||||||
profile.value = response.data
|
|
||||||
// 若后端返回 providerInfo,则回填前端展示字段
|
|
||||||
const p = profile.value?.providerInfo
|
|
||||||
if (p) {
|
|
||||||
providerName.value = p.displayName || p.name || profile.value?.provider || providerName.value
|
|
||||||
providerColor.value = p.color || providerColor.value
|
|
||||||
localStorage.setItem('auth_provider', providerName.value)
|
|
||||||
if (providerColor.value) localStorage.setItem('auth_provider_color', providerColor.value)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load profile:', error)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const loadDevices = async () => {
|
|
||||||
if (!token.value) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await apiClient.getAccountDevices()
|
|
||||||
devices.value = response.data || []
|
|
||||||
return devices.value
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load devices:', error)
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const bindDevice = async (deviceUuid) => {
|
|
||||||
if (!token.value) throw new Error('未登录')
|
|
||||||
|
|
||||||
const response = await apiClient.bindDevice(deviceUuid)
|
|
||||||
// 重新加载设备列表
|
|
||||||
await loadDevices()
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
const unbindDevice = async (deviceUuid) => {
|
|
||||||
if (!token.value) throw new Error('未登录')
|
|
||||||
|
|
||||||
const response = await apiClient.unbindDevice(deviceUuid)
|
|
||||||
// 重新加载设备列表
|
|
||||||
await loadDevices()
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (authToken) => {
|
|
||||||
setToken(authToken)
|
|
||||||
await loadProfile()
|
|
||||||
await loadDevices()
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
token.value = null
|
|
||||||
profile.value = null
|
|
||||||
devices.value = []
|
|
||||||
setToken(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 刷新访问令牌(按需刷新)
|
|
||||||
const refreshNow = async () => {
|
|
||||||
if (!refreshToken.value) throw new Error('No refresh token')
|
|
||||||
const res = await apiClient.refreshAccessToken(refreshToken.value)
|
|
||||||
// 期望结构:{ success, data: { access_token, expires_in, account? } }
|
|
||||||
const newAccess = res?.data?.access_token || res?.access_token || res?.token || null
|
|
||||||
if (!newAccess) throw new Error(res?.message || '刷新失败')
|
|
||||||
setToken(newAccess)
|
|
||||||
return newAccess
|
|
||||||
}
|
|
||||||
|
|
||||||
// 初始化注入 axios 认证处理
|
|
||||||
setAuthHandlers({
|
|
||||||
getAccessToken: () => token.value,
|
|
||||||
getRefreshToken: () => refreshToken.value,
|
|
||||||
setAccessToken: (t) => setToken(t),
|
|
||||||
refreshAccessToken: () => refreshNow(),
|
|
||||||
onAuthFailure: () => logout(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// 初始化:迁移旧存储并设置定时刷新
|
|
||||||
if (token.value) {
|
|
||||||
// 若未存储过期时间,尝试从JWT解析
|
|
||||||
if (!accessExpiresAt.value) {
|
|
||||||
accessExpiresAt.value = decodeJwtExpMs(token.value) || 0
|
|
||||||
if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value))
|
|
||||||
}
|
|
||||||
scheduleProactiveRefresh()
|
|
||||||
loadProfile()
|
|
||||||
loadDevices()
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 状态
|
// 状态
|
||||||
token,
|
// 访问令牌(兼容旧 key: auth_token)
|
||||||
refreshToken,
|
const token = ref(localStorage.getItem('auth_token') || localStorage.getItem('auth_access_token') || null)
|
||||||
profile,
|
const refreshToken = ref(localStorage.getItem('auth_refresh_token') || null)
|
||||||
devices,
|
const accessExpiresAt = ref(Number(localStorage.getItem('auth_access_exp') || 0) || 0) // ms 时间戳
|
||||||
loading,
|
const profile = ref(null)
|
||||||
providerName,
|
const devices = ref([])
|
||||||
providerColor,
|
const loading = ref(false)
|
||||||
|
const providerName = ref(localStorage.getItem('auth_provider') || '')
|
||||||
|
const providerColor = ref(localStorage.getItem('auth_provider_color') || '')
|
||||||
|
let proactiveTimer = null
|
||||||
|
|
||||||
// 计算属性
|
// 计算属性
|
||||||
isAuthenticated,
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
userName,
|
const userName = computed(() => profile.value?.name || '')
|
||||||
userAvatar,
|
const userAvatar = computed(() => profile.value?.avatarUrl || '')
|
||||||
userId,
|
const userId = computed(() => profile.value?.id || null)
|
||||||
userProviderDisplay,
|
const userProviderDisplay = computed(() => profile.value?.providerInfo?.displayName || profile.value?.providerInfo?.name || providerName.value || profile.value?.provider || '')
|
||||||
userProviderColor,
|
const userProviderColor = computed(() => profile.value?.providerInfo?.color || providerColor.value || '')
|
||||||
|
|
||||||
|
// 工具:解析 JWT 过期时间(秒时间戳),返回 ms
|
||||||
|
function decodeJwtExpMs(jwt) {
|
||||||
|
try {
|
||||||
|
const [, payload] = jwt.split('.')
|
||||||
|
if (!payload) return 0
|
||||||
|
const json = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')))
|
||||||
|
if (!json?.exp) return 0
|
||||||
|
return Number(json.exp) * 1000
|
||||||
|
} catch {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProactiveTimer() {
|
||||||
|
if (proactiveTimer) {
|
||||||
|
clearTimeout(proactiveTimer)
|
||||||
|
proactiveTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleProactiveRefresh() {
|
||||||
|
clearProactiveTimer()
|
||||||
|
if (!token.value) return
|
||||||
|
const expMs = accessExpiresAt.value || decodeJwtExpMs(token.value)
|
||||||
|
if (!expMs) return
|
||||||
|
const now = Date.now()
|
||||||
|
// 提前 2 分钟刷新,最小 5 秒
|
||||||
|
const lead = 2 * 60 * 1000
|
||||||
|
let delay = expMs - now - lead
|
||||||
|
if (delay < 5000) delay = 5000
|
||||||
|
proactiveTimer = setTimeout(() => {
|
||||||
|
refreshNow().catch(() => {
|
||||||
|
})
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
// 方法
|
// 方法
|
||||||
setToken,
|
const setToken = (newToken) => {
|
||||||
setTokens,
|
token.value = newToken
|
||||||
loadProfile,
|
if (newToken) {
|
||||||
loadDevices,
|
localStorage.setItem('auth_token', newToken)
|
||||||
bindDevice,
|
localStorage.setItem('auth_access_token', newToken)
|
||||||
unbindDevice,
|
accessExpiresAt.value = decodeJwtExpMs(newToken) || 0
|
||||||
login,
|
if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value))
|
||||||
logout,
|
scheduleProactiveRefresh()
|
||||||
refreshNow,
|
} else {
|
||||||
}
|
localStorage.removeItem('auth_token')
|
||||||
|
localStorage.removeItem('auth_access_token')
|
||||||
|
localStorage.removeItem('auth_access_exp')
|
||||||
|
localStorage.removeItem('auth_refresh_token')
|
||||||
|
localStorage.removeItem('auth_provider')
|
||||||
|
localStorage.removeItem('auth_provider_color')
|
||||||
|
providerName.value = ''
|
||||||
|
providerColor.value = ''
|
||||||
|
refreshToken.value = null
|
||||||
|
accessExpiresAt.value = 0
|
||||||
|
clearProactiveTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const setTokens = (access, refresh) => {
|
||||||
|
if (access) setToken(access)
|
||||||
|
if (refresh) {
|
||||||
|
refreshToken.value = refresh
|
||||||
|
localStorage.setItem('auth_refresh_token', refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadProfile = async () => {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getAccountProfile()
|
||||||
|
profile.value = response.data
|
||||||
|
// 若后端返回 providerInfo,则回填前端展示字段
|
||||||
|
const p = profile.value?.providerInfo
|
||||||
|
if (p) {
|
||||||
|
providerName.value = p.displayName || p.name || profile.value?.provider || providerName.value
|
||||||
|
providerColor.value = p.color || providerColor.value
|
||||||
|
localStorage.setItem('auth_provider', providerName.value)
|
||||||
|
if (providerColor.value) localStorage.setItem('auth_provider_color', providerColor.value)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load profile:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadDevices = async () => {
|
||||||
|
if (!token.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiClient.getAccountDevices()
|
||||||
|
devices.value = response.data || []
|
||||||
|
return devices.value
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load devices:', error)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindDevice = async (deviceUuid) => {
|
||||||
|
if (!token.value) throw new Error('未登录')
|
||||||
|
|
||||||
|
const response = await apiClient.bindDevice(deviceUuid)
|
||||||
|
// 重新加载设备列表
|
||||||
|
await loadDevices()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const unbindDevice = async (deviceUuid) => {
|
||||||
|
if (!token.value) throw new Error('未登录')
|
||||||
|
|
||||||
|
const response = await apiClient.unbindDevice(deviceUuid)
|
||||||
|
// 重新加载设备列表
|
||||||
|
await loadDevices()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (authToken) => {
|
||||||
|
setToken(authToken)
|
||||||
|
await loadProfile()
|
||||||
|
await loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
token.value = null
|
||||||
|
profile.value = null
|
||||||
|
devices.value = []
|
||||||
|
setToken(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新访问令牌(按需刷新)
|
||||||
|
const refreshNow = async () => {
|
||||||
|
if (!refreshToken.value) throw new Error('No refresh token')
|
||||||
|
const res = await apiClient.refreshAccessToken(refreshToken.value)
|
||||||
|
// 期望结构:{ success, data: { access_token, expires_in, account? } }
|
||||||
|
const newAccess = res?.data?.access_token || res?.access_token || res?.token || null
|
||||||
|
if (!newAccess) throw new Error(res?.message || '刷新失败')
|
||||||
|
setToken(newAccess)
|
||||||
|
return newAccess
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化注入 axios 认证处理
|
||||||
|
setAuthHandlers({
|
||||||
|
getAccessToken: () => token.value,
|
||||||
|
getRefreshToken: () => refreshToken.value,
|
||||||
|
setAccessToken: (t) => setToken(t),
|
||||||
|
refreshAccessToken: () => refreshNow(),
|
||||||
|
onAuthFailure: () => logout(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化:迁移旧存储并设置定时刷新
|
||||||
|
if (token.value) {
|
||||||
|
// 若未存储过期时间,尝试从JWT解析
|
||||||
|
if (!accessExpiresAt.value) {
|
||||||
|
accessExpiresAt.value = decodeJwtExpMs(token.value) || 0
|
||||||
|
if (accessExpiresAt.value) localStorage.setItem('auth_access_exp', String(accessExpiresAt.value))
|
||||||
|
}
|
||||||
|
scheduleProactiveRefresh()
|
||||||
|
loadProfile()
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
token,
|
||||||
|
refreshToken,
|
||||||
|
profile,
|
||||||
|
devices,
|
||||||
|
loading,
|
||||||
|
providerName,
|
||||||
|
providerColor,
|
||||||
|
// 计算属性
|
||||||
|
isAuthenticated,
|
||||||
|
userName,
|
||||||
|
userAvatar,
|
||||||
|
userId,
|
||||||
|
userProviderDisplay,
|
||||||
|
userProviderColor,
|
||||||
|
// 方法
|
||||||
|
setToken,
|
||||||
|
setTokens,
|
||||||
|
loadProfile,
|
||||||
|
loadDevices,
|
||||||
|
bindDevice,
|
||||||
|
unbindDevice,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
refreshNow,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
237
src/style.css
237
src/style.css
@ -1,146 +1,151 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
--color-card-foreground: var(--card-foreground);
|
--color-card-foreground: var(--card-foreground);
|
||||||
--color-popover: var(--popover);
|
--color-popover: var(--popover);
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
--color-primary: var(--primary);
|
--color-primary: var(--primary);
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
--color-secondary: var(--secondary);
|
--color-secondary: var(--secondary);
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
--color-muted: var(--muted);
|
--color-muted: var(--muted);
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
--color-accent: var(--accent);
|
--color-accent: var(--accent);
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
--color-destructive: var(--destructive);
|
--color-destructive: var(--destructive);
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
--color-border: var(--border);
|
--color-border: var(--border);
|
||||||
--color-input: var(--input);
|
--color-input: var(--input);
|
||||||
--color-ring: var(--ring);
|
--color-ring: var(--ring);
|
||||||
--color-chart-1: var(--chart-1);
|
--color-chart-1: var(--chart-1);
|
||||||
--color-chart-2: var(--chart-2);
|
--color-chart-2: var(--chart-2);
|
||||||
--color-chart-3: var(--chart-3);
|
--color-chart-3: var(--chart-3);
|
||||||
--color-chart-4: var(--chart-4);
|
--color-chart-4: var(--chart-4);
|
||||||
--color-chart-5: var(--chart-5);
|
--color-chart-5: var(--chart-5);
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(1 0 0);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.145 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(1 0 0);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.145 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(1 0 0);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.205 0 0);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.97 0 0);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.97 0 0);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.97 0 0);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.922 0 0);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.922 0 0);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.708 0 0);
|
||||||
--chart-1: oklch(0.646 0.222 41.116);
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
--chart-2: oklch(0.6 0.118 184.704);
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
--chart-3: oklch(0.398 0.07 227.392);
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
--chart-4: oklch(0.828 0.189 84.429);
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
--chart-5: oklch(0.769 0.188 70.08);
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.985 0 0);
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.145 0 0);
|
--card: oklch(0.145 0 0);
|
||||||
--card-foreground: oklch(0.985 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(0.145 0 0);
|
--popover: oklch(0.145 0 0);
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.985 0 0);
|
--primary: oklch(0.985 0 0);
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
--secondary: oklch(0.269 0 0);
|
--secondary: oklch(0.269 0 0);
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.269 0 0);
|
--muted: oklch(0.269 0 0);
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
--accent: oklch(0.269 0 0);
|
--accent: oklch(0.269 0 0);
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.396 0.141 25.723);
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
--destructive-foreground: oklch(0.637 0.237 25.331);
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
--border: oklch(0.269 0 0);
|
--border: oklch(0.269 0 0);
|
||||||
--input: oklch(0.269 0 0);
|
--input: oklch(0.269 0 0);
|
||||||
--ring: oklch(0.439 0 0);
|
--ring: oklch(0.439 0 0);
|
||||||
--chart-1: oklch(0.488 0.243 264.376);
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
--chart-2: oklch(0.696 0.17 162.48);
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: oklch(0.205 0 0);
|
--sidebar: oklch(0.205 0 0);
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(0.269 0 0);
|
--sidebar-border: oklch(0.269 0 0);
|
||||||
--sidebar-ring: oklch(0.439 0 0);
|
--sidebar-ring: oklch(0.439 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
html {
|
|
||||||
/* Reserve space for the vertical scrollbar to avoid layout shift/flicker */
|
html {
|
||||||
scrollbar-gutter: stable;
|
/* Reserve space for the vertical scrollbar to avoid layout shift/flicker */
|
||||||
}
|
scrollbar-gutter: stable;
|
||||||
body {
|
}
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Page transition animations */
|
/* Page transition animations */
|
||||||
.page-enter-active,
|
.page-enter-active,
|
||||||
.page-leave-active {
|
.page-leave-active {
|
||||||
transition: opacity 200ms ease, transform 200ms ease;
|
transition: opacity 200ms ease, transform 200ms ease;
|
||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-enter-from,
|
.page-enter-from,
|
||||||
.page-leave-to {
|
.page-leave-to {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(8px);
|
transform: translateY(8px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-enter-to,
|
.page-enter-to,
|
||||||
.page-leave-from {
|
.page-leave-from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,26 +1,26 @@
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { fileURLToPath, URL } from 'node:url'
|
import {fileURLToPath, URL} from 'node:url'
|
||||||
import tailwindcss from "@tailwindcss/vite"
|
import tailwindcss from "@tailwindcss/vite"
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import { defineConfig } from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import VueRouter from 'unplugin-vue-router/vite'
|
import VueRouter from 'unplugin-vue-router/vite'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
VueRouter({
|
VueRouter({
|
||||||
routesFolder: 'src/pages',
|
routesFolder: 'src/pages',
|
||||||
dts: false,
|
dts: false,
|
||||||
}),
|
}),
|
||||||
vue(),
|
vue(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
vueDevTools(),
|
vueDevTools(),
|
||||||
],
|
],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": fileURLToPath(new URL('./src', import.meta.url)),
|
"@": fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user