mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-10-22 20:43:09 +00:00
Compare commits
12 Commits
ef29982de7
...
473ffc2f50
Author | SHA1 | Date | |
---|---|---|---|
![]() |
473ffc2f50 | ||
![]() |
29daa623cc | ||
![]() |
3f02c06ddb | ||
![]() |
0e561f8ed2 | ||
![]() |
2f9121dc2a | ||
![]() |
7b0e1610d5 | ||
![]() |
70fb93d8d7 | ||
![]() |
be54e877d5 | ||
![]() |
6f6a372abd | ||
![]() |
9388280fd5 | ||
![]() |
0bc579f014 | ||
![]() |
ba42950105 |
40
index.html
40
index.html
@ -4,6 +4,46 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<script>
|
||||||
|
// 在 CSS 加载前尽早应用系统深色模式,避免首次渲染闪烁
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var root = document.documentElement;
|
||||||
|
var storageKey = 'theme';
|
||||||
|
var hasLocal = false;
|
||||||
|
try {
|
||||||
|
hasLocal = localStorage.getItem(storageKey) != null;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
var prefersDark = false;
|
||||||
|
try {
|
||||||
|
prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
var initialDark = hasLocal
|
||||||
|
? localStorage.getItem(storageKey) === 'dark'
|
||||||
|
: prefersDark;
|
||||||
|
|
||||||
|
root.classList.toggle('dark', !!initialDark);
|
||||||
|
|
||||||
|
// 跟随系统主题变化(若未手动设置主题)
|
||||||
|
var mq;
|
||||||
|
try {
|
||||||
|
mq = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
} catch (_) {}
|
||||||
|
if (mq) {
|
||||||
|
var onChange = function (e) {
|
||||||
|
var locked = false;
|
||||||
|
try { locked = localStorage.getItem(storageKey) != null; } catch (_) {}
|
||||||
|
if (!locked) root.classList.toggle('dark', e.matches);
|
||||||
|
};
|
||||||
|
if (mq.addEventListener) mq.addEventListener('change', onChange);
|
||||||
|
else if (mq.addListener) mq.addListener(onChange);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<title>Classworks KV</title>
|
<title>Classworks KV</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
6010
package-lock.json
generated
Normal file
6010
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,66 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<title>404 - 页面未找到</title>
|
|
||||||
<meta name="robots" content="noindex" />
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: light dark; }
|
|
||||||
html, body { height: 100%; }
|
|
||||||
body {
|
|
||||||
margin: 0; display: grid; place-items: center; font: 14px/1.6 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
|
|
||||||
background: linear-gradient(180deg, rgba(0,0,0,.02), rgba(0,0,0,.05));
|
|
||||||
}
|
|
||||||
.card {
|
|
||||||
width: min(560px, 92vw);
|
|
||||||
border: 1px solid rgba(125,125,125,.25);
|
|
||||||
border-radius: 14px;
|
|
||||||
background: rgba(255,255,255,.7);
|
|
||||||
-webkit-backdrop-filter: blur(6px);
|
|
||||||
backdrop-filter: blur(6px);
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: 0 10px 25px rgba(0,0,0,.08);
|
|
||||||
color: #1f2937;
|
|
||||||
}
|
|
||||||
.row { display:flex; justify-content:center; align-items:center; gap:10px; }
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.card { background: rgba(24,24,27,.6); color: #e5e7eb; border-color: rgba(255,255,255,.12); }
|
|
||||||
}
|
|
||||||
.title { font-size: 22px; font-weight: 700; margin: 8px 0 4px; }
|
|
||||||
.desc { color: #6b7280; margin: 0 0 8px; }
|
|
||||||
.hint { font-size: 12px; color: #9ca3af; text-align: center; margin-top: 10px; }
|
|
||||||
.actions { display: flex; justify-content: center; gap: 10px; margin-top: 16px; }
|
|
||||||
.btn {
|
|
||||||
appearance: none; border: 1px solid rgba(125,125,125,.35); background: rgba(255,255,255,.9);
|
|
||||||
color: #111827; padding: 10px 16px; border-radius: 8px; cursor: pointer; font-weight: 600;
|
|
||||||
}
|
|
||||||
.btn.primary { background: #111827; color: white; border-color: #111827; }
|
|
||||||
.btn:active { transform: translateY(1px); }
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.btn { background: rgba(63,63,70,.8); color: #e5e7eb; border-color: rgba(255,255,255,.14); }
|
|
||||||
.btn.primary { background: #3b82f6; border-color: #3b82f6; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="card">
|
|
||||||
<div class="row">
|
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" x2="12" y1="8" y2="12"></line><line x1="12" x2="12.01" y1="16" y2="16"></line></svg>
|
|
||||||
<div>
|
|
||||||
<div class="title">页面未找到</div>
|
|
||||||
<div class="desc">即将为您跳转到首页。如果未跳转,请使用下面的按钮。</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="actions">
|
|
||||||
<button class="btn primary" onclick="location.assign('/')">返回首页</button>
|
|
||||||
<button class="btn" onclick="history.length>1?history.back():location.assign('/')">返回上一页</button>
|
|
||||||
</div>
|
|
||||||
<p class="hint">错误代码:404</p>
|
|
||||||
</main>
|
|
||||||
<script>
|
|
||||||
// 对于静态托管(如 GitHub Pages、Vercel 静态导出),尝试回退到 SPA 入口
|
|
||||||
setTimeout(function(){location.replace('/')}, 1500)
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -5,6 +5,10 @@ import 'vue-sonner/style.css'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView v-slot="{ Component, route }">
|
||||||
|
<Transition name="page" mode="out-in">
|
||||||
|
<component :is="Component" :key="route.fullPath" />
|
||||||
|
</Transition>
|
||||||
|
</RouterView>
|
||||||
<Toaster class="pointer-events-auto" />
|
<Toaster class="pointer-events-auto" />
|
||||||
</template>
|
</template>
|
||||||
|
@ -33,8 +33,10 @@ const newUuid = ref('')
|
|||||||
const deviceName = ref('')
|
const deviceName = ref('')
|
||||||
const bindToAccount = ref(false)
|
const bindToAccount = ref(false)
|
||||||
const accountDevices = ref([])
|
const accountDevices = ref([])
|
||||||
|
const historyDevices = ref([])
|
||||||
|
const manualUuid = ref('')
|
||||||
const loadingDevices = ref(false)
|
const loadingDevices = ref(false)
|
||||||
const activeTab = ref('load') // 'load' 或 'register'
|
const activeTab = ref('load') // 'load' | 'history' | 'register'
|
||||||
const showLoginDialog = ref(false) // 登录对话框状态
|
const showLoginDialog = ref(false) // 登录对话框状态
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
@ -47,6 +49,9 @@ watch(isOpen, (newVal) => {
|
|||||||
if (newVal && accountStore.isAuthenticated && activeTab.value === 'load') {
|
if (newVal && accountStore.isAuthenticated && activeTab.value === 'load') {
|
||||||
loadAccountDevices()
|
loadAccountDevices()
|
||||||
}
|
}
|
||||||
|
if (newVal && activeTab.value === 'history') {
|
||||||
|
loadHistoryDevices()
|
||||||
|
}
|
||||||
// 切换到注册选项卡时,自动生成UUID
|
// 切换到注册选项卡时,自动生成UUID
|
||||||
if (newVal && activeTab.value === 'register' && !newUuid.value) {
|
if (newVal && activeTab.value === 'register' && !newUuid.value) {
|
||||||
generateRandomUuid()
|
generateRandomUuid()
|
||||||
@ -58,6 +63,9 @@ watch(activeTab, (newVal) => {
|
|||||||
if (newVal === 'load' && accountStore.isAuthenticated && isOpen.value) {
|
if (newVal === 'load' && accountStore.isAuthenticated && isOpen.value) {
|
||||||
loadAccountDevices()
|
loadAccountDevices()
|
||||||
}
|
}
|
||||||
|
if (newVal === 'history' && isOpen.value) {
|
||||||
|
loadHistoryDevices()
|
||||||
|
}
|
||||||
if (newVal === 'register' && !newUuid.value) {
|
if (newVal === 'register' && !newUuid.value) {
|
||||||
generateRandomUuid()
|
generateRandomUuid()
|
||||||
}
|
}
|
||||||
@ -121,12 +129,35 @@ 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 })
|
||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
emit('confirm')
|
emit('confirm')
|
||||||
resetForm()
|
resetForm()
|
||||||
toast.success(`已切换到设备: ${device.name || device.uuid}`)
|
toast.success(`已切换到设备: ${device.name || device.uuid}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 手动输入UUID加载
|
||||||
|
const loadByUuid = () => {
|
||||||
|
const id = manualUuid.value?.trim()
|
||||||
|
if (!id) {
|
||||||
|
toast.error('请输入设备 UUID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 可选:基本格式校验(宽松处理,避免误判合法UUID)
|
||||||
|
const ok = /^[0-9a-fA-F-]{8,}$/.test(id)
|
||||||
|
if (!ok) {
|
||||||
|
toast.error('UUID 格式不正确')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deviceStore.setDeviceUuid(id)
|
||||||
|
deviceStore.addDeviceToHistory({ uuid: id })
|
||||||
|
isOpen.value = false
|
||||||
|
emit('confirm')
|
||||||
|
resetForm()
|
||||||
|
toast.success(`已切换到设备: ${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
// 注册新设备
|
// 注册新设备
|
||||||
const registerDevice = async () => {
|
const registerDevice = async () => {
|
||||||
if (!newUuid.value.trim()) {
|
if (!newUuid.value.trim()) {
|
||||||
@ -142,6 +173,8 @@ const registerDevice = async () => {
|
|||||||
try {
|
try {
|
||||||
// 1. 保存UUID到本地
|
// 1. 保存UUID到本地
|
||||||
deviceStore.setDeviceUuid(newUuid.value.trim())
|
deviceStore.setDeviceUuid(newUuid.value.trim())
|
||||||
|
// 写入历史
|
||||||
|
deviceStore.addDeviceToHistory({ uuid: newUuid.value.trim(), name: deviceName.value.trim() })
|
||||||
|
|
||||||
// 2. 调用设备注册接口(会自动在云端创建设备)
|
// 2. 调用设备注册接口(会自动在云端创建设备)
|
||||||
await apiClient.registerDevice(
|
await apiClient.registerDevice(
|
||||||
@ -181,6 +214,7 @@ const resetForm = () => {
|
|||||||
bindToAccount.value = accountStore.isAuthenticated
|
bindToAccount.value = accountStore.isAuthenticated
|
||||||
accountDevices.value = []
|
accountDevices.value = []
|
||||||
activeTab.value = 'load'
|
activeTab.value = 'load'
|
||||||
|
manualUuid.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理弹框关闭
|
// 处理弹框关闭
|
||||||
@ -211,6 +245,11 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown, true)
|
document.removeEventListener('keydown', handleKeydown, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 加载本地历史设备
|
||||||
|
const loadHistoryDevices = () => {
|
||||||
|
historyDevices.value = deviceStore.getDeviceHistory()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -239,11 +278,14 @@ onUnmounted(() => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="w-full">
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-2">
|
<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>
|
||||||
<TabsTrigger value="register">
|
<TabsTrigger value="register">
|
||||||
<Plus class="h-4 w-4 mr-2" />
|
<Plus class="h-4 w-4 mr-2" />
|
||||||
注册设备
|
注册设备
|
||||||
@ -252,54 +294,79 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 加载设备选项卡 -->
|
<!-- 加载设备选项卡 -->
|
||||||
<TabsContent value="load" class="space-y-4 mt-4">
|
<TabsContent value="load" class="space-y-4 mt-4">
|
||||||
<div v-if="!accountStore.isAuthenticated" class="text-center py-8">
|
<!-- 账户设备区域 -->
|
||||||
<p class="text-muted-foreground mb-4">请先登录以查看您的设备列表</p>
|
<div class="space-y-3">
|
||||||
<Button variant="outline" @click="handleOpenLogin">
|
<div v-if="!accountStore.isAuthenticated" class="text-center py-6">
|
||||||
登录账户
|
<p class="text-muted-foreground mb-3">登录后可查看您账户绑定的设备</p>
|
||||||
</Button>
|
<Button variant="outline" @click="handleOpenLogin">
|
||||||
</div>
|
登录账户
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="loadingDevices" class="text-center py-8">
|
<div v-else>
|
||||||
<p class="text-muted-foreground">加载中...</p>
|
<div v-if="loadingDevices" class="text-center py-6">
|
||||||
</div>
|
<p class="text-muted-foreground">加载中...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else-if="accountDevices.length === 0" class="text-center py-8">
|
<div v-else-if="accountDevices.length === 0" class="text-center py-6">
|
||||||
<p class="text-muted-foreground mb-4">您的账户暂未绑定任何设备</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
<div
|
|
||||||
v-for="device in accountDevices"
|
|
||||||
:key="device.uuid"
|
|
||||||
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
|
||||||
@click="loadDevice(device)"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-medium text-base">
|
|
||||||
{{ device.name || '未命名设备' }}
|
|
||||||
</div>
|
|
||||||
<code class="text-xs text-muted-foreground block mt-1">
|
|
||||||
{{ device.uuid }}
|
|
||||||
</code>
|
|
||||||
<div class="text-xs text-muted-foreground mt-2">
|
|
||||||
创建时间: {{ new Date(device.createdAt).toLocaleString('zh-CN') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
@click.stop="loadDevice(device)"
|
|
||||||
>
|
|
||||||
加载
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="device in accountDevices"
|
||||||
|
:key="device.uuid"
|
||||||
|
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
||||||
|
@click="loadDevice(device)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-base">
|
||||||
|
{{ device.name || '未命名设备' }}
|
||||||
|
</div>
|
||||||
|
<code class="text-xs text-muted-foreground block mt-1">
|
||||||
|
{{ device.uuid }}
|
||||||
|
</code>
|
||||||
|
<div class="text-xs text-muted-foreground mt-2">
|
||||||
|
创建时间: {{ new Date(device.createdAt).toLocaleString('zh-CN') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="loadDevice(device)"
|
||||||
|
>
|
||||||
|
加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- 手动输入 UUID 加载 -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label for="manualUuid">手动输入 UUID</Label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="manualUuid"
|
||||||
|
v-model="manualUuid"
|
||||||
|
placeholder="输入设备 UUID 直接加载"
|
||||||
|
class="flex-1"
|
||||||
|
@keyup.enter="loadByUuid"
|
||||||
|
/>
|
||||||
|
<Button @click="loadByUuid" :disabled="!manualUuid.trim()">
|
||||||
|
加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted-foreground">无需注册或登录即可加载已有设备。</p>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- 注册设备选项卡 -->
|
<!-- 注册设备选项卡 -->
|
||||||
@ -328,7 +395,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- 设备名称输入 -->
|
<!-- 设备名称输入 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="deviceName">设备名称</Label>
|
<Label for="deviceName">* 设备名称</Label>
|
||||||
<Input
|
<Input
|
||||||
id="deviceName"
|
id="deviceName"
|
||||||
v-model="deviceName"
|
v-model="deviceName"
|
||||||
@ -361,16 +428,6 @@ onUnmounted(() => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 提示信息 -->
|
|
||||||
<div class="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-3">
|
|
||||||
<p><strong>提示:</strong></p>
|
|
||||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
|
||||||
<li>UUID将保存到本地浏览器存储</li>
|
|
||||||
<li v-if="deviceName">设备名称将帮助您快速识别不同的设备</li>
|
|
||||||
<li v-if="bindToAccount && accountStore.isAuthenticated">绑定后可在任何设备上通过账户加载</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-2 pt-2">
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
@ -388,6 +445,42 @@ onUnmounted(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<!-- 历史设备选项卡 -->
|
||||||
|
<TabsContent value="history" class="space-y-4 mt-4">
|
||||||
|
<div v-if="historyDevices.length === 0" class="text-center py-8 text-muted-foreground">
|
||||||
|
暂无历史设备
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2 max-h-96 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="device in historyDevices"
|
||||||
|
:key="device.uuid"
|
||||||
|
class="p-4 rounded-lg border hover:bg-accent cursor-pointer transition-colors"
|
||||||
|
@click="loadDevice(device)"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="font-medium text-base">
|
||||||
|
{{ device.name || '未命名设备' }}
|
||||||
|
</div>
|
||||||
|
<code class="text-xs text-muted-foreground block mt-1">
|
||||||
|
{{ device.uuid }}
|
||||||
|
</code>
|
||||||
|
<div class="text-xs text-muted-foreground mt-2">
|
||||||
|
最近使用: {{ new Date(device.lastUsedAt).toLocaleString('zh-CN') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="loadDevice(device)"
|
||||||
|
>
|
||||||
|
加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
62
src/components/RelativeTime.vue
Normal file
62
src/components/RelativeTime.vue
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, onBeforeUnmount, ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
date: { type: [String, Number, Date], required: true },
|
||||||
|
refreshMs: { type: Number, default: 60_000 }, // 默认每分钟刷新
|
||||||
|
locale: { type: String, default: 'zh-CN' },
|
||||||
|
prefix: { type: String, default: '' }, // 可选前缀,如 "于"
|
||||||
|
suffix: { type: String, default: '' }, // 可选后缀,如 "前"
|
||||||
|
showTooltip: { type: Boolean, default: true }, // 鼠标悬浮显示绝对时间
|
||||||
|
})
|
||||||
|
|
||||||
|
const now = ref(Date.now())
|
||||||
|
let timer = null
|
||||||
|
|
||||||
|
const dateObj = computed(() => new Date(props.date))
|
||||||
|
const absText = computed(() => dateObj.value.toLocaleString(props.locale))
|
||||||
|
|
||||||
|
function formatRelative(from, to) {
|
||||||
|
const rtf = new Intl.RelativeTimeFormat(props.locale, { numeric: 'auto' })
|
||||||
|
const diff = to - from
|
||||||
|
const sec = Math.round(diff / 1000)
|
||||||
|
const min = Math.round(sec / 60)
|
||||||
|
const hour = Math.round(min / 60)
|
||||||
|
const day = Math.round(hour / 24)
|
||||||
|
const month = Math.round(day / 30)
|
||||||
|
const year = Math.round(month / 12)
|
||||||
|
|
||||||
|
if (Math.abs(sec) < 60) return rtf.format(-sec, 'second')
|
||||||
|
if (Math.abs(min) < 60) return rtf.format(-min, 'minute')
|
||||||
|
if (Math.abs(hour) < 24) return rtf.format(-hour, 'hour')
|
||||||
|
if (Math.abs(day) < 30) return rtf.format(-day, 'day')
|
||||||
|
if (Math.abs(month) < 12) return rtf.format(-month, 'month')
|
||||||
|
return rtf.format(-year, 'year')
|
||||||
|
}
|
||||||
|
|
||||||
|
const relText = computed(() => {
|
||||||
|
const text = formatRelative(dateObj.value.getTime(), now.value)
|
||||||
|
return `${props.prefix}${text}${props.suffix}`
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => { now.value = Date.now() }, Math.max(5_000, props.refreshMs))
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.refreshMs, (v) => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
timer = setInterval(() => { now.value = Date.now() }, Math.max(5_000, v || 60_000))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :title="showTooltip ? absText : undefined">{{ relText }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 行内展示,无额外样式 */
|
||||||
|
</style>
|
147
src/components/TokenList.vue
Normal file
147
src/components/TokenList.vue
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Table, TableBody, 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'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
items: { type: Array, default: () => [] }, // [{ id, token, appId, appName?, note, installedAt }]
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
copiedId: { type: [String, Number, null], default: null }, // 用于显示已复制状态
|
||||||
|
showAppColumn: { type: Boolean, default: true }, // 是否显示“应用”列,嵌在应用卡片下方时可隐藏
|
||||||
|
compact: { type: Boolean, default: false }, // 仅显示备注(或时间),点击展开查看详情
|
||||||
|
sortByTime: { type: Boolean, default: false }, // 按时间倒序排序
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['copy', 'revoke'])
|
||||||
|
|
||||||
|
const rows = computed(() => {
|
||||||
|
const list = [...props.items]
|
||||||
|
if (props.sortByTime) {
|
||||||
|
return list.sort((a, b) => new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime())
|
||||||
|
}
|
||||||
|
// 默认排序:按 appName 升序,时间倒序
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const an = (a.appName || a.appId || '').toString().toLowerCase()
|
||||||
|
const bn = (b.appName || b.appId || '').toString().toLowerCase()
|
||||||
|
if (an === bn) {
|
||||||
|
return new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime()
|
||||||
|
}
|
||||||
|
return an < bn ? -1 : 1
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const colCount = computed(() => (props.compact ? 1 : (props.showAppColumn ? 5 : 4)))
|
||||||
|
|
||||||
|
const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
|
||||||
|
|
||||||
|
// 紧凑模式下:点击行由父组件决定弹框打开
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<Table>
|
||||||
|
<TableCaption v-if="!loading && rows.length === 0">
|
||||||
|
暂无授权应用令牌。
|
||||||
|
</TableCaption>
|
||||||
|
<TableHeader v-if="!props.compact">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead v-if="props.showAppColumn" class="w-[28%]">应用</TableHead>
|
||||||
|
<TableHead :class="props.showAppColumn ? 'w-[32%]' : 'w-[44%]'">令牌</TableHead>
|
||||||
|
<TableHead class="w-[20%]">备注</TableHead>
|
||||||
|
<TableHead class="w-[20%] text-right">授权时间</TableHead>
|
||||||
|
<TableHead class="w-[100px] text-right">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
<TableRow v-if="loading">
|
||||||
|
<TableCell :colspan="colCount" class="text-center py-8 text-muted-foreground">
|
||||||
|
加载中...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<TableRow v-else-if="rows.length === 0">
|
||||||
|
<TableCell :colspan="colCount">
|
||||||
|
<TableEmpty icon="package" description="暂无数据" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
<!-- 非紧凑模式:完整列集 -->
|
||||||
|
<template v-if="!props.compact">
|
||||||
|
<TableRow v-for="item in rows" :key="item.id">
|
||||||
|
<TableCell v-if="props.showAppColumn">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" class="shrink-0">{{ item.appId }}</Badge>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="font-medium truncate">{{ item.appName || `应用 ${item.appId}` }}</div>
|
||||||
|
<div class="text-xs text-muted-foreground truncate">ID: {{ item.appId }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Key class="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
<code class="text-xs font-mono truncate">{{ item.token }}</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 w-7 ml-auto"
|
||||||
|
@click="emit('copy', item)"
|
||||||
|
:title="props.copiedId === item.token ? '已复制' : '复制令牌'"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span class="text-sm text-muted-foreground block truncate">{{ item.note || '-' }}</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<div class="flex items-center justify-end gap-1 text-sm text-muted-foreground">
|
||||||
|
<Clock class="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
<RelativeTime :date="item.installedAt" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
|
@click="emit('revoke', item)"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-3.5 w-3.5 mr-1" /> 撤销
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 紧凑模式:仅显示备注(无备注显示时间),点击触发 open 事件 -->
|
||||||
|
<template v-else>
|
||||||
|
<TableRow
|
||||||
|
v-for="item in rows"
|
||||||
|
:key="item.id"
|
||||||
|
class="cursor-pointer hover:bg-muted/50"
|
||||||
|
@click="emit('open', item)"
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div class="text-sm font-medium truncate">
|
||||||
|
{{ item.note || '' }}
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
<RelativeTime :date="item.installedAt" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</template>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.truncate { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
</style>
|
@ -13,6 +13,7 @@ export const deviceStore = {
|
|||||||
STORAGE_KEY: 'device_uuid',
|
STORAGE_KEY: 'device_uuid',
|
||||||
BACKUP_KEY: 'device_uuid_backup',
|
BACKUP_KEY: 'device_uuid_backup',
|
||||||
SESSION_KEY: 'device_uuid_session',
|
SESSION_KEY: 'device_uuid_session',
|
||||||
|
HISTORY_KEY: 'device_history', // 本地历史设备记录
|
||||||
|
|
||||||
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
||||||
getDeviceUuid() {
|
getDeviceUuid() {
|
||||||
@ -189,3 +190,64 @@ export const deviceStore = {
|
|||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
deviceStore.tryRestoreFromIndexedDB()
|
deviceStore.tryRestoreFromIndexedDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为 deviceStore 扩展历史设备管理功能
|
||||||
|
// 记录结构:{ uuid: string, name?: string, lastUsedAt: number }
|
||||||
|
deviceStore.getDeviceHistory = function () {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(this.HISTORY_KEY)
|
||||||
|
const list = raw ? JSON.parse(raw) : []
|
||||||
|
if (!Array.isArray(list)) return []
|
||||||
|
// 排序:最近使用在前
|
||||||
|
return list.sort((a, b) => (b.lastUsedAt || 0) - (a.lastUsedAt || 0))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceStore.addDeviceToHistory = function (device) {
|
||||||
|
try {
|
||||||
|
if (!device || !device.uuid) return
|
||||||
|
const maxItems = 20
|
||||||
|
const now = Date.now()
|
||||||
|
const list = this.getDeviceHistory()
|
||||||
|
const idx = list.findIndex(d => d.uuid === device.uuid)
|
||||||
|
const entry = {
|
||||||
|
uuid: device.uuid,
|
||||||
|
name: device.name || device.deviceName || '',
|
||||||
|
lastUsedAt: now
|
||||||
|
}
|
||||||
|
if (idx >= 0) {
|
||||||
|
// 更新名称和时间
|
||||||
|
list[idx] = { ...list[idx], ...entry }
|
||||||
|
} else {
|
||||||
|
list.unshift(entry)
|
||||||
|
}
|
||||||
|
// 去重(按 uuid)并截断
|
||||||
|
const uniqMap = new Map()
|
||||||
|
for (const item of list) {
|
||||||
|
if (!uniqMap.has(item.uuid)) uniqMap.set(item.uuid, item)
|
||||||
|
}
|
||||||
|
const next = Array.from(uniqMap.values()).slice(0, maxItems)
|
||||||
|
localStorage.setItem(this.HISTORY_KEY, JSON.stringify(next))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceStore.removeDeviceFromHistory = function (uuid) {
|
||||||
|
try {
|
||||||
|
const list = this.getDeviceHistory().filter(d => d.uuid !== uuid)
|
||||||
|
localStorage.setItem(this.HISTORY_KEY, JSON.stringify(list))
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceStore.clearDeviceHistory = function () {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(this.HISTORY_KEY)
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,3 +1,11 @@
|
|||||||
|
<route lang="json">
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"requiresAuth": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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'
|
||||||
|
@ -14,6 +14,7 @@ import { Plus, Trash2, Key, Shield, RefreshCw, Copy, CheckCircle2, Settings, Pac
|
|||||||
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'
|
||||||
|
import TokenList from '@/components/TokenList.vue'
|
||||||
import PasswordInput from '@/components/PasswordInput.vue'
|
import PasswordInput from '@/components/PasswordInput.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'
|
||||||
@ -39,6 +40,7 @@ const showEditNameDialog = ref(false)
|
|||||||
const showUserMenu = ref(false)
|
const showUserMenu = ref(false)
|
||||||
const deviceRequired = ref(false) // 标记是否必须注册设备
|
const deviceRequired = ref(false) // 标记是否必须注册设备
|
||||||
const selectedToken = ref(null)
|
const selectedToken = ref(null)
|
||||||
|
const showTokenDialog = ref(false)
|
||||||
|
|
||||||
// Form data
|
// Form data
|
||||||
const appIdToAuthorize = ref('')
|
const appIdToAuthorize = ref('')
|
||||||
@ -56,19 +58,22 @@ const { handleOAuthCallback } = useOAuthCallback()
|
|||||||
// 使用计算属性来获取是否有密码
|
// 使用计算属性来获取是否有密码
|
||||||
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
||||||
|
|
||||||
// Group tokens by appId
|
// 为 TokenList 扁平化数据并附带 appName
|
||||||
const groupedByApp = computed(() => {
|
const flatTokenList = computed(() => {
|
||||||
|
return tokens.value.map(t => ({
|
||||||
|
...t,
|
||||||
|
appName: appInfoCache.value[t.appId]?.name || null,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 按应用分组以用于“应用卡片 + 下方小列表”布局
|
||||||
|
const groupedTokens = computed(() => {
|
||||||
const groups = {}
|
const groups = {}
|
||||||
tokens.value.forEach(token => {
|
for (const t of tokens.value) {
|
||||||
const appId = token.appId
|
const id = t.appId
|
||||||
if (!groups[appId]) {
|
if (!groups[id]) groups[id] = { appId: id, tokens: [] }
|
||||||
groups[appId] = {
|
groups[id].tokens.push(t)
|
||||||
appId: appId,
|
}
|
||||||
tokens: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
groups[appId].tokens.push(token)
|
|
||||||
})
|
|
||||||
return Object.values(groups)
|
return Object.values(groups)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -218,6 +223,10 @@ const copyToClipboard = async (text, id) => {
|
|||||||
const updateUuid = () => {
|
const updateUuid = () => {
|
||||||
showRegisterDialog.value = false
|
showRegisterDialog.value = false
|
||||||
deviceUuid.value = deviceStore.getDeviceUuid()
|
deviceUuid.value = deviceStore.getDeviceUuid()
|
||||||
|
// 记录到历史
|
||||||
|
if (deviceUuid.value) {
|
||||||
|
deviceStore.addDeviceToHistory({ uuid: deviceUuid.value, name: deviceInfo.value?.name || deviceInfo.value?.deviceName })
|
||||||
|
}
|
||||||
loadDeviceInfo()
|
loadDeviceInfo()
|
||||||
loadDeviceAccount()
|
loadDeviceAccount()
|
||||||
loadTokens()
|
loadTokens()
|
||||||
@ -400,6 +409,15 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- 切换设备按钮 -->
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
@click="showRegisterDialog = true"
|
||||||
|
title="切换设备"
|
||||||
|
>
|
||||||
|
切换设备
|
||||||
|
</Button>
|
||||||
<!-- 账户状态 -->
|
<!-- 账户状态 -->
|
||||||
<template v-if="accountStore.isAuthenticated">
|
<template v-if="accountStore.isAuthenticated">
|
||||||
<DropdownMenu v-model:open="showUserMenu" class="z-50">
|
<DropdownMenu v-model:open="showUserMenu" class="z-50">
|
||||||
@ -568,7 +586,7 @@ onMounted(async () => {
|
|||||||
<!-- Quick Stats -->
|
<!-- Quick Stats -->
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
||||||
<div class="text-2xl font-bold text-primary">{{ groupedByApp.length }}</div>
|
<div class="text-2xl font-bold text-primary">{{ new Set(tokens.map(t => t.appId)).size }}</div>
|
||||||
<div class="text-xs text-muted-foreground">应用数</div>
|
<div class="text-xs text-muted-foreground">应用数</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
||||||
@ -599,7 +617,7 @@ 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 @click="showAuthorizeDialog = true" class="gap-2">
|
||||||
<Plus class="h-4 w-4" />
|
<Plus class="h-4 w-4" />
|
||||||
授权新应用
|
授权新应用
|
||||||
@ -613,7 +631,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Card v-else-if="groupedByApp.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>
|
||||||
@ -624,67 +642,33 @@ onMounted(async () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div
|
<div
|
||||||
v-for="app in groupedByApp"
|
v-for="group in groupedTokens"
|
||||||
:key="app.appId"
|
:key="group.appId"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<AppCard :app-id="app.appId" />
|
<AppCard :app-id="group.appId" />
|
||||||
<Card class="border-dashed">
|
|
||||||
<CardContent class="p-4 space-y-3">
|
|
||||||
<div
|
|
||||||
v-for="(token, index) in app.tokens"
|
|
||||||
:key="token.token"
|
|
||||||
class="p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors"
|
|
||||||
>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Key class="h-3 w-3 text-muted-foreground flex-shrink-0" />
|
|
||||||
<code class="text-xs font-mono flex-1 truncate">
|
|
||||||
{{ token.token }}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 w-7 p-0"
|
|
||||||
@click="copyToClipboard(token.token, token.token)"
|
|
||||||
>
|
|
||||||
<CheckCircle2 v-if="copied === token.token" class="h-3 w-3 text-green-500" />
|
|
||||||
<Copy v-else class="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="token.note" class="text-xs text-muted-foreground pl-5">
|
<TokenList
|
||||||
{{ token.note }}
|
:items="group.tokens.map(t => ({
|
||||||
</div>
|
id: t.id,
|
||||||
|
token: t.token,
|
||||||
|
appId: t.appId,
|
||||||
|
appName: appInfoCache[t.appId]?.name || null,
|
||||||
|
note: t.note,
|
||||||
|
installedAt: t.installedAt,
|
||||||
|
}))"
|
||||||
|
:loading="isLoading"
|
||||||
|
:copied-id="copied"
|
||||||
|
:show-app-column="false"
|
||||||
|
compact
|
||||||
|
sort-by-time
|
||||||
|
@copy="(item) => copyToClipboard(item.token, item.token)"
|
||||||
|
@revoke="confirmRevoke"
|
||||||
|
@open="(item) => { selectedToken = item; showTokenDialog = true }"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between pl-5">
|
|
||||||
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
|
||||||
<Clock class="h-3 w-3" />
|
|
||||||
{{ formatDate(token.installedAt) }}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
|
||||||
@click="confirmRevoke(token)"
|
|
||||||
>
|
|
||||||
<Trash2 class="h-3 w-3 mr-1" />
|
|
||||||
撤销
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="index < app.tokens.length - 1"
|
|
||||||
class="mt-3 border-t border-border/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -723,7 +707,7 @@ onMounted(async () => {
|
|||||||
:show-hint="true"
|
:show-hint="true"
|
||||||
:show-strength="false"
|
:show-strength="false"
|
||||||
:required="!accountStore.isAuthenticated"
|
:required="!accountStore.isAuthenticated"
|
||||||
/>
|
/><br/>
|
||||||
<p v-if="accountStore.isAuthenticated" class="text-xs text-muted-foreground mt-2">
|
<p v-if="accountStore.isAuthenticated" class="text-xs text-muted-foreground mt-2">
|
||||||
已登录绑定账户,无需输入密码
|
已登录绑定账户,无需输入密码
|
||||||
</p>
|
</p>
|
||||||
@ -746,7 +730,7 @@ onMounted(async () => {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>撤销授权</DialogTitle>
|
<DialogTitle>撤销授权</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
确定要撤销此令牌的授权吗?此操作无法撤销。{{selectedToken}}
|
确定要撤销此令牌的授权吗?此操作无法撤销。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div v-if="selectedToken" class="py-4 space-y-4">
|
<div v-if="selectedToken" class="py-4 space-y-4">
|
||||||
@ -799,6 +783,53 @@ onMounted(async () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- 令牌详情弹框 -->
|
||||||
|
<Dialog v-model:open="showTokenDialog">
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>令牌详情</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
查看并对该令牌执行操作
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div v-if="selectedToken" class="space-y-3 py-2">
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">备注:</span>
|
||||||
|
<span>{{ selectedToken.note || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">应用名称:</span>
|
||||||
|
<span>{{ selectedToken.appName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm">
|
||||||
|
<span class="font-medium">应用ID:</span>
|
||||||
|
<span>{{ selectedToken.appId }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span class="font-medium">令牌:</span>
|
||||||
|
<code class="text-xs font-mono break-all">{{ selectedToken.token.slice(0, 8) }}...</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 w-7 ml-auto"
|
||||||
|
@click="copyToClipboard(selectedToken.token, selectedToken.token)"
|
||||||
|
>
|
||||||
|
<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" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Clock class="h-4 w-4" />
|
||||||
|
<span>{{ formatDate(selectedToken.installedAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" @click="showTokenDialog = false">关闭</Button>
|
||||||
|
<Button variant="destructive" @click="() => { showTokenDialog = false; confirmRevoke(selectedToken) }">撤销</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
|
||||||
<Dialog v-model:open="showPasswordDialog">
|
<Dialog v-model:open="showPasswordDialog">
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
@ -391,29 +391,18 @@ onMounted(async () => {
|
|||||||
<h2 class="text-xl font-semibold mt-12 mb-4">设备管理</h2>
|
<h2 class="text-xl font-semibold mt-12 mb-4">设备管理</h2>
|
||||||
|
|
||||||
<!-- 设备重置卡片 -->
|
<!-- 设备重置卡片 -->
|
||||||
<Card class="mb-6 border-red-200 dark:border-red-900">
|
<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 text-red-500" />
|
<Smartphone class="h-5 w-5" />
|
||||||
重置设备
|
更换设备
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
重置或换新设备标识。此操作无法撤销,您将失去当前设备的所有授权。
|
更换新的设备标识。
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="p-4 rounded-lg bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-900">
|
|
||||||
<div class="flex items-start gap-2">
|
|
||||||
<AlertTriangle class="h-5 w-5 text-red-600 dark:text-red-400 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-red-900 dark:text-red-100">警告:此操作不可逆</p>
|
|
||||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">
|
|
||||||
重置设备后,您将获得全新的设备标识,现有的所有授权将被撤销,无法恢复。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@ -421,7 +410,7 @@ onMounted(async () => {
|
|||||||
@click="showResetDeviceDialog = true"
|
@click="showResetDeviceDialog = true"
|
||||||
>
|
>
|
||||||
<RefreshCw class="h-4 w-4" />
|
<RefreshCw class="h-4 w-4" />
|
||||||
重置设备
|
更换设备
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
@ -118,7 +118,29 @@
|
|||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
}
|
||||||
|
html {
|
||||||
|
/* Reserve space for the vertical scrollbar to avoid layout shift/flicker */
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page transition animations */
|
||||||
|
.page-enter-active,
|
||||||
|
.page-leave-active {
|
||||||
|
transition: opacity 200ms ease, transform 200ms ease;
|
||||||
|
will-change: opacity;
|
||||||
|
|
||||||
|
}
|
||||||
|
.page-enter-from,
|
||||||
|
.page-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
.page-enter-to,
|
||||||
|
.page-leave-from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user