Compare commits

..

12 Commits

Author SHA1 Message Date
SunWuyuan
473ffc2f50
feat: 添加令牌列表组件,支持按时间排序和复制功能;优化授权应用展示 2025-10-08 14:10:58 +08:00
SunWuyuan
29daa623cc
feat: 添加页面切换动画,优化路由视图体验 2025-10-08 12:47:38 +08:00
SunWuyuan
3f02c06ddb
feat: 添加深色模式支持,优化主题切换体验;更新设备管理界面文案 2025-10-08 12:40:21 +08:00
SunWuyuan
0e561f8ed2
feat: 添加404页面,提供友好的错误提示和导航选项 2025-10-08 12:15:57 +08:00
SunWuyuan
2f9121dc2a
Merge branch 'main' of github.com:Sunwuyuan/ClassworksKVAdmin 2025-10-08 12:14:33 +08:00
SunWuyuan
7b0e1610d5
feat: 添加历史设备管理功能,支持手动输入UUID加载设备 2025-10-08 12:10:18 +08:00
Sunwuyuan
70fb93d8d7
Delete public/404.html 2025-10-08 09:32:44 +08:00
Sunwuyuan
be54e877d5
Merge pull request #1 from ZeroCatDev/copilot/fix-authorize-page-access
[WIP] Fix bug preventing access to authorize page
2025-10-08 09:25:54 +08:00
copilot-swe-agent[bot]
6f6a372abd Fix authorize page access by setting requiresAuth to false
Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2025-10-08 01:25:22 +00:00
copilot-swe-agent[bot]
9388280fd5 Initial investigation of authorize page access bug
Co-authored-by: Sunwuyuan <88357633+Sunwuyuan@users.noreply.github.com>
2025-10-08 01:22:23 +00:00
copilot-swe-agent[bot]
0bc579f014 Initial plan 2025-10-08 01:17:38 +00:00
Sunwuyuan
ba42950105
Delete src/pages/[...path].vue 2025-10-08 09:14:42 +08:00
12 changed files with 6612 additions and 210 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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