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:
parent
d37af1b73e
commit
ce45d93ce5
39
src/api/session.ts
Normal file
39
src/api/session.ts
Normal 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`)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { http } from '@/api/http'
|
import { http } from '@/api/http'
|
||||||
|
|
||||||
export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
export type UserStatus = 'enabled' | 'disabled'
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string
|
id: string
|
||||||
@ -11,6 +11,8 @@ export interface User {
|
|||||||
phone: string | null
|
phone: string | null
|
||||||
avatar: string | null
|
avatar: string | null
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
|
lockedAt: number | null
|
||||||
|
lockReason: string | null
|
||||||
orgCode: string | null
|
orgCode: string | null
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: 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> {
|
export function assignUserRoles(userId: string, roleCodes: string[]): Promise<unknown> {
|
||||||
return http.put(`/users/${userId}/roles`, { roleCodes })
|
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`)
|
||||||
|
}
|
||||||
|
|||||||
128
src/pages/monitor/session/index.vue
Normal file
128
src/pages/monitor/session/index.vue
Normal 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>
|
||||||
@ -43,7 +43,9 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
listUsers,
|
listUsers,
|
||||||
|
lockUser,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
|
unlockUser,
|
||||||
updateUser,
|
updateUser,
|
||||||
type UserListQuery,
|
type UserListQuery,
|
||||||
type UserStatus,
|
type UserStatus,
|
||||||
@ -63,7 +65,7 @@ const columns = computed<TableColumn[]>(() => {
|
|||||||
{ prop: 'nickname', label: '昵称', minWidth: 100 },
|
{ prop: 'nickname', label: '昵称', minWidth: 100 },
|
||||||
{ prop: 'email', label: '邮箱', minWidth: 160 },
|
{ prop: 'email', label: '邮箱', minWidth: 160 },
|
||||||
{ prop: 'phone', label: '手机', minWidth: 120 },
|
{ 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: 'orgCode', label: '所属组织', minWidth: 110, formatter: (v) => orgName(v) },
|
||||||
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
{ prop: 'createdAt', label: '创建时间', width: 160, type: 'datetime' },
|
||||||
]
|
]
|
||||||
@ -78,7 +80,7 @@ const searchSchema: JsonSchema = {
|
|||||||
status: {
|
status: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dictCode: 'user_status',
|
dictCode: 'common_status',
|
||||||
placeholder: '全部',
|
placeholder: '全部',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -102,7 +104,7 @@ const formSchema = computed<JsonSchema>(() => ({
|
|||||||
status: {
|
status: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dictCode: 'user_status',
|
dictCode: 'common_status',
|
||||||
default: 'enabled',
|
default: 'enabled',
|
||||||
},
|
},
|
||||||
orgCode: {
|
orgCode: {
|
||||||
@ -121,6 +123,8 @@ const actions: CrudAction[] = [
|
|||||||
'delete',
|
'delete',
|
||||||
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
|
{ label: '分配角色', type: 'primary', permission: 'user:assign-role', onClick: (row) => assignRolesAction(row) },
|
||||||
{ label: '重置密码', type: 'warning', permission: 'user:reset-pwd', onClick: (row) => resetPwd(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 {
|
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 create = (data: Record<string, any>) => createUser(data)
|
||||||
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
|
const update = (id: string, data: Record<string, any>) => updateUser(id, data)
|
||||||
const remove = (id: string) => deleteUser(id)
|
const remove = (id: string) => deleteUser(id)
|
||||||
@ -166,6 +176,34 @@ async function assignRolesAction(row: Record<string, any>): Promise<void> {
|
|||||||
|
|
||||||
const crudRef = ref()
|
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> {
|
async function batchDelete(rows: Record<string, any>[]): Promise<void> {
|
||||||
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
|
await ElMessageBox.confirm(`确认删除选中的 ${rows.length} 个用户?`, '提示', { type: 'warning' })
|
||||||
const result = await batchDeleteUsers(rows.map((r) => String(r.id)))
|
const result = await batchDeleteUsers(rows.map((r) => String(r.id)))
|
||||||
|
|||||||
13
typed-router.d.ts
vendored
13
typed-router.d.ts
vendored
@ -79,6 +79,13 @@ declare module 'vue-router/auto-routes' {
|
|||||||
Record<never, never>,
|
Record<never, never>,
|
||||||
| never
|
| never
|
||||||
>,
|
>,
|
||||||
|
'/monitor/session/': RouteRecordInfo<
|
||||||
|
'/monitor/session/',
|
||||||
|
'/monitor/session',
|
||||||
|
Record<never, never>,
|
||||||
|
Record<never, never>,
|
||||||
|
| never
|
||||||
|
>,
|
||||||
'/register': RouteRecordInfo<
|
'/register': RouteRecordInfo<
|
||||||
'/register',
|
'/register',
|
||||||
'/register',
|
'/register',
|
||||||
@ -224,6 +231,12 @@ declare module 'vue-router/auto-routes' {
|
|||||||
views:
|
views:
|
||||||
| never
|
| never
|
||||||
}
|
}
|
||||||
|
'src/pages/monitor/session/index.vue': {
|
||||||
|
routes:
|
||||||
|
| '/monitor/session/'
|
||||||
|
views:
|
||||||
|
| never
|
||||||
|
}
|
||||||
'src/pages/register.vue': {
|
'src/pages/register.vue': {
|
||||||
routes:
|
routes:
|
||||||
| '/register'
|
| '/register'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user