mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-10-26 07:13:11 +00:00
Compare commits
No commits in common. "473ffc2f5097110ca861a236cc00118a3fba61f6" and "ef29982de7542204d81204f2536d2f4ad294d47a" have entirely different histories.
473ffc2f50
...
ef29982de7
40
index.html
40
index.html
@ -4,46 +4,6 @@
|
|||||||
<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
6010
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
66
public/404.html
Normal file
66
public/404.html
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
<!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,10 +5,6 @@ import 'vue-sonner/style.css'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<RouterView v-slot="{ Component, route }">
|
<RouterView />
|
||||||
<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,10 +33,8 @@ 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' | 'history' | 'register'
|
const activeTab = ref('load') // 'load' 或 'register'
|
||||||
const showLoginDialog = ref(false) // 登录对话框状态
|
const showLoginDialog = ref(false) // 登录对话框状态
|
||||||
|
|
||||||
const isOpen = computed({
|
const isOpen = computed({
|
||||||
@ -49,9 +47,6 @@ 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()
|
||||||
@ -63,9 +58,6 @@ 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()
|
||||||
}
|
}
|
||||||
@ -129,35 +121,12 @@ 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()) {
|
||||||
@ -173,8 +142,6 @@ 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(
|
||||||
@ -214,7 +181,6 @@ const resetForm = () => {
|
|||||||
bindToAccount.value = accountStore.isAuthenticated
|
bindToAccount.value = accountStore.isAuthenticated
|
||||||
accountDevices.value = []
|
accountDevices.value = []
|
||||||
activeTab.value = 'load'
|
activeTab.value = 'load'
|
||||||
manualUuid.value = ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理弹框关闭
|
// 处理弹框关闭
|
||||||
@ -245,11 +211,6 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('keydown', handleKeydown, true)
|
document.removeEventListener('keydown', handleKeydown, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 加载本地历史设备
|
|
||||||
const loadHistoryDevices = () => {
|
|
||||||
historyDevices.value = deviceStore.getDeviceHistory()
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -278,14 +239,11 @@ const loadHistoryDevices = () => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="w-full">
|
<Tabs v-model="activeTab" class="w-full">
|
||||||
<TabsList class="grid w-full grid-cols-3">
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
<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" />
|
||||||
注册设备
|
注册设备
|
||||||
@ -294,22 +252,19 @@ const loadHistoryDevices = () => {
|
|||||||
|
|
||||||
<!-- 加载设备选项卡 -->
|
<!-- 加载设备选项卡 -->
|
||||||
<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">
|
||||||
<div class="space-y-3">
|
<p class="text-muted-foreground mb-4">请先登录以查看您的设备列表</p>
|
||||||
<div v-if="!accountStore.isAuthenticated" class="text-center py-6">
|
|
||||||
<p class="text-muted-foreground mb-3">登录后可查看您账户绑定的设备</p>
|
|
||||||
<Button variant="outline" @click="handleOpenLogin">
|
<Button variant="outline" @click="handleOpenLogin">
|
||||||
登录账户
|
登录账户
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else-if="loadingDevices" class="text-center py-8">
|
||||||
<div v-if="loadingDevices" class="text-center py-6">
|
|
||||||
<p class="text-muted-foreground">加载中...</p>
|
<p class="text-muted-foreground">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="accountDevices.length === 0" class="text-center py-6">
|
<div v-else-if="accountDevices.length === 0" class="text-center py-8">
|
||||||
<p class="text-muted-foreground mb-3">您的账户暂未绑定任何设备</p>
|
<p class="text-muted-foreground mb-4">您的账户暂未绑定任何设备</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" />
|
||||||
注册新设备
|
注册新设备
|
||||||
@ -345,28 +300,6 @@ const loadHistoryDevices = () => {
|
|||||||
</div>
|
</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>
|
||||||
|
|
||||||
<!-- 注册设备选项卡 -->
|
<!-- 注册设备选项卡 -->
|
||||||
@ -395,7 +328,7 @@ const loadHistoryDevices = () => {
|
|||||||
|
|
||||||
<!-- 设备名称输入 -->
|
<!-- 设备名称输入 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="deviceName">* 设备名称</Label>
|
<Label for="deviceName">设备名称</Label>
|
||||||
<Input
|
<Input
|
||||||
id="deviceName"
|
id="deviceName"
|
||||||
v-model="deviceName"
|
v-model="deviceName"
|
||||||
@ -428,6 +361,16 @@ const loadHistoryDevices = () => {
|
|||||||
</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">
|
||||||
@ -445,42 +388,6 @@ const loadHistoryDevices = () => {
|
|||||||
</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>
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
<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,7 +13,6 @@ 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() {
|
||||||
@ -190,64 +189,3 @@ 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,11 +1,3 @@
|
|||||||
<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,7 +14,6 @@ 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'
|
||||||
@ -40,7 +39,6 @@ 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('')
|
||||||
@ -58,22 +56,19 @@ const { handleOAuthCallback } = useOAuthCallback()
|
|||||||
// 使用计算属性来获取是否有密码
|
// 使用计算属性来获取是否有密码
|
||||||
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
const hasPassword = computed(() => deviceInfo.value?.hasPassword || false)
|
||||||
|
|
||||||
// 为 TokenList 扁平化数据并附带 appName
|
// Group tokens by appId
|
||||||
const flatTokenList = computed(() => {
|
const groupedByApp = computed(() => {
|
||||||
return tokens.value.map(t => ({
|
|
||||||
...t,
|
|
||||||
appName: appInfoCache.value[t.appId]?.name || null,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
// 按应用分组以用于“应用卡片 + 下方小列表”布局
|
|
||||||
const groupedTokens = computed(() => {
|
|
||||||
const groups = {}
|
const groups = {}
|
||||||
for (const t of tokens.value) {
|
tokens.value.forEach(token => {
|
||||||
const id = t.appId
|
const appId = token.appId
|
||||||
if (!groups[id]) groups[id] = { appId: id, tokens: [] }
|
if (!groups[appId]) {
|
||||||
groups[id].tokens.push(t)
|
groups[appId] = {
|
||||||
|
appId: appId,
|
||||||
|
tokens: []
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
groups[appId].tokens.push(token)
|
||||||
|
})
|
||||||
return Object.values(groups)
|
return Object.values(groups)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -223,10 +218,6 @@ 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()
|
||||||
@ -409,15 +400,6 @@ 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">
|
||||||
@ -586,7 +568,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">{{ new Set(tokens.map(t => t.appId)).size }}</div>
|
<div class="text-2xl font-bold text-primary">{{ groupedByApp.length }}</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">
|
||||||
@ -631,7 +613,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<Card v-else-if="tokens.length === 0" class="border-dashed">
|
<Card v-else-if="groupedByApp.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>
|
||||||
@ -642,33 +624,67 @@ 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="group in groupedTokens"
|
v-for="app in groupedByApp"
|
||||||
:key="group.appId"
|
:key="app.appId"
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
<AppCard :app-id="group.appId" />
|
<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>
|
||||||
|
|
||||||
<TokenList
|
<div v-if="token.note" class="text-xs text-muted-foreground pl-5">
|
||||||
:items="group.tokens.map(t => ({
|
{{ token.note }}
|
||||||
id: t.id,
|
</div>
|
||||||
token: t.token,
|
|
||||||
appId: t.appId,
|
<div class="flex items-center justify-between pl-5">
|
||||||
appName: appInfoCache[t.appId]?.name || null,
|
<div class="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
note: t.note,
|
<Clock class="h-3 w-3" />
|
||||||
installedAt: t.installedAt,
|
{{ formatDate(token.installedAt) }}
|
||||||
}))"
|
</div>
|
||||||
:loading="isLoading"
|
<Button
|
||||||
:copied-id="copied"
|
variant="ghost"
|
||||||
:show-app-column="false"
|
size="sm"
|
||||||
compact
|
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||||
sort-by-time
|
@click="confirmRevoke(token)"
|
||||||
@copy="(item) => copyToClipboard(item.token, item.token)"
|
>
|
||||||
@revoke="confirmRevoke"
|
<Trash2 class="h-3 w-3 mr-1" />
|
||||||
@open="(item) => { selectedToken = item; showTokenDialog = true }"
|
撤销
|
||||||
|
</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>
|
||||||
|
|
||||||
@ -707,7 +723,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>
|
||||||
@ -730,7 +746,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">
|
||||||
@ -783,53 +799,6 @@ 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,18 +391,29 @@ 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 ">
|
<Card class="mb-6 border-red-200 dark:border-red-900">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-lg flex items-center gap-2">
|
<CardTitle class="text-lg flex items-center gap-2">
|
||||||
<Smartphone class="h-5 w-5" />
|
<Smartphone class="h-5 w-5 text-red-500" />
|
||||||
更换设备
|
重置设备
|
||||||
</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"
|
||||||
@ -410,7 +421,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,29 +118,7 @@
|
|||||||
* {
|
* {
|
||||||
@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