feat: 添加令牌列表组件,支持按时间排序和复制功能;优化授权应用展示

This commit is contained in:
SunWuyuan 2025-10-08 14:10:58 +08:00
parent 29daa623cc
commit 473ffc2f50
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
4 changed files with 304 additions and 71 deletions

View File

@ -0,0 +1,62 @@
<script setup>
import { onMounted, onBeforeUnmount, ref, watch, computed } from 'vue'
const props = defineProps({
date: { type: [String, Number, Date], required: true },
refreshMs: { type: Number, default: 60_000 }, //
locale: { type: String, default: 'zh-CN' },
prefix: { type: String, default: '' }, // ""
suffix: { type: String, default: '' }, // ""
showTooltip: { type: Boolean, default: true }, //
})
const now = ref(Date.now())
let timer = null
const dateObj = computed(() => new Date(props.date))
const absText = computed(() => dateObj.value.toLocaleString(props.locale))
function formatRelative(from, to) {
const rtf = new Intl.RelativeTimeFormat(props.locale, { numeric: 'auto' })
const diff = to - from
const sec = Math.round(diff / 1000)
const min = Math.round(sec / 60)
const hour = Math.round(min / 60)
const day = Math.round(hour / 24)
const month = Math.round(day / 30)
const year = Math.round(month / 12)
if (Math.abs(sec) < 60) return rtf.format(-sec, 'second')
if (Math.abs(min) < 60) return rtf.format(-min, 'minute')
if (Math.abs(hour) < 24) return rtf.format(-hour, 'hour')
if (Math.abs(day) < 30) return rtf.format(-day, 'day')
if (Math.abs(month) < 12) return rtf.format(-month, 'month')
return rtf.format(-year, 'year')
}
const relText = computed(() => {
const text = formatRelative(dateObj.value.getTime(), now.value)
return `${props.prefix}${text}${props.suffix}`
})
onMounted(() => {
timer = setInterval(() => { now.value = Date.now() }, Math.max(5_000, props.refreshMs))
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
watch(() => props.refreshMs, (v) => {
if (timer) clearInterval(timer)
timer = setInterval(() => { now.value = Date.now() }, Math.max(5_000, v || 60_000))
})
</script>
<template>
<span :title="showTooltip ? absText : undefined">{{ relText }}</span>
</template>
<style scoped>
/* 行内展示,无额外样式 */
</style>

View File

@ -0,0 +1,147 @@
<script setup>
import { computed } from 'vue'
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow, TableEmpty } from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Copy, CheckCircle2, Key, Clock, Trash2 } from 'lucide-vue-next'
import RelativeTime from '@/components/RelativeTime.vue'
const props = defineProps({
items: { type: Array, default: () => [] }, // [{ id, token, appId, appName?, note, installedAt }]
loading: { type: Boolean, default: false },
copiedId: { type: [String, Number, null], default: null }, //
showAppColumn: { type: Boolean, default: true }, //
compact: { type: Boolean, default: false }, //
sortByTime: { type: Boolean, default: false }, //
})
const emit = defineEmits(['copy', 'revoke'])
const rows = computed(() => {
const list = [...props.items]
if (props.sortByTime) {
return list.sort((a, b) => new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime())
}
// appName
return list.sort((a, b) => {
const an = (a.appName || a.appId || '').toString().toLowerCase()
const bn = (b.appName || b.appId || '').toString().toLowerCase()
if (an === bn) {
return new Date(b.installedAt).getTime() - new Date(a.installedAt).getTime()
}
return an < bn ? -1 : 1
})
})
const colCount = computed(() => (props.compact ? 1 : (props.showAppColumn ? 5 : 4)))
const formatDate = (dateString) => new Date(dateString).toLocaleString('zh-CN')
//
</script>
<template>
<div class="relative">
<Table>
<TableCaption v-if="!loading && rows.length === 0">
暂无授权应用令牌
</TableCaption>
<TableHeader v-if="!props.compact">
<TableRow>
<TableHead v-if="props.showAppColumn" class="w-[28%]">应用</TableHead>
<TableHead :class="props.showAppColumn ? 'w-[32%]' : 'w-[44%]'">令牌</TableHead>
<TableHead class="w-[20%]">备注</TableHead>
<TableHead class="w-[20%] text-right">授权时间</TableHead>
<TableHead class="w-[100px] text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell :colspan="colCount" class="text-center py-8 text-muted-foreground">
加载中...
</TableCell>
</TableRow>
<TableRow v-else-if="rows.length === 0">
<TableCell :colspan="colCount">
<TableEmpty icon="package" description="暂无数据" />
</TableCell>
</TableRow>
<!-- 非紧凑模式完整列集 -->
<template v-if="!props.compact">
<TableRow v-for="item in rows" :key="item.id">
<TableCell v-if="props.showAppColumn">
<div class="flex items-center gap-2">
<Badge variant="secondary" class="shrink-0">{{ item.appId }}</Badge>
<div class="min-w-0">
<div class="font-medium truncate">{{ item.appName || `应用 ${item.appId}` }}</div>
<div class="text-xs text-muted-foreground truncate">ID: {{ item.appId }}</div>
</div>
</div>
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<Key class="h-3.5 w-3.5 text-muted-foreground" />
<code class="text-xs font-mono truncate">{{ item.token }}</code>
<Button
variant="ghost"
size="sm"
class="h-7 w-7 ml-auto"
@click="emit('copy', item)"
:title="props.copiedId === item.token ? '已复制' : '复制令牌'"
>
<CheckCircle2 v-if="props.copiedId === item.token" class="h-3.5 w-3.5 text-green-500" />
<Copy v-else class="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
<TableCell>
<span class="text-sm text-muted-foreground block truncate">{{ item.note || '-' }}</span>
</TableCell>
<TableCell class="text-right">
<div class="flex items-center justify-end gap-1 text-sm text-muted-foreground">
<Clock class="h-3.5 w-3.5" />
<span>
<RelativeTime :date="item.installedAt" />
</span>
</div>
</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="sm"
class="h-7 text-xs text-destructive hover:text-destructive hover:bg-destructive/10"
@click="emit('revoke', item)"
>
<Trash2 class="h-3.5 w-3.5 mr-1" /> 撤销
</Button>
</TableCell>
</TableRow>
</template>
<!-- 紧凑模式仅显示备注无备注显示时间点击触发 open 事件 -->
<template v-else>
<TableRow
v-for="item in rows"
:key="item.id"
class="cursor-pointer hover:bg-muted/50"
@click="emit('open', item)"
>
<TableCell>
<div class="text-sm font-medium truncate">
{{ item.note || '' }}
<span class="text-xs text-muted-foreground">
<RelativeTime :date="item.installedAt" />
</span>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</template>
<style scoped>
.truncate { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
</style>

View File

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

View File

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