feat(session): 在线会话页面 + 用户锁定/解锁 UI

- 新增 monitor/session 在线会话页面(列表 + 强退 + 踢全部)
- 用户页新增锁定/解锁操作,锁定状态独立标签展示
- api/session.ts + api/user.ts 扩展

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 21:35:54 +08:00
parent d37af1b73e
commit ce45d93ce5
5 changed files with 240 additions and 5 deletions

39
src/api/session.ts Normal file
View File

@ -0,0 +1,39 @@
import { http } from '@/api/http'
export interface Session {
sid: string | null
userId: string
username: string
nickname: string | null
ip: string | null
userAgent: string | null
loginAt: number
expireAt: number
}
export interface SessionListQuery {
page: number
pageSize: number
username?: string
ip?: string
}
export interface SessionListResult {
list: Session[]
total: number
page: number
pageSize: number
}
/** 活跃会话分页列表。 */
export function listSessions(query: SessionListQuery, signal?: AbortSignal): Promise<SessionListResult> {
const params: Record<string, unknown> = { page: query.page, pageSize: query.pageSize }
if (query.username) params.username = query.username
if (query.ip) params.ip = query.ip
return http.get<SessionListResult>('/sessions', { params, signal })
}
/** 单会话强退。 */
export function kickSession(sid: string): Promise<unknown> {
return http.post(`/sessions/${sid}/kick`)
}

View File

