@@ -0,0 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NSpin } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { listSessions, revokeSession, revokeSessions } from '@/api/auth'
|
||||
import ConfirmPop from '@/components/ConfirmPop.vue'
|
||||
import FootNote from '@/components/FootNote.vue'
|
||||
import { useAsync } from '@/composables/useAsync'
|
||||
import { fmtRelative, fmtTime, uaLabel } from '@/composables/useFormat'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { SessionItem } from '@/types/api'
|
||||
|
||||
/** 活跃会话卡:全宽表格列出当前有效登录会话,支持定点撤销与撤销全部 */
|
||||
const message = useToast()
|
||||
const auth = useAuthStore()
|
||||
const sessions = useAsync(listSessions)
|
||||
|
||||
const items = computed(() => sessions.data.value?.items ?? [])
|
||||
/** 零行才是空态:单条本机会话也渲染表格(展示设备/IP/时间比空文案更有信息量);
|
||||
* 零行不证明只有一个活跃会话——升级前签发的旧令牌不产生记录 */
|
||||
const empty = computed(() => items.value.length === 0)
|
||||
|
||||
const methodLabels: Record<string, string> = {
|
||||
password: '密码',
|
||||
passkey: '通行密钥',
|
||||
wallet: '钱包',
|
||||
oidc: 'OIDC',
|
||||
github: 'GitHub',
|
||||
}
|
||||
|
||||
function deviceOf(s: SessionItem) {
|
||||
return uaLabel(s.userAgent)
|
||||
}
|
||||
|
||||
/** 撤销互斥:单个与全部共享——撤销全部在服务端递增版本后、前端写入新
|
||||
* token 前,并发的单撤销会携带旧 token 吃 401 并触发全局登出竞态 */
|
||||
const revoking = ref(false)
|
||||
|
||||
async function revokeOne(s: SessionItem) {
|
||||
if (revoking.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
await revokeSession(s.id)
|
||||
message.success('已撤销,该设备将立即退出登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '撤销失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
void sessions.run({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
/** 撤销全部 = 令牌版本递增:其他设备立即失效,本会话经新 token 无感换新 */
|
||||
async function revokeAll() {
|
||||
if (revoking.value) return
|
||||
revoking.value = true
|
||||
try {
|
||||
const r = await revokeSessions()
|
||||
if (r?.token) auth.setSession(r.token, r.expiresAt)
|
||||
message.success('已撤销全部会话;其他设备须重新登录')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '操作失败')
|
||||
} finally {
|
||||
revoking.value = false
|
||||
void sessions.run({ silent: true })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="panel">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-line-soft px-4.5 py-3.5"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-semibold">活跃会话</div>
|
||||
<div class="mt-0.5 text-xs text-ink-3">
|
||||
当前有效登录会话,最近活跃在前;登出、撤销或凭据变更后自动消失
|
||||
</div>
|
||||
</div>
|
||||
<ConfirmPop
|
||||
title="撤销全部会话?"
|
||||
content="其他设备与旧令牌立即失效并需重新登录;本会话自动换新。"
|
||||
confirm-text="全部撤销"
|
||||
:on-confirm="revokeAll"
|
||||
>
|
||||
<template #trigger>
|
||||
<!-- 不依赖列表禁用(存量令牌唯一失效入口);仅与单撤销互斥 -->
|
||||
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销全部会话</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</div>
|
||||
|
||||
<div v-if="sessions.loading.value && !sessions.data.value" class="flex justify-center py-8">
|
||||
<NSpin size="small" />
|
||||
</div>
|
||||
<!-- 请求失败与空列表分开呈现,失败不得伪装成「仅本机一个会话」 -->
|
||||
<div
|
||||
v-else-if="sessions.error.value"
|
||||
class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3"
|
||||
>
|
||||
会话列表加载失败:{{ sessions.error.value }}
|
||||
<NButton size="tiny" quaternary type="primary" class="ml-1" @click="() => sessions.run()">
|
||||
重试
|
||||
</NButton>
|
||||
</div>
|
||||
<div v-else-if="empty" class="px-4.5 py-8 text-center text-xs leading-relaxed text-ink-3">
|
||||
暂无会话记录。升级前签发的旧令牌不产生记录,不代表没有其他活跃会话;
|
||||
如有顾虑可直接「撤销全部会话」。
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr class="text-left text-xs text-ink-3">
|
||||
<th class="border-b border-line-soft px-4.5 py-2 font-medium">设备</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">登录方式</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">IP</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">登录时间</th>
|
||||
<th class="border-b border-line-soft px-3 py-2 font-medium">最近活跃</th>
|
||||
<th class="w-20 border-b border-line-soft px-3 py-2 font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="s in items" :key="s.id" class="[&:last-child>td]:border-b-0">
|
||||
<td class="border-b border-line-soft px-4.5 py-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span
|
||||
class="flex h-7 w-7 flex-none items-center justify-center rounded-md bg-wash text-ink-2"
|
||||
>
|
||||
<svg
|
||||
v-if="deviceOf(s).mobile"
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<rect x="7" y="2" width="10" height="20" rx="2" />
|
||||
<path d="M11 18h2" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
class="h-3.5 w-3.5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
>
|
||||
<rect x="3" y="5" width="18" height="12" rx="2" />
|
||||
<path d="M8 21h8M12 17v4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="font-medium whitespace-nowrap">{{ deviceOf(s).label }}</span>
|
||||
<span
|
||||
v-if="s.current"
|
||||
class="rounded-full bg-ok/12 px-2 py-px text-[11px] font-semibold whitespace-nowrap text-ok"
|
||||
>
|
||||
本机
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span
|
||||
class="rounded-full border border-line bg-white px-2 py-px text-[11.5px] whitespace-nowrap text-ink-2"
|
||||
>
|
||||
{{ methodLabels[s.method] || s.method || '—' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="mono border-b border-line-soft px-3 py-2.5 text-ink-2">{{ s.clientIp }}</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
|
||||
{{ fmtTime(s.createdAt) }}
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5 whitespace-nowrap text-ink-2">
|
||||
{{ fmtRelative(s.lastSeenAt) }}
|
||||
</td>
|
||||
<td class="border-b border-line-soft px-3 py-2.5">
|
||||
<span v-if="s.current" class="px-2 text-xs text-ink-3">—</span>
|
||||
<ConfirmPop
|
||||
v-else
|
||||
title="撤销该会话?"
|
||||
:content="`${deviceOf(s).label}(${s.clientIp})将立即退出登录;其他会话不受影响。`"
|
||||
confirm-text="撤销"
|
||||
:on-confirm="() => revokeOne(s)"
|
||||
>
|
||||
<template #trigger>
|
||||
<NButton size="tiny" type="error" quaternary :disabled="revoking">撤销</NButton>
|
||||
</template>
|
||||
</ConfirmPop>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<FootNote>
|
||||
当前会话不可定点撤销(请用退出登录);升级前签发的存量令牌不在列表中,24h 内自然过期
|
||||
</FootNote>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user