mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-10-22 12:03:10 +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" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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>
|
||||
</head>
|
||||
<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>
|
||||
|
||||
<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" />
|
||||
</template>
|
||||
|
@ -33,8 +33,10 @@ const newUuid = ref('')
|
||||
const deviceName = ref('')
|
||||
const bindToAccount = ref(false)
|
||||
const accountDevices = ref([])
|
||||
const historyDevices = ref([])
|
||||
const manualUuid = ref('')
|
||||
const loadingDevices = ref(false)
|
||||
const activeTab = ref('load') // 'load' 或 'register'
|
||||
const activeTab = ref('load') // 'load' | 'history' | 'register'
|
||||
const showLoginDialog = ref(false) // 登录对话框状态
|
||||
|
||||
const isOpen = computed({
|
||||
@ -47,6 +49,9 @@ watch(isOpen, (newVal) => {
|
||||
if (newVal && accountStore.isAuthenticated && activeTab.value === 'load') {
|
||||
loadAccountDevices()
|
||||
}
|
||||
if (newVal && activeTab.value === 'history') {
|
||||
loadHistoryDevices()
|
||||
}
|
||||
// 切换到注册选项卡时,自动生成UUID
|
||||
if (newVal && activeTab.value === 'register' && !newUuid.value) {
|
||||
generateRandomUuid()
|
||||
@ -58,6 +63,9 @@ watch(activeTab, (newVal) => {
|
||||
if (newVal === 'load' && accountStore.isAuthenticated && isOpen.value) {
|
||||
loadAccountDevices()
|
||||
}
|
||||
if (newVal === 'history' && isOpen.value) {
|
||||
loadHistoryDevices()
|
||||
}
|
||||
if (newVal === 'register' && !newUuid.value) {
|
||||
generateRandomUuid()
|
||||
}
|
||||
@ -121,12 +129,35 @@ const loadAccountDevices = async () => {
|
||||
// 加载选中的设备
|
||||
const loadDevice = (device) => {
|
||||
deviceStore.setDeviceUuid(device.uuid)
|
||||
// 写入历史
|
||||
deviceStore.addDeviceToHistory({ uuid: device.uuid, name: device.name })
|
||||
isOpen.value = false
|
||||
emit('confirm')
|
||||
resetForm()
|
||||
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 () => {
|
||||
if (!newUuid.value.trim()) {
|
||||
@ -142,6 +173,8 @@ const registerDevice = async () => {
|
||||
try {
|
||||
// 1. 保存UUID到本地
|
||||
deviceStore.setDeviceUuid(newUuid.value.trim())
|
||||
// 写入历史
|
||||
deviceStore.addDeviceToHistory({ uuid: newUuid.value.trim(), name: deviceName.value.trim() })
|
||||
|
||||
// 2. 调用设备注册接口(会自动在云端创建设备)
|
||||
await apiClient.registerDevice(
|
||||
@ -181,6 +214,7 @@ const resetForm = () => {
|
||||
bindToAccount.value = accountStore.isAuthenticated
|
||||
accountDevices.value = []
|
||||
activeTab.value = 'load'
|
||||
manualUuid.value = ''
|
||||
}
|
||||
|
||||
// 处理弹框关闭
|
||||
@ -211,6 +245,11 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleKeydown, true)
|
||||
})
|
||||
|
||||
// 加载本地历史设备
|
||||
const loadHistoryDevices = () => {
|
||||
historyDevices.value = deviceStore.getDeviceHistory()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -239,11 +278,14 @@ onUnmounted(() => {
|
||||
</DialogHeader>
|
||||
|
||||
<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">
|
||||
<Download class="h-4 w-4 mr-2" />
|
||||
加载设备
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="history">
|
||||
历史记录
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="register">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
注册设备
|
||||
@ -252,19 +294,22 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 加载设备选项卡 -->
|
||||
<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">
|
||||
<div v-if="!accountStore.isAuthenticated" class="text-center py-6">
|
||||
<p class="text-muted-foreground mb-3">登录后可查看您账户绑定的设备</p>
|
||||
<Button variant="outline" @click="handleOpenLogin">
|
||||
登录账户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadingDevices" class="text-center py-8">
|
||||
<div v-else>
|
||||
<div v-if="loadingDevices" class="text-center py-6">
|
||||
<p class="text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="accountDevices.length === 0" class="text-center py-8">
|
||||
<p class="text-muted-foreground mb-4">您的账户暂未绑定任何设备</p>
|
||||
<div v-else-if="accountDevices.length === 0" class="text-center py-6">
|
||||
<p class="text-muted-foreground mb-3">您的账户暂未绑定任何设备</p>
|
||||
<Button variant="outline" @click="activeTab = 'register'">
|
||||
<Plus class="h-4 w-4 mr-2" />
|
||||
注册新设备
|
||||
@ -300,6 +345,28 @@ onUnmounted(() => {
|
||||
</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>
|
||||
|
||||
<!-- 注册设备选项卡 -->
|
||||
@ -328,7 +395,7 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 设备名称输入 -->
|
||||
<div class="space-y-2">
|
||||
<Label for="deviceName">设备名称</Label>
|
||||
<Label for="deviceName">* 设备名称</Label>
|
||||
<Input
|
||||
id="deviceName"
|
||||
v-model="deviceName"
|
||||
@ -361,16 +428,6 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<div class="text-sm text-muted-foreground bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-900 rounded-lg p-3">
|
||||
<p><strong>提示:</strong></p>
|
||||
<ul class="list-disc list-inside mt-1 space-y-1">
|
||||
<li>UUID将保存到本地浏览器存储</li>
|
||||
<li v-if="deviceName">设备名称将帮助您快速识别不同的设备</li>
|
||||
<li v-if="bindToAccount && accountStore.isAuthenticated">绑定后可在任何设备上通过账户加载</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
@ -388,6 +445,42 @@ onUnmounted(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</DialogContent>
|
||||
</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',
|
||||
BACKUP_KEY: 'device_uuid_backup',
|
||||
SESSION_KEY: 'device_uuid_session',
|
||||
HISTORY_KEY: 'device_history', // 本地历史设备记录
|
||||
|
||||
// 获取当前设备 UUID(从多个存储位置尝试读取)
|
||||
getDeviceUuid() {
|
||||
@ -189,3 +190,64 @@ export const deviceStore = {
|
||||
if (typeof window !== 'undefined') {
|
||||
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>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
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 DropdownItem from '@/components/ui/dropdown-menu/DropdownItem.vue'
|
||||
import AppCard from '@/components/AppCard.vue'
|
||||
import TokenList from '@/components/TokenList.vue'
|
||||
import PasswordInput from '@/components/PasswordInput.vue'
|
||||
import LoginDialog from '@/components/LoginDialog.vue'
|
||||
import DeviceRegisterDialog from '@/components/DeviceRegisterDialog.vue'
|
||||
@ -39,6 +40,7 @@ const showEditNameDialog = ref(false)
|
||||
const showUserMenu = ref(false)
|
||||
const deviceRequired = ref(false) // 标记是否必须注册设备
|
||||
const selectedToken = ref(null)
|
||||
const showTokenDialog = ref(false)
|
||||
|
||||
// Form data
|
||||
const appIdToAuthorize = ref('')
|
||||
@ -56,19 +58,22 @@ const { handleOAuthCallback } = useOAuthCallback()
|
||||
// 使用计算属性来获取是否有密码
|
||||
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
||||
|
||||
// Group tokens by appId
|
||||
const groupedByApp = computed(() => {
|
||||
// 为 TokenList 扁平化数据并附带 appName
|
||||
const flatTokenList = computed(() => {
|
||||
return tokens.value.map(t => ({
|
||||
...t,
|
||||
appName: appInfoCache.value[t.appId]?.name || null,
|
||||
}))
|
||||
})
|
||||
|
||||
// 按应用分组以用于“应用卡片 + 下方小列表”布局
|
||||
const groupedTokens = computed(() => {
|
||||
const groups = {}
|
||||
tokens.value.forEach(token => {
|
||||
const appId = token.appId
|
||||
if (!groups[appId]) {
|
||||
groups[appId] = {
|
||||
appId: appId,
|
||||
tokens: []
|
||||
for (const t of tokens.value) {
|
||||
const id = t.appId
|
||||
if (!groups[id]) groups[id] = { appId: id, tokens: [] }
|
||||
groups[id].tokens.push(t)
|
||||
}
|
||||
}
|
||||
groups[appId].tokens.push(token)
|
||||
})
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
@ -218,6 +223,10 @@ const copyToClipboard = async (text, id) => {
|
||||
const updateUuid = () => {
|
||||
showRegisterDialog.value = false
|
||||
deviceUuid.value = deviceStore.getDeviceUuid()
|
||||
// 记录到历史
|
||||
if (deviceUuid.value) {
|
||||
deviceStore.addDeviceToHistory({ uuid: deviceUuid.value, name: deviceInfo.value?.name || deviceInfo.value?.deviceName })
|
||||
}
|
||||
loadDeviceInfo()
|
||||
loadDeviceAccount()
|
||||
loadTokens()
|
||||
@ -400,6 +409,15 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- 切换设备按钮 -->
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="showRegisterDialog = true"
|
||||
title="切换设备"
|
||||
>
|
||||
切换设备
|
||||
</Button>
|
||||
<!-- 账户状态 -->
|
||||
<template v-if="accountStore.isAuthenticated">
|
||||
<DropdownMenu v-model:open="showUserMenu" class="z-50">
|
||||
@ -568,7 +586,7 @@ onMounted(async () => {
|
||||
<!-- Quick Stats -->
|
||||
<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="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>
|
||||
<div class="p-3 rounded-lg bg-muted/50 text-center">
|
||||
@ -613,7 +631,7 @@ onMounted(async () => {
|
||||
</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">
|
||||
<Package class="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<p class="text-lg font-medium text-muted-foreground mb-2">暂无授权应用</p>
|
||||
@ -624,67 +642,33 @@ onMounted(async () => {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
<div v-else class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div
|
||||
v-for="app in groupedByApp"
|
||||
:key="app.appId"
|
||||
v-for="group in groupedTokens"
|
||||
:key="group.appId"
|
||||
class="space-y-4"
|
||||
>
|
||||
<AppCard :app-id="app.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>
|
||||
<AppCard :app-id="group.appId" />
|
||||
|
||||
<div v-if="token.note" class="text-xs text-muted-foreground pl-5">
|
||||
{{ token.note }}
|
||||
</div>
|
||||
|
||||
<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"
|
||||
<TokenList
|
||||
:items="group.tokens.map(t => ({
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -723,7 +707,7 @@ onMounted(async () => {
|
||||
:show-hint="true"
|
||||
:show-strength="false"
|
||||
:required="!accountStore.isAuthenticated"
|
||||
/>
|
||||
/><br/>
|
||||
<p v-if="accountStore.isAuthenticated" class="text-xs text-muted-foreground mt-2">
|
||||
已登录绑定账户,无需输入密码
|
||||
</p>
|
||||
@ -746,7 +730,7 @@ onMounted(async () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>撤销授权</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要撤销此令牌的授权吗?此操作无法撤销。{{selectedToken}}
|
||||
确定要撤销此令牌的授权吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div v-if="selectedToken" class="py-4 space-y-4">
|
||||
@ -799,6 +783,53 @@ onMounted(async () => {
|
||||
</DialogContent>
|
||||
</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">
|
||||
<DialogContent>
|
||||
|
@ -391,29 +391,18 @@ onMounted(async () => {
|
||||
<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>
|
||||
<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>
|
||||
<CardDescription>
|
||||
重置或换新设备标识。此操作无法撤销,您将失去当前设备的所有授权。
|
||||
更换新的设备标识。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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
|
||||
variant="destructive"
|
||||
@ -421,7 +410,7 @@ onMounted(async () => {
|
||||
@click="showResetDeviceDialog = true"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
重置设备
|
||||
更换设备
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
@ -118,7 +118,29 @@
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
html {
|
||||
/* Reserve space for the vertical scrollbar to avoid layout shift/flicker */
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
body {
|
||||
@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