@ -1,6 +1,6 @@
import { http } from '@/api/http'
export type UserStatus = 'enabled' | 'disabled' | 'locked'
export type UserStatus = 'enabled' | 'disabled'
export interface User {
id: string
@ -11,6 +11,8 @@ export interface User {
phone: string | null
avatar: string | null
status: UserStatus
lockedAt: number | null
lockReason: string | null
orgCode: string | null
createdAt: number
updatedAt: number
@ -86,3 +88,18 @@ export function listUserRoles(userId: string, signal?: AbortSignal): Promise<str
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
return http.put(`/users/${userId}/roles`, { roleCodes })
}
/** 踢出用户全部会话。 */
export function kickSessions(userId: string): Promise<unknown> {
return http.post(`/users/${userId}/kick-sessions`)
}
/** 锁定用户(可附原因)。 */
export function lockUser(userId: string, reason?: string): Promise<User> {
return http.post<User>(`/users/${userId}/lock`, { reason })
}
/** 解锁用户。 */
export function unlockUser(userId: string): Promise<User> {
return http.post<User>(`/users/${userId}/unlock`)
}

View File

@ -0,0 +1,128 @@
<template>
<div class="session-page">
<el-card class="session-page__search" shadow="never">
<ty-search-form :schema="searchSchema" :model-value="searchQuery" @search="handleSearch" @reset="handleReset" />
</el-card>
<el-card class="session-page__table" shadow="never">
<div class="session-page__table-body">
<ty-table
:columns="columns"
:data="list"
:loading="loading"
:total="total"
v-model:page="page"
v-model:page-size="pageSize"
:actions-width="150"
>
<template #actions="{ row }">
<el-button link type="danger" :disabled="!row.sid" @click="kick(row)">强退</el-button>
<el-button link type="warning" @click="kickAll(row)">踢全部</el-button>
</template>
</ty-table>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { JsonSchema, TableColumn } from '@/types/crud'
import { useRequest } from '@/composables/useRequest'
import { kickSession, listSessions } from '@/api/session'
import { kickSessions } from '@/api/user'
const columns: TableColumn[] = [
{ prop: 'username', label: '用户名', width: 120 },
{ prop: 'nickname', label: '昵称', width: 110 },
{ prop: 'ip', label: 'IP', width: 130 },
{ prop: 'userAgent', label: 'UA', minWidth: 200 },
{ prop: 'loginAt', label: '登录时间', width: 160, type: 'datetime' },
{ prop: 'expireAt', label: '过期时间', width: 160, type: 'datetime' },
]
const searchSchema: JsonSchema = {
type: 'object',
properties: {
username: { type: 'string', title: '用户名', placeholder: '请输入用户名' },
ip: { type: 'string', title: 'IP', placeholder: '请输入设备 IP' },
},
}
const page = ref(1)
const pageSize = ref(10)
const searchQuery = reactive({ username: '', ip: '' })
const appliedQuery = reactive({ username: '', ip: '' })
const { data, loading, run } = useRequest(
(signal: AbortSignal) => listSessions({ page: page.value, pageSize: pageSize.value, ...appliedQuery }, signal),
{ manual: true },
)
const list = computed(() => data.value?.list ?? [])
const total = computed(() => data.value?.total ?? 0)
function handleSearch(): void {
appliedQuery.username = searchQuery.username
appliedQuery.ip = searchQuery.ip
page.value = 1
run()
}
function handleReset(): void {
appliedQuery.username = ''
appliedQuery.ip = ''
page.value = 1
run()
}
async function kick(row: Record<string, any>): Promise<void> {
try {
await ElMessageBox.confirm(`确认强退「${row.username}」的这个会话?`, '提示', { type: 'warning' })
} catch {
return
}
await kickSession(String(row.sid))
ElMessage.success('已强退')
run()
}
async function kickAll(row: Record<string, any>): Promise<void> {
try {
await ElMessageBox.confirm(`确认踢出「${row.username}」的全部会话?`, '提示', { type: 'warning' })
} catch {
return
}
await kickSessions(String(row.userId))
ElMessage.success('已踢出全部会话')
run()
}
onMounted(run)
watch([page, pageSize], () => {
run()
})
</script>
<style scoped lang="scss">
.session-page {
@apply flex h-full flex-col;
}
.session-page__search {
@apply mb-md flex-shrink-0;
}
.session-page__table {
@apply flex min-h-0 flex-1 flex-col;
:deep(.el-card__body) {
@apply flex min-h-0 flex-1 flex-col;
}
}
.session-page__table-body {
@apply min-h-0 flex-1;
}
</style>

View File

@ -43,7 +43,9 @@ import {
createUser,
deleteUser,
listUsers,
lockUser,
resetPassword,
unlockUser,
updateUser,
type UserListQuery,
type UserStatus,
@ -63,7 +65,7 @@ const columns = computed<TableColumn[]>(() => {
{ prop: 'nickname', label: '昵称', minWidth: 100 },
{ prop: 'email', label: '邮箱', minWidth: 160 },
{ prop: 'phone', label: '手机', minWidth: 120 },
{ prop: 'status', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'user_status' },
{ prop: 'displayStatus', label: '状态', width: 80, align: 'center', type: 'tag', dictCode: 'user_status' },
{ prop: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
]
@ -78,7 +80,7 @@ const searchSchema: JsonSchema = {
status: {
type: 'string',
title: '状态',
dictCode: 'user_status',
dictCode: 'common_status',
placeholder: '全部',
},
},
@ -102,7 +104,7 @@ const formSchema = computed<JsonSchema>(() => ({
status: {
type: 'string',
title: '状态',
dictCode: 'user_status',
dictCode: 'common_status',
default: 'enabled',
},
orgCode: {
@ -121,6 +123,8 @@ const actions: CrudAction[] = [
'delete',
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(row) },
{ label: '锁定', type: 'danger', permission: 'user:lock', show: (row) => !row.lockedAt, onClick: (row) => lockAction(row) },
{ label: '解锁', type: 'success', permission: 'user:unlock', show: (row) => !!row.lockedAt, onClick: (row) => unlockAction(row) },
]
function toListQuery(query: PageQuery): UserListQuery {
@ -135,7 +139,13 @@ function toListQuery(query: PageQuery): UserListQuery {
}
}
const list = (query: PageQuery, signal?: AbortSignal) => listUsers(toListQuery(query), signal)
const list = async (query: PageQuery, signal?: AbortSignal) => {
const result = await listUsers(toListQuery(query), signal)
return {
...result,
list: result.list.map((u) => ({ ...u, displayStatus: u.lockedAt ? 'locked' : u.status })),
}
}
const create = (data: Record<string, any>) => createUser(data)
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
const remove = (id: string) => deleteUser(id)
@ -166,6 +176,34 @@ async function assignRolesAction(row: Record<string, any>): Promise<void> {
const crudRef = ref()
async function lockAction(row: Record<string, any>): Promise<void> {
let reason: string | undefined
try {
const res = await ElMessageBox.prompt(`锁定用户「${row.username}」`, '锁定用户', {
inputPlaceholder: '锁定原因(可选)',
confirmButtonText: '锁定',
cancelButtonText: '取消',
})
reason = res.value || undefined
} catch {
return // 用户取消
}
await lockUser(String(row.id), reason)
ElMessage.success('已锁定')
crudRef.value?.reload()
}
async function unlockAction(row: Record<string, any>): Promise<void> {
try {
await ElMessageBox.confirm(`确认解锁用户「${row.username}」?`, '提示', { type: 'warning' })
} catch {
return
}
await unlockUser(String(row.id))
ElMessage.success('已解锁')
crudRef.value?.reload()
}
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
const result = await batchDeleteUsers(rows.map((r) => String(r.id)))

13
typed-router.d.ts vendored
View File

@ -79,6 +79,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/monitor/session/': RouteRecordInfo<
'/monitor/session/',
'/monitor/session',
Record<never, never>,
Record<never, never>,
| never
>,
'/register': RouteRecordInfo<
'/register',
'/register',
@ -224,6 +231,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/monitor/session/index.vue': {
routes:
| '/monitor/session/'
views:
| never
}
'src/pages/register.vue': {
routes:
| '/register'