规范代码格式

This commit is contained in:
Sunwuyuan 2025-11-16 16:16:58 +08:00
parent 008d93e76c
commit 5fd99c2121
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
33 changed files with 2459 additions and 2337 deletions

View File

@ -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>

View File

@ -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

View File

@ -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: {
@ -196,8 +196,8 @@ fetchApp();
<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')"
/> />
@ -222,8 +222,8 @@ fetchApp();
<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')"
/> />
@ -245,33 +245,33 @@ fetchApp();
<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>

View File

@ -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: '未指定'},
] ]
// //
@ -160,10 +160,10 @@ const saveConfig = async () => {
</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,7 +175,7 @@ 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
@ -199,8 +199,8 @@ const saveConfig = async () => {
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
:disabled="isLoading"
type="button" type="button"
variant="outline" variant="outline"
@click="closeDialog" @click="closeDialog"
:disabled="isLoading"
> >
取消 取消
</Button> </Button>
<Button <Button
:disabled="isLoading"
type="button" type="button"
@click="saveConfig" @click="saveConfig"
:disabled="isLoading"
> >
<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>

View File

@ -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>
@ -109,19 +109,19 @@ const handleAuth = async () => {
<DialogFooter> <DialogFooter>
<Button <Button
v-if="closable" v-if="closable"
:disabled="isLoading"
type="button" type="button"
variant="outline" variant="outline"
@click="closeDialog" @click="closeDialog"
:disabled="isLoading"
> >
取消 取消
</Button> </Button>
<Button <Button
:disabled="isLoading"
type="button" type="button"
@click="handleAuth" @click="handleAuth"
:disabled="isLoading"
> >
<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>

View File

@ -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
} }
@ -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,7 +182,7 @@ 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(
@ -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,7 +319,7 @@ 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>
@ -343,8 +344,8 @@ const loadHistoryDevices = () => {
</div> </div>
</div> </div>
<Button <Button
variant="ghost"
size="sm" size="sm"
variant="ghost"
@click.stop="loadDevice(device)" @click.stop="loadDevice(device)"
> >
加载 加载
@ -355,7 +356,7 @@ const loadHistoryDevices = () => {
</div> </div>
</div> </div>
<Separator /> <Separator/>
<!-- 手动输入 UUID 加载 --> <!-- 手动输入 UUID 加载 -->
<div class="space-y-2"> <div class="space-y-2">
@ -364,11 +365,11 @@ const loadHistoryDevices = () => {
<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,7 +378,7 @@ 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">
@ -386,16 +387,16 @@ const loadHistoryDevices = () => {
<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"
@click="generateRandomUuid"
title="生成随机UUID" title="生成随机UUID"
variant="outline"
@click="generateRandomUuid"
> >
<Shuffle class="h-4 w-4" /> <Shuffle class="h-4 w-4"/>
</Button> </Button>
</div> </div>
</div> </div>
@ -411,7 +412,7 @@ const loadHistoryDevices = () => {
/> />
</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">
@ -422,8 +423,8 @@ const loadHistoryDevices = () => {
/> />
<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>
@ -439,22 +440,22 @@ const loadHistoryDevices = () => {
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<Button <Button
variant="outline"
@click="handleClose"
:disabled="props.required" :disabled="props.required"
:title="props.required ? '必须先注册设备' : '取消'" :title="props.required ? '必须先注册设备' : '取消'"
variant="outline"
@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>
@ -478,8 +479,8 @@ const loadHistoryDevices = () => {
</div> </div>
</div> </div>
<Button <Button
variant="ghost"
size="sm" size="sm"
variant="ghost"
@click.stop="loadDevice(device)" @click.stop="loadDevice(device)"
> >
加载 加载

View File

@ -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: {
@ -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>
@ -265,11 +265,11 @@ onMounted(() => {
<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,7 +312,7 @@ onMounted(() => {
</div> </div>
</DropdownItem> </DropdownItem>
<Separator class="my-1" /> <Separator class="my-1"/>
</div> </div>
</div> </div>
@ -320,32 +320,33 @@ onMounted(() => {
<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>
@ -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>

View File

@ -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('请输入设备名称')
@ -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>
@ -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>

View File

@ -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,
@ -97,10 +97,10 @@ const saveNamespace = async () => {
</Label> </Label>
<Input <Input
id="namespace" id="namespace"
type="text"
v-model="namespace" v-model="namespace"
placeholder="例如: class-2024-grade1"
autocomplete="off" autocomplete="off"
placeholder="例如: class-2024-grade1"
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
:disabled="isLoading"
type="button" type="button"
variant="outline" variant="outline"
@click="closeDialog" @click="closeDialog"
:disabled="isLoading"
> >
取消 取消
</Button> </Button>
<Button <Button
:disabled="isLoading"
type="button" type="button"
@click="saveNamespace" @click="saveNamespace"
:disabled="isLoading"
> >
<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>

View File

@ -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,
@ -76,9 +76,10 @@ const navigateTo = (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>

View File

@ -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 }>()

View File

@ -19,20 +19,20 @@
<button <button
v-for="provider in providers" v-for="provider in providers"
:key="provider.id" :key="provider.id"
@click="handleLogin(provider)"
class="w-full flex items-center gap-3 px-4 py-3 border rounded-lg transition-colors hover:bg-accent"
:style="{ :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,
@ -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
}) })
// //

View File

@ -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
@ -172,12 +170,12 @@ onMounted(() => {
<!-- 密码提示按钮 --> <!-- 密码提示按钮 -->
<button <button
v-if="showHint && passwordHint" v-if="showHint && passwordHint"
class="group relative"
type="button" type="button"
@click="showHintPopup = !showHintPopup" @click="showHintPopup = !showHintPopup"
class="group relative"
> >
<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>
@ -188,7 +186,7 @@ onMounted(() => {
> >
<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>
@ -204,14 +202,14 @@ onMounted(() => {
<div class="relative"> <div class="relative">
<Input <Input
:id="id" :id="id"
type="text"
:value="localValue"
@input="handleInput"
:placeholder="placeholder"
:disabled="disabled"
:class="{ :class="{
'border-red-500': !validationState.isValid && localValue 'border-red-500': !validationState.isValid && localValue
}" }"
:disabled="disabled"
:placeholder="placeholder"
:value="localValue"
type="text"
@input="handleInput"
/> />
<!-- 可见性切换按钮已移除 --> <!-- 可见性切换按钮已移除 -->
@ -222,13 +220,12 @@ onMounted(() => {
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
@ -236,7 +233,7 @@ onMounted(() => {
: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>

View File

@ -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>

View File

@ -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"
size="sm"
class="h-7 w-7 ml-auto"
@click="emit('copy', item)"
:title="props.copiedId === item.token ? '已复制' : '复制令牌'" :title="props.copiedId === item.token ? '已复制' : '复制令牌'"
class="h-7 w-7 ml-auto"
size="sm"
variant="ghost"
@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"
size="sm"
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10" class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
size="sm"
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>
@ -130,7 +140,7 @@ const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
<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>

View File

@ -1,7 +1,7 @@
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回调
@ -13,7 +13,7 @@ export function useOAuthCallback() {
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
@ -42,12 +42,20 @@ export function useOAuthCallback() {
}) })
// 清除URL参数 // 清除URL参数
router.replace({ query: {} }) router.replace({query: {}})
// 触发storage事件通知其他窗口 // 触发storage事件通知其他窗口
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_token', newValue: access_token, url: window.location.href })) window.dispatchEvent(new StorageEvent('storage', {
key: 'auth_token',
newValue: access_token,
url: window.location.href
}))
if (refresh_token) { if (refresh_token) {
window.dispatchEvent(new StorageEvent('storage', { key: 'auth_refresh_token', newValue: refresh_token, url: window.location.href })) window.dispatchEvent(new StorageEvent('storage', {
key: 'auth_refresh_token',
newValue: refresh_token,
url: window.location.href
}))
} }
// 如果是在新窗口中打开的OAuth回调自动关闭窗口 // 如果是在新窗口中打开的OAuth回调自动关闭窗口
@ -87,7 +95,7 @@ export function useOAuthCallback() {
}) })
// 清除URL参数 // 清除URL参数
router.replace({ query: {} }) router.replace({query: {}})
// 如果是在新窗口中打开的OAuth回调自动关闭窗口 // 如果是在新窗口中打开的OAuth回调自动关闭窗口
if (window.opener) { if (window.opener) {

View File

@ -11,7 +11,7 @@ class ApiClient {
async fetch(endpoint, options = {}) { async fetch(endpoint, options = {}) {
const method = options.method || 'GET' const method = options.method || 'GET'
const headers = { ...options.headers } const headers = {...options.headers}
const data = options.body const data = options.body
const params = options.params const params = options.params
@ -84,7 +84,7 @@ class ApiClient {
} }
async revokeToken(targetToken, authOptions = {}) { async revokeToken(targetToken, authOptions = {}) {
const { deviceUuid, usePathParam = true, bearerToken } = authOptions; const {deviceUuid, usePathParam = true, bearerToken} = authOptions;
if (usePathParam) { if (usePathParam) {
// 使用路径参数方式 (推荐) // 使用路径参数方式 (推荐)
@ -102,7 +102,7 @@ class ApiClient {
}); });
} else { } else {
// 使用查询参数方式 (向后兼容) // 使用查询参数方式 (向后兼容)
const params = new URLSearchParams({ token: targetToken }); const params = new URLSearchParams({token: targetToken});
const headers = {}; const headers = {};
if (bearerToken) { if (bearerToken) {
@ -120,7 +120,7 @@ class ApiClient {
// 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId) // 应用安装接口 (对应后端的 /apps/devices/:uuid/install/:appId)
async authorizeApp(appId, deviceUuid, options = {}) { async authorizeApp(appId, deviceUuid, options = {}) {
const { note, token } = options; const {note, token} = options;
const headers = { const headers = {
'x-device-uuid': deviceUuid, 'x-device-uuid': deviceUuid,
@ -134,13 +134,13 @@ class ApiClient {
return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}`, { return this.fetch(`/apps/devices/${deviceUuid}/install/${appId}`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ note: note || '应用授权' }), body: JSON.stringify({note: note || '应用授权'}),
}); });
} }
// 设备级别的应用卸载,使用新的 uninstall 接口 // 设备级别的应用卸载,使用新的 uninstall 接口
async revokeDeviceToken(deviceUuid, installId, token = null) { async revokeDeviceToken(deviceUuid, installId, token = null) {
const params = new URLSearchParams({ uuid: deviceUuid }); const params = new URLSearchParams({uuid: deviceUuid});
const headers = {}; const headers = {};
if (token) { if (token) {
@ -154,14 +154,11 @@ class ApiClient {
} }
// 设备授权相关 API // 设备授权相关 API
async bindDeviceCode(deviceCode, token) { async bindDeviceCode(deviceCode, token) {
return this.fetch('/auth/device/bind', { return this.fetch('/auth/device/bind', {
method: 'POST', method: 'POST',
body: JSON.stringify({ device_code: deviceCode, token }), body: JSON.stringify({device_code: deviceCode, token}),
}) })
} }
@ -173,20 +170,20 @@ class ApiClient {
async listKVItems(token, params = {}) { async listKVItems(token, params = {}) {
const query = new URLSearchParams(params).toString() const query = new URLSearchParams(params).toString()
return this.fetch(`/kv${query ? `?${query}` : ''}`, { return this.fetch(`/kv${query ? `?${query}` : ''}`, {
headers: { 'x-app-token': token } headers: {'x-app-token': token}
}) })
} }
async getKVItem(token, key) { async getKVItem(token, key) {
return this.fetch(`/kv/${encodeURIComponent(key)}`, { return this.fetch(`/kv/${encodeURIComponent(key)}`, {
headers: { 'x-app-token': token } headers: {'x-app-token': token}
}) })
} }
async setKVItem(token, key, value) { async setKVItem(token, key, value) {
return this.fetch(`/kv/${encodeURIComponent(key)}`, { return this.fetch(`/kv/${encodeURIComponent(key)}`, {
method: 'POST', method: 'POST',
headers: { 'x-app-token': token }, headers: {'x-app-token': token},
body: JSON.stringify(value), body: JSON.stringify(value),
}) })
} }
@ -194,13 +191,13 @@ class ApiClient {
async deleteKVItem(token, key) { async deleteKVItem(token, key) {
return this.fetch(`/kv/${encodeURIComponent(key)}`, { return this.fetch(`/kv/${encodeURIComponent(key)}`, {
method: 'DELETE', method: 'DELETE',
headers: { 'x-app-token': token } headers: {'x-app-token': token}
}) })
} }
async getKVKeys(token, pattern = '*') { async getKVKeys(token, pattern = '*') {
return this.fetch(`/kv/_keys?pattern=${encodeURIComponent(pattern)}`, { return this.fetch(`/kv/_keys?pattern=${encodeURIComponent(pattern)}`, {
headers: { 'x-app-token': token } headers: {'x-app-token': token}
}) })
} }
@ -215,7 +212,6 @@ class ApiClient {
} }
// 账户相关 APIAuthorization 由 axios 拦截器统一注入) // 账户相关 APIAuthorization 由 axios 拦截器统一注入)
async getOAuthProviders() { async getOAuthProviders() {
return this.fetch('/accounts/oauth/providers') return this.fetch('/accounts/oauth/providers')
@ -232,14 +228,14 @@ class ApiClient {
async bindDevice(deviceUuid) { async bindDevice(deviceUuid) {
return this.fetch('/accounts/devices/bind', { return this.fetch('/accounts/devices/bind', {
method: 'POST', method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }), body: JSON.stringify({uuid: deviceUuid}),
}) })
} }
async unbindDevice(deviceUuid) { async unbindDevice(deviceUuid) {
return this.fetch('/accounts/devices/unbind', { return this.fetch('/accounts/devices/unbind', {
method: 'POST', method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }), body: JSON.stringify({uuid: deviceUuid}),
}) })
} }
@ -252,14 +248,14 @@ class ApiClient {
return this.fetch('/accounts/refresh', { return this.fetch('/accounts/refresh', {
method: 'POST', method: 'POST',
// 刷新接口不应由请求拦截器附加旧的 Authorization // 刷新接口不应由请求拦截器附加旧的 Authorization
headers: { 'Content-Type': 'application/json' }, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ refresh_token: refreshToken }), body: JSON.stringify({refresh_token: refreshToken}),
}) })
} }
async getTokenInfo(accessToken) { async getTokenInfo(accessToken) {
return this.fetch('/accounts/token-info', { return this.fetch('/accounts/token-info', {
headers: { 'Authorization': `Bearer ${accessToken}` } headers: {'Authorization': `Bearer ${accessToken}`}
}) })
} }
@ -267,7 +263,7 @@ class ApiClient {
async bindDeviceToAccount(deviceUuid) { async bindDeviceToAccount(deviceUuid) {
return this.fetch('/accounts/devices/bind', { return this.fetch('/accounts/devices/bind', {
method: 'POST', method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }), body: JSON.stringify({uuid: deviceUuid}),
}) })
} }
@ -275,7 +271,7 @@ class ApiClient {
async unbindDeviceFromAccount(deviceUuid) { async unbindDeviceFromAccount(deviceUuid) {
return this.fetch('/accounts/devices/unbind', { return this.fetch('/accounts/devices/unbind', {
method: 'POST', method: 'POST',
body: JSON.stringify({ uuid: deviceUuid }), body: JSON.stringify({uuid: deviceUuid}),
}) })
} }
@ -283,7 +279,7 @@ class ApiClient {
async batchUnbindDevices(deviceUuids) { async batchUnbindDevices(deviceUuids) {
return this.fetch('/accounts/devices/unbind', { return this.fetch('/accounts/devices/unbind', {
method: 'POST', method: 'POST',
body: JSON.stringify({ uuids: deviceUuids }), body: JSON.stringify({uuids: deviceUuids}),
}) })
} }
@ -299,22 +295,20 @@ class ApiClient {
return this.fetch(`/devices/${deviceUuid}/name`, { return this.fetch(`/devices/${deviceUuid}/name`, {
method: 'PUT', method: 'PUT',
headers, headers,
body: JSON.stringify({ name }), body: JSON.stringify({name}),
}); });
} }
// 设备注册 API // 设备注册 API
async registerDevice(uuid, deviceName, token = null) { async registerDevice(uuid, deviceName, token = null) {
return this.authenticatedFetch('/devices', { return this.authenticatedFetch('/devices', {
method: 'POST', method: 'POST',
body: JSON.stringify({ uuid, deviceName }), body: JSON.stringify({uuid, deviceName}),
}, token) }, token)
} }
// 兼容性方法 - 保持旧的API调用方式 // 兼容性方法 - 保持旧的API调用方式
async getTokens(deviceUuid, options = {}) { async getTokens(deviceUuid, options = {}) {
return this.getDeviceTokens(deviceUuid, options); return this.getDeviceTokens(deviceUuid, options);
@ -322,7 +316,7 @@ class ApiClient {
async deleteToken(targetToken, deviceUuid = null) { async deleteToken(targetToken, deviceUuid = null) {
// 向后兼容的删除方法 // 向后兼容的删除方法
return this.revokeToken(targetToken, { deviceUuid, usePathParam: true }); return this.revokeToken(targetToken, {deviceUuid, usePathParam: true});
} }
// 便捷方法使用设备UUID删除token // 便捷方法使用设备UUID删除token
@ -369,12 +363,12 @@ class ApiClient {
break; break;
} }
return this.fetch(`/apps/tokens?${params}`, { headers }); return this.fetch(`/apps/tokens?${params}`, {headers});
} }
async revokeTokenWithAuth(targetToken, authType, authValue) { async revokeTokenWithAuth(targetToken, authType, authValue) {
const headers = {}; const headers = {};
const params = new URLSearchParams({ token: targetToken }); const params = new URLSearchParams({token: targetToken});
switch (authType) { switch (authType) {
case 'uuid': case 'uuid':
@ -432,7 +426,7 @@ class ApiClient {
async updateDeviceNamespace(deviceUuid, token, namespace) { async updateDeviceNamespace(deviceUuid, token, namespace) {
return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/namespace`, { return this.authenticatedFetch(`/auto-auth/devices/${deviceUuid}/namespace`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ namespace }), body: JSON.stringify({namespace}),
}, token); }, token);
} }
@ -440,7 +434,7 @@ class ApiClient {
async getTokenByNamespace(namespace, password, appId) { async getTokenByNamespace(namespace, password, appId) {
return this.fetch('/apps/auth/token', { return this.fetch('/apps/auth/token', {
method: 'POST', method: 'POST',
body: JSON.stringify({ namespace, password, appId }), body: JSON.stringify({namespace, password, appId}),
}); });
} }
@ -448,7 +442,7 @@ class ApiClient {
async setStudentName(token, name) { async setStudentName(token, name) {
return this.fetch(`/apps/tokens/${token}/set-student-name`, { return this.fetch(`/apps/tokens/${token}/set-student-name`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ name }), body: JSON.stringify({name}),
}); });
} }
} }

View File

@ -1,5 +1,5 @@
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 || ''
@ -21,16 +21,18 @@ let authHandlers = {
// 可选:返回刷新令牌 // 可选:返回刷新令牌
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 || {})}
} }
// 请求拦截器 // 请求拦截器
@ -111,16 +113,21 @@ async function ensureDeviceRegistered(uuid, authHeader) {
const p = axiosInstance.post( const p = axiosInstance.post(
'/devices', '/devices',
{ uuid, deviceName }, {uuid, deviceName},
{ headers, // 避免递归触发注册重试 {
headers, // 避免递归触发注册重试
skipDeviceRegistrationRetry: true, skipDeviceRegistrationRetry: true,
__isRegistrationRequest: true } __isRegistrationRequest: true
}
) )
registrationLocks.set(uuid, p) registrationLocks.set(uuid, p)
try { try {
await p await p
// 保存UUID到本地存储确保后续可用 // 保存UUID到本地存储确保后续可用
try { deviceStore.setDeviceUuid(uuid) } catch {} try {
deviceStore.setDeviceUuid(uuid)
} catch {
}
return true return true
} catch (e) { } catch (e) {
return false return false
@ -142,7 +149,8 @@ axiosInstance.interceptors.response.use(
if (newToken && authHandlers?.setAccessToken) { if (newToken && authHandlers?.setAccessToken) {
authHandlers.setAccessToken(newToken) authHandlers.setAccessToken(newToken)
} }
} catch {} } catch {
}
return response.data return response.data
}, },
async (error) => { async (error) => {
@ -159,7 +167,10 @@ axiosInstance.interceptors.response.use(
// 若没有刷新能力或没有刷新令牌,则直接走失败逻辑 // 若没有刷新能力或没有刷新令牌,则直接走失败逻辑
if (!authHandlers?.refreshAccessToken || !authHandlers?.getRefreshToken || !authHandlers.getRefreshToken()) { if (!authHandlers?.refreshAccessToken || !authHandlers?.getRefreshToken || !authHandlers.getRefreshToken()) {
// 无法刷新,触发认证失败回调并退出 // 无法刷新,触发认证失败回调并退出
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error('NO_REFRESH_TOKEN')) } catch {} try {
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error('NO_REFRESH_TOKEN'))
} catch {
}
throw new Error('NO_REFRESH_TOKEN') throw new Error('NO_REFRESH_TOKEN')
} }
@ -168,7 +179,10 @@ axiosInstance.interceptors.response.use(
refreshingPromise = authHandlers.refreshAccessToken() refreshingPromise = authHandlers.refreshAccessToken()
.catch((e) => { .catch((e) => {
// 刷新失败,触发失败处理 // 刷新失败,触发失败处理
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(e) } catch {} try {
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(e)
} catch {
}
throw e throw e
}) })
.finally(() => { .finally(() => {
@ -183,14 +197,20 @@ axiosInstance.interceptors.response.use(
return await axiosInstance.request(config) return await axiosInstance.request(config)
} catch (refreshErr) { } catch (refreshErr) {
// 刷新失败,触发认证失败并返回原始错误信息 // 刷新失败,触发认证失败并返回原始错误信息
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(refreshErr) } catch {} try {
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(refreshErr)
} catch {
}
return Promise.reject(new Error(message)) return Promise.reject(new Error(message))
} }
} }
// 明确的权限问题同样触发登出(例如服务端使用 403 表示 Token 无效或权限已失效) // 明确的权限问题同样触发登出(例如服务端使用 403 表示 Token 无效或权限已失效)
if (status === 403&& resp?.data?.code === 'AUTH_JWT_EXPIRED') { if (status === 403 && resp?.data?.code === 'AUTH_JWT_EXPIRED') {
try { authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error(message || 'FORBIDDEN')) } catch {} try {
authHandlers?.onAuthFailure && authHandlers.onAuthFailure(new Error(message || 'FORBIDDEN'))
} catch {
}
return Promise.reject(new Error(message || 'FORBIDDEN')) return Promise.reject(new Error(message || 'FORBIDDEN'))
} }
@ -204,7 +224,8 @@ axiosInstance.interceptors.response.use(
try { try {
const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data const body = typeof config.data === 'string' ? JSON.parse(config.data) : config.data
if (body && typeof body === 'object' && body.uuid) uuid = body.uuid if (body && typeof body === 'object' && body.uuid) uuid = body.uuid
} catch {} } catch {
}
} }
// 可能需要账户授权头 // 可能需要账户授权头

View File

@ -1,6 +1,6 @@
// 生成 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)
@ -131,7 +131,7 @@ export const deviceStore = {
const db = await this.openDB() const db = await this.openDB()
const transaction = db.transaction(['device'], 'readwrite') const transaction = db.transaction(['device'], 'readwrite')
const store = transaction.objectStore('device') const store = transaction.objectStore('device')
await store.put({ id: 'uuid', value: uuid }) await store.put({id: 'uuid', value: uuid})
} catch (e) { } catch (e) {
console.log('Failed to save UUID to IndexedDB:', e) console.log('Failed to save UUID to IndexedDB:', e)
} }
@ -171,7 +171,7 @@ export const deviceStore = {
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = event.target.result const db = event.target.result
if (!db.objectStoreNames.contains('device')) { if (!db.objectStoreNames.contains('device')) {
db.createObjectStore('device', { keyPath: 'id' }) db.createObjectStore('device', {keyPath: 'id'})
} }
} }
}) })
@ -219,7 +219,7 @@ deviceStore.addDeviceToHistory = function (device) {
} }
if (idx >= 0) { if (idx >= 0) {
// 更新名称和时间 // 更新名称和时间
list[idx] = { ...list[idx], ...entry } list[idx] = {...list[idx], ...entry}
} else { } else {
list.unshift(entry) list.unshift(entry)
} }

View File

@ -47,7 +47,7 @@ export const tokenStore = {
// 更新 token // 更新 token
updateToken(id, updates) { updateToken(id, updates) {
const tokens = this.getTokens().map(t => const tokens = this.getTokens().map(t =>
t.id === id ? { ...t, ...updates } : t t.id === id ? {...t, ...updates} : t
) )
localStorage.setItem('kv_tokens', JSON.stringify(tokens)) localStorage.setItem('kv_tokens', JSON.stringify(tokens))
}, },

View File

@ -1,5 +1,5 @@
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));

View File

@ -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'
@ -33,7 +33,7 @@ router.beforeEach((to, _from, next) => {
const activeToken = tokenStore.getActiveToken() const activeToken = tokenStore.getActiveToken()
if (requiresAuth && !activeToken) { if (requiresAuth && !activeToken) {
next({ path: '/' }) next({path: '/'})
} else { } else {
next() next()
} }

View File

@ -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()
@ -39,7 +39,7 @@ onMounted(() => {
<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>

View File

@ -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,7 +269,7 @@ 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">
@ -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,7 +295,6 @@ 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">
@ -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>
@ -337,8 +336,8 @@ onMounted(async () => {
<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>
@ -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>

View File

@ -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'
@ -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>
@ -259,12 +259,12 @@ onMounted(async () => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
v-if="isAuthenticated" v-if="isAuthenticated"
variant="outline"
size="icon"
@click="loadConfigs"
:disabled="isLoading" :disabled="isLoading"
size="icon"
variant="outline"
@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"
size="icon"
class="h-6 w-6" class="h-6 w-6"
@click="editNamespace" size="icon"
title="编辑命名空间" title="编辑命名空间"
variant="ghost"
@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>
@ -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"
size="icon"
class="h-7 w-7" class="h-7 w-7"
size="icon"
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"
size="sm"
@click="editConfig(config)"
class="flex-1" class="flex-1"
size="sm"
variant="outline"
@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"
size="sm"
@click="confirmDelete(config)"
class="flex-1" class="flex-1"
size="sm"
variant="destructive"
@click="confirmDelete(config)"
> >
<Trash2 class="h-3 w-3 mr-1" /> <Trash2 class="h-3 w-3 mr-1"/>
删除 删除
</Button> </Button>
</div> </div>
@ -467,9 +467,9 @@ onMounted(async () => {
<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"
/> />
@ -477,9 +477,9 @@ onMounted(async () => {
<EditNamespaceDialog <EditNamespaceDialog
v-if="isAuthenticated && deviceInfo" v-if="isAuthenticated && deviceInfo"
v-model="showNamespaceDialog" v-model="showNamespaceDialog"
:device-uuid="deviceUuid"
:current-namespace="deviceInfo.namespace"
:account-token="accountStore.token" :account-token="accountStore.token"
:current-namespace="deviceInfo.namespace"
: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>

View File

@ -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()
@ -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>
@ -273,20 +273,20 @@ const goBack = () => {
<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
class="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
size="icon"
tabindex="-1"
type="button" type="button"
variant="ghost" variant="ghost"
size="icon"
class="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
@click="tab1ShowPassword = !tab1ShowPassword" @click="tab1ShowPassword = !tab1ShowPassword"
tabindex="-1"
> >
<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>
@ -301,12 +301,12 @@ const goBack = () => {
</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>
@ -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>
@ -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>
@ -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>
@ -436,18 +436,18 @@ const goBack = () => {
<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>

View File

@ -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"
size="icon"
@click="loadDevices"
:disabled="isLoading" :disabled="isLoading"
size="icon"
variant="outline"
@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>
@ -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"
size="sm"
@click="editDeviceName(device)"
class="flex-1" class="flex-1"
size="sm"
variant="outline"
@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"
size="sm"
@click="confirmUnbind(device)"
class="w-full" class="w-full"
size="sm"
variant="destructive"
@click="confirmUnbind(device)"
> >
<Trash2 class="h-3 w-3 mr-1" /> <Trash2 class="h-3 w-3 mr-1"/>
解绑设备 解绑设备
</Button> </Button>
</CardContent> </CardContent>
@ -218,14 +218,13 @@ 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>

View File

@ -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
@ -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')
} }
@ -347,7 +364,6 @@ onMounted(async () => {
await loadDeviceAccount() await loadDeviceAccount()
// tokens // tokens
await loadTokens() await loadTokens()
} }
@ -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"
size="sm"
class="flex items-center gap-2" class="flex items-center gap-2"
size="sm"
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"
size="icon"
@click="loadTokens"
:disabled="isLoading" :disabled="isLoading"
size="icon"
variant="outline"
@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">
建议自定义命名空间 建议自定义命名空间
@ -481,12 +497,12 @@ onMounted(async () => {
</p> </p>
<Button <Button
v-if="accountStore.isAuthenticated" v-if="accountStore.isAuthenticated"
variant="outline"
size="sm"
@click="showEditNamespaceDialog = true"
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" 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"
variant="outline"
@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">
@ -509,12 +525,12 @@ onMounted(async () => {
</CardTitle> </CardTitle>
<Button <Button
v-if="accountStore.isAuthenticated" v-if="accountStore.isAuthenticated"
variant="ghost"
size="sm"
@click="showEditNameDialog = true"
class="h-6 w-6 p-0" class="h-6 w-6 p-0"
size="sm"
variant="ghost"
@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')"
variant="outline"
size="sm" size="sm"
variant="outline"
@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"
size="sm"
@click="showEditNamespaceDialog = true"
class="h-7" class="h-7"
size="sm"
variant="ghost"
@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"
size="icon"
class="h-8 w-8" class="h-8 w-8"
@click="copyToClipboard(deviceInfo?.namespace || deviceUuid, 'namespace')" size="icon"
title="复制命名空间" title="复制命名空间"
variant="ghost"
@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>
@ -607,26 +624,26 @@ 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>
@ -637,9 +654,10 @@ onMounted(async () => {
: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
:copied-id="copied"
:items="group.tokens.map(t => ({ :items="group.tokens.map(t => ({
id: t.id, id: t.id,
token: t.token, token: t.token,
@ -649,13 +667,12 @@ onMounted(async () => {
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)"
@revoke="confirmRevoke"
@open="(item) => { selectedToken = item; showTokenDialog = true }" @open="(item) => { selectedToken = item; showTokenDialog = true }"
@revoke="confirmRevoke"
/> />
</div> </div>
@ -663,7 +680,7 @@ onMounted(async () => {
<!-- 功能导航 --> <!-- 功能导航 -->
<div class="mt-12"> <div class="mt-12">
<FeatureNavigation /> <FeatureNavigation/>
</div> </div>
@ -767,29 +784,30 @@ 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"
size="sm"
class="h-7 w-7 ml-auto" class="h-7 w-7 ml-auto"
size="sm"
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>
<!-- 登录弹框 --> <!-- 登录弹框 -->
@ -804,15 +822,15 @@ onMounted(async () => {
/> <!-- --> /> <!-- -->
<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"
/> />
@ -820,9 +838,9 @@ onMounted(async () => {
<EditNamespaceDialog <EditNamespaceDialog
v-if="accountStore.isAuthenticated && deviceInfo" v-if="accountStore.isAuthenticated && deviceInfo"
v-model="showEditNamespaceDialog" v-model="showEditNamespaceDialog"
:device-uuid="deviceUuid"
:current-namespace="deviceInfo.namespace"
:account-token="accountStore.token" :account-token="accountStore.token"
:current-namespace="deviceInfo.namespace"
:device-uuid="deviceUuid"
@success="handleNamespaceUpdated" @success="handleNamespaceUpdated"
/> />
</div> </div>

View File

@ -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') || '')
@ -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>

View File

@ -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"
size="sm"
@click="showEditNameDialog = true"
class="h-7" class="h-7"
size="sm"
variant="ghost"
@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"
size="icon"
class="h-8 w-8 flex-shrink-0" class="h-8 w-8 flex-shrink-0"
@click="copyToClipboard(deviceUuid, 'uuid')" size="icon"
title="复制 UUID" title="复制 UUID"
variant="ghost"
@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"
size="icon"
class="h-8 w-8 flex-shrink-0" class="h-8 w-8 flex-shrink-0"
@click="copyToClipboard(deviceInfo?.namespace || deviceUuid, 'namespace')" size="icon"
title="复制命名空间" title="复制命名空间"
variant="ghost"
@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,11 +200,6 @@ onMounted(async () => {
</div> </div>
<!-- 设备重置弹框 --> <!-- 设备重置弹框 -->
<DeviceRegisterDialog <DeviceRegisterDialog
v-model="showResetDeviceDialog" v-model="showResetDeviceDialog"
@ -214,15 +210,15 @@ onMounted(async () => {
<!-- 必需注册弹框 --> <!-- 必需注册弹框 -->
<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>

View File

@ -1,7 +1,7 @@
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', () => {
// 状态 // 状态
@ -55,7 +55,8 @@ export const useAccountStore = defineStore('account', () => {
let delay = expMs - now - lead let delay = expMs - now - lead
if (delay < 5000) delay = 5000 if (delay < 5000) delay = 5000
proactiveTimer = setTimeout(() => { proactiveTimer = setTimeout(() => {
refreshNow().catch(() => {}) refreshNow().catch(() => {
})
}, delay) }, delay)
} }

View File

@ -1,4 +1,5 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/typography";
@import "tw-animate-css"; @import "tw-animate-css";
@ -118,10 +119,12 @@
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
html { html {
/* Reserve space for the vertical scrollbar to avoid layout shift/flicker */ /* Reserve space for the vertical scrollbar to avoid layout shift/flicker */
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
@ -134,11 +137,13 @@
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;

View File

@ -1,8 +1,8 @@
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'