mirror of
https://github.com/ZeroCatDev/ClassworksKVAdmin.git
synced 2025-10-21 19:13:09 +00:00
feat: 添加令牌列表组件,支持按时间排序和复制功能;优化授权应用展示
This commit is contained in:
parent
29daa623cc
commit
473ffc2f50
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>
|
@ -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: []
|
||||
}
|
||||
}
|
||||
groups[appId].tokens.push(token)
|
||||
})
|
||||
for (const t of tokens.value) {
|
||||
const id = t.appId
|
||||
if (!groups[id]) groups[id] = { appId: id, tokens: [] }
|
||||
groups[id].tokens.push(t)
|
||||
}
|
||||
return Object.values(groups)
|
||||
})
|
||||
|
||||
@ -581,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">
|
||||
@ -612,7 +617,7 @@ onMounted(async () => {
|
||||
|
||||
|
||||
<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">
|
||||
<Plus class="h-4 w-4" />
|
||||
授权新应用
|
||||
@ -626,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>
|
||||
@ -637,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>
|
||||
<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 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>
|
||||
|
||||
@ -759,7 +730,7 @@ onMounted(async () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>撤销授权</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要撤销此令牌的授权吗?此操作无法撤销。{{selectedToken}}
|
||||
确定要撤销此令牌的授权吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div v-if="selectedToken" class="py-4 space-y-4">
|
||||
@ -812,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>
|
||||
|
@ -118,6 +118,10 @@
|
||||
* {
|
||||
@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;
|
||||
}
|
||||
@ -127,6 +131,8 @@
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 200ms ease, transform 200ms ease;
|
||||
will-change: opacity;
|
||||
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
|
Loading…
x
Reference in New Issue
Block a user