committed by
GitHub
45 changed files with 1313 additions and 19 deletions
@ -0,0 +1,48 @@ |
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */ |
|||
import type { Notification as NotificationInfo } from '@abp/notifications'; |
|||
|
|||
import { onMounted, onUnmounted } from 'vue'; |
|||
|
|||
import { useAbpStore, useEventBus } from '@abp/core'; |
|||
import { NotificationNames, useNotifications } from '@abp/notifications'; |
|||
import { Modal } from 'ant-design-vue'; |
|||
|
|||
import { useAuthStore } from '#/store'; |
|||
|
|||
export function useSessions() { |
|||
const authStore = useAuthStore(); |
|||
const abpStore = useAbpStore(); |
|||
const { subscribe, unSubscribe } = useEventBus(); |
|||
const { register, release } = useNotifications(); |
|||
|
|||
function _register() { |
|||
subscribe(NotificationNames.SessionExpiration, _onSessionExpired); |
|||
register(); |
|||
} |
|||
|
|||
function _release() { |
|||
unSubscribe(NotificationNames.SessionExpiration, _onSessionExpired); |
|||
release(); |
|||
} |
|||
|
|||
/** 处理会话过期事件 */ |
|||
function _onSessionExpired(event?: NotificationInfo) { |
|||
const { data, title, message } = event!; |
|||
const sessionId = data.SessionId; |
|||
if (sessionId === abpStore.application?.currentUser?.sessionId) { |
|||
_release(); |
|||
Modal.confirm({ |
|||
iconType: 'warning', |
|||
title, |
|||
content: message, |
|||
centered: true, |
|||
afterClose: () => { |
|||
authStore.logout(); |
|||
}, |
|||
}); |
|||
} |
|||
} |
|||
|
|||
onMounted(_register); |
|||
onUnmounted(_release); |
|||
} |
|||
@ -0,0 +1,15 @@ |
|||
<script lang="ts" setup> |
|||
import { Page } from '@vben/common-ui'; |
|||
|
|||
import { SessionTable } from '@abp/identity'; |
|||
|
|||
defineOptions({ |
|||
name: 'IdentitySessions', |
|||
}); |
|||
</script> |
|||
|
|||
<template> |
|||
<Page> |
|||
<SessionTable /> |
|||
</Page> |
|||
</template> |
|||
@ -1,4 +1,5 @@ |
|||
export { useAccountApi } from './useAccountApi'; |
|||
export { useMySessionApi } from './useMySessionApi'; |
|||
export { useProfileApi } from './useProfileApi'; |
|||
export { useTokenApi } from './useTokenApi'; |
|||
export { useUserInfoApi } from './useUserInfoApi'; |
|||
|
|||
@ -0,0 +1,41 @@ |
|||
import type { PagedResultDto } from '@abp/core'; |
|||
import type { GetMySessionsInput, IdentitySessionDto } from '@abp/identity'; |
|||
|
|||
import { useRequest } from '@abp/request'; |
|||
|
|||
export function useMySessionApi() { |
|||
const { cancel, request } = useRequest(); |
|||
|
|||
/** |
|||
* 查询会话列表 |
|||
* @param { GetMySessionsInput } input 查询参数 |
|||
* @returns { Promise<PagedResultDto<IdentitySessionDto>> } 用户会话列表 |
|||
*/ |
|||
function getSessionsApi( |
|||
input?: GetMySessionsInput, |
|||
): Promise<PagedResultDto<IdentitySessionDto>> { |
|||
return request<PagedResultDto<IdentitySessionDto>>( |
|||
'/api/account/my-profile/sessions', |
|||
{ |
|||
method: 'GET', |
|||
params: input, |
|||
}, |
|||
); |
|||
} |
|||
/** |
|||
* 撤销会话 |
|||
* @param { string } sessionId 会话id |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
function revokeSessionApi(sessionId: string): Promise<void> { |
|||
return request(`/api/account/my-profile/sessions/${sessionId}/revoke`, { |
|||
method: 'DELETE', |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
cancel, |
|||
getSessionsApi, |
|||
revokeSessionApi, |
|||
}; |
|||
} |
|||
@ -0,0 +1,48 @@ |
|||
<script setup lang="ts"> |
|||
import type { IdentitySessionDto } from '@abp/identity'; |
|||
|
|||
import { onMounted, ref } from 'vue'; |
|||
|
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { UserSessionTable } from '@abp/identity'; |
|||
import { Card, message, Modal } from 'ant-design-vue'; |
|||
|
|||
import { useMySessionApi } from '../../api/useMySessionApi'; |
|||
|
|||
const { cancel, getSessionsApi, revokeSessionApi } = useMySessionApi(); |
|||
|
|||
const sessions = ref<IdentitySessionDto[]>([]); |
|||
|
|||
async function getSessions() { |
|||
const { items } = await getSessionsApi(); |
|||
sessions.value = items; |
|||
} |
|||
|
|||
async function onRevoke(session: IdentitySessionDto) { |
|||
Modal.confirm({ |
|||
centered: true, |
|||
content: $t('AbpIdentity.SessionWillBeRevokedMessage'), |
|||
iconType: 'warning', |
|||
onCancel: () => { |
|||
cancel(); |
|||
}, |
|||
onOk: async () => { |
|||
await revokeSessionApi(session.sessionId); |
|||
message.success($t('AbpIdentity.SuccessfullyRevoked')); |
|||
await getSessions(); |
|||
}, |
|||
title: $t('AbpUi.AreYouSure'), |
|||
}); |
|||
} |
|||
|
|||
onMounted(getSessions); |
|||
</script> |
|||
|
|||
<template> |
|||
<Card :bordered="false" :title="$t('abp.account.settings.sessionSettings')"> |
|||
<UserSessionTable :sessions="sessions" @revoke="onRevoke" /> |
|||
</Card> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,10 @@ |
|||
export const Events = { |
|||
/** 收到服务器消息 */ |
|||
GetNotification: 'get-notification', |
|||
/** 新通知消息 */ |
|||
NotificationRecevied: 'sys_notifications_recevied', |
|||
/** 用户登录事件 */ |
|||
UserLogin: 'sys_user_login', |
|||
/** 用户登出事件 */ |
|||
UserLogout: 'sys_user_logout', |
|||
}; |
|||
@ -1 +1,2 @@ |
|||
export * from './events'; |
|||
export * from './validation'; |
|||
|
|||
@ -0,0 +1,30 @@ |
|||
import type { EventType, Handler, WildcardHandler } from '../utils/mitt'; |
|||
|
|||
import mitt from '../utils/mitt'; |
|||
|
|||
const emitter = mitt(); |
|||
|
|||
interface EventBus { |
|||
/** 发布事件 */ |
|||
publish(type: '*', event?: any): void; |
|||
/** 发布事件 */ |
|||
publish<T = any>(type: EventType, event?: T): void; |
|||
|
|||
/** 订阅事件 */ |
|||
subscribe(type: '*', handler: WildcardHandler): void; |
|||
/** 订阅事件 */ |
|||
subscribe<T = any>(type: EventType, handler: Handler<T>): void; |
|||
|
|||
/** 退订事件 */ |
|||
unSubscribe(type: '*', handler: WildcardHandler): void; |
|||
/** 退订事件 */ |
|||
unSubscribe<T = any>(type: EventType, handler: Handler<T>): void; |
|||
} |
|||
|
|||
export function useEventBus(): EventBus { |
|||
return { |
|||
publish: emitter.emit, |
|||
subscribe: emitter.on, |
|||
unSubscribe: emitter.off, |
|||
}; |
|||
} |
|||
@ -1,4 +1,5 @@ |
|||
export * from './date'; |
|||
export * from './mitt'; |
|||
export * from './regex'; |
|||
export * from './string'; |
|||
export * from './tree'; |
|||
|
|||
@ -0,0 +1,107 @@ |
|||
/* eslint-disable array-callback-return */ |
|||
/** |
|||
* copy to https://github.com/developit/mitt
|
|||
* Expand clear method |
|||
*/ |
|||
|
|||
export type EventType = string | symbol; |
|||
|
|||
// An event handler can take an optional event argument
|
|||
// and should not return a value
|
|||
export type Handler<T = any> = (event?: T) => void; |
|||
export type WildcardHandler = (type: EventType, event?: any) => void; |
|||
|
|||
// An array of all currently registered event handlers for a type
|
|||
export type EventHandlerList = Array<Handler>; |
|||
export type WildCardEventHandlerList = Array<WildcardHandler>; |
|||
|
|||
// A map of event types and their corresponding event handlers.
|
|||
export type EventHandlerMap = Map< |
|||
EventType, |
|||
EventHandlerList | WildCardEventHandlerList |
|||
>; |
|||
|
|||
export interface Emitter { |
|||
all: EventHandlerMap; |
|||
|
|||
clear(): void; |
|||
emit(type: '*', event?: any): void; |
|||
|
|||
emit<T = any>(type: EventType, event?: T): void; |
|||
off(type: '*', handler: WildcardHandler): void; |
|||
|
|||
off<T = any>(type: EventType, handler: Handler<T>): void; |
|||
on(type: '*', handler: WildcardHandler): void; |
|||
on<T = any>(type: EventType, handler: Handler<T>): void; |
|||
} |
|||
|
|||
/** |
|||
* Mitt: Tiny (~200b) functional event emitter / pubsub. |
|||
* @name mitt |
|||
* @returns {Mitt} Emitter |
|||
*/ |
|||
export default function mitt(all?: EventHandlerMap): Emitter { |
|||
all = all || new Map(); |
|||
|
|||
return { |
|||
/** |
|||
* A Map of event names to registered handler functions. |
|||
*/ |
|||
all, |
|||
|
|||
/** |
|||
* Clear all |
|||
*/ |
|||
clear() { |
|||
this.all.clear(); |
|||
}, |
|||
|
|||
/** |
|||
* Invoke all handlers for the given type. |
|||
* If present, `"*"` handlers are invoked after type-matched handlers. |
|||
* |
|||
* Note: Manually firing "*" handlers is not supported. |
|||
* |
|||
* @param {string|symbol} type The event type to invoke |
|||
* @param {Any} [evt] Any value (object is recommended and powerful), passed to each handler |
|||
* @memberOf mitt |
|||
*/ |
|||
emit<T = any>(type: EventType, evt: T) { |
|||
[...((all?.get(type) || []) as EventHandlerList)].map((handler) => { |
|||
handler(evt); |
|||
}); |
|||
[...((all?.get('*') || []) as WildCardEventHandlerList)].map( |
|||
(handler) => { |
|||
handler(type, evt); |
|||
}, |
|||
); |
|||
}, |
|||
|
|||
/** |
|||
* Remove an event handler for the given type. |
|||
* @param {string|symbol} type Type of event to unregister `handler` from, or `"*"` |
|||
* @param {Function} handler Handler function to remove |
|||
* @memberOf mitt |
|||
*/ |
|||
off<T = any>(type: EventType, handler: Handler<T>) { |
|||
const handlers = all?.get(type); |
|||
if (handlers) { |
|||
handlers.splice(handlers.indexOf(handler) >>> 0, 1); |
|||
} |
|||
}, |
|||
|
|||
/** |
|||
* Register an event handler for the given type. |
|||
* @param {string|symbol} type Type of event to listen for, or `"*"` for all events |
|||
* @param {Function} handler Function to call in response to given event |
|||
* @memberOf mitt |
|||
*/ |
|||
on<T = any>(type: EventType, handler: Handler<T>) { |
|||
const handlers = all?.get(type); |
|||
const added = handlers && handlers.push(handler); |
|||
if (!added) { |
|||
all?.set(type, [handler]); |
|||
} |
|||
}, |
|||
}; |
|||
} |
|||
@ -0,0 +1,42 @@ |
|||
import type { PagedResultDto } from '@abp/core'; |
|||
|
|||
import type { GetUserSessionsInput, IdentitySessionDto } from '../types'; |
|||
|
|||
import { useRequest } from '@abp/request'; |
|||
|
|||
export function useUserSessionsApi() { |
|||
const { cancel, request } = useRequest(); |
|||
|
|||
/** |
|||
* 查询会话列表 |
|||
* @param { GetUserSessionsInput } input 查询参数 |
|||
* @returns { Promise<PagedResultDto<IdentitySessionDto>> } 用户会话列表 |
|||
*/ |
|||
function getSessionsApi( |
|||
input?: GetUserSessionsInput, |
|||
): Promise<PagedResultDto<IdentitySessionDto>> { |
|||
return request<PagedResultDto<IdentitySessionDto>>( |
|||
'/api/identity/sessions', |
|||
{ |
|||
method: 'GET', |
|||
params: input, |
|||
}, |
|||
); |
|||
} |
|||
/** |
|||
* 撤销会话 |
|||
* @param { string } sessionId 会话id |
|||
* @returns { Promise<void> } |
|||
*/ |
|||
function revokeSessionApi(sessionId: string): Promise<void> { |
|||
return request(`/api/identity/sessions/${sessionId}/revoke`, { |
|||
method: 'DELETE', |
|||
}); |
|||
} |
|||
|
|||
return { |
|||
cancel, |
|||
getSessionsApi, |
|||
revokeSessionApi, |
|||
}; |
|||
} |
|||
@ -0,0 +1,243 @@ |
|||
<script setup lang="ts"> |
|||
import type { VbenFormProps, VxeGridListeners, VxeGridProps } from '@abp/ui'; |
|||
import type { SelectValue } from 'ant-design-vue/es/select'; |
|||
|
|||
import type { IdentityUserDto } from '../../types'; |
|||
import type { IdentitySessionDto } from '../../types/sessions'; |
|||
|
|||
import { computed, h, onMounted, ref } from 'vue'; |
|||
|
|||
import { useAccess } from '@vben/access'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { useAbpStore } from '@abp/core'; |
|||
import { useVbenVxeGrid } from '@abp/ui'; |
|||
import { DeleteOutlined } from '@ant-design/icons-vue'; |
|||
import { Button, message, Modal, Select, Tag } from 'ant-design-vue'; |
|||
import debounce from 'lodash.debounce'; |
|||
|
|||
import { useUsersApi } from '../../api/useUsersApi'; |
|||
import { useUserSessionsApi } from '../../api/useUserSessionsApi'; |
|||
import { IdentitySessionPermissions } from '../../constants/permissions'; |
|||
|
|||
defineOptions({ |
|||
name: 'SessionTable', |
|||
}); |
|||
const { hasAccessByCodes } = useAccess(); |
|||
const { getPagedListApi: getUserListApi } = useUsersApi(); |
|||
const { cancel, getSessionsApi, revokeSessionApi } = useUserSessionsApi(); |
|||
|
|||
const abpStore = useAbpStore(); |
|||
|
|||
const users = ref<IdentityUserDto[]>([]); |
|||
|
|||
/** 获取登录用户会话Id */ |
|||
const getMySessionId = computed(() => { |
|||
return abpStore.application?.currentUser.sessionId; |
|||
}); |
|||
/** 获取是否允许撤销会话 */ |
|||
const getAllowRevokeSession = computed(() => { |
|||
return (session: IdentitySessionDto) => { |
|||
return getMySessionId.value !== session.sessionId; |
|||
}; |
|||
}); |
|||
|
|||
const formOptions: VbenFormProps = { |
|||
// 默认展开 |
|||
collapsed: false, |
|||
schema: [ |
|||
{ |
|||
component: 'Select', |
|||
fieldName: 'userId', |
|||
label: $t('AbpIdentity.DisplayName:UserName'), |
|||
}, |
|||
{ |
|||
component: 'Input', |
|||
fieldName: 'clientId', |
|||
label: $t('AbpIdentity.DisplayName:ClientId'), |
|||
}, |
|||
{ |
|||
component: 'Input', |
|||
fieldName: 'device', |
|||
label: $t('AbpIdentity.DisplayName:Device'), |
|||
}, |
|||
{ |
|||
component: 'Input', |
|||
fieldName: 'filter', |
|||
formItemClass: 'col-span-2 items-baseline', |
|||
label: $t('AbpUi.Search'), |
|||
}, |
|||
], |
|||
// 控制表单是否显示折叠按钮 |
|||
showCollapseButton: true, |
|||
// 按下回车时是否提交表单 |
|||
submitOnEnter: true, |
|||
}; |
|||
|
|||
const gridOptions: VxeGridProps<IdentitySessionDto> = { |
|||
columns: [ |
|||
{ |
|||
align: 'left', |
|||
field: 'sessionId', |
|||
minWidth: 150, |
|||
title: $t('AbpIdentity.DisplayName:SessionId'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'device', |
|||
minWidth: 120, |
|||
slots: { default: 'device' }, |
|||
title: $t('AbpIdentity.DisplayName:Device'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'deviceInfo', |
|||
title: $t('AbpIdentity.DisplayName:DeviceInfo'), |
|||
width: 'auto', |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'clientId', |
|||
minWidth: 120, |
|||
title: $t('AbpIdentity.DisplayName:ClientId'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'ipAddresses', |
|||
minWidth: 120, |
|||
title: $t('AbpIdentity.DisplayName:IpAddresses'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'signedIn', |
|||
minWidth: 120, |
|||
title: $t('AbpIdentity.DisplayName:SignedIn'), |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'lastAccessed', |
|||
minWidth: 120, |
|||
title: $t('AbpIdentity.DisplayName:LastAccessed'), |
|||
}, |
|||
{ |
|||
field: 'action', |
|||
fixed: 'right', |
|||
slots: { default: 'action' }, |
|||
title: $t('AbpUi.Actions'), |
|||
visible: hasAccessByCodes([ |
|||
IdentitySessionPermissions.Default, |
|||
IdentitySessionPermissions.Revoke, |
|||
]), |
|||
width: 150, |
|||
}, |
|||
], |
|||
exportConfig: {}, |
|||
keepSource: true, |
|||
proxyConfig: { |
|||
ajax: { |
|||
query: async ({ page }, formValues) => { |
|||
return await getSessionsApi({ |
|||
maxResultCount: page.pageSize, |
|||
skipCount: (page.currentPage - 1) * page.pageSize, |
|||
...formValues, |
|||
}); |
|||
}, |
|||
}, |
|||
response: { |
|||
total: 'totalCount', |
|||
list: 'items', |
|||
}, |
|||
}, |
|||
toolbarConfig: { |
|||
custom: true, |
|||
export: true, |
|||
// import: true, |
|||
refresh: true, |
|||
zoom: true, |
|||
}, |
|||
}; |
|||
|
|||
const gridEvents: VxeGridListeners<IdentitySessionDto> = { |
|||
cellClick: () => {}, |
|||
}; |
|||
|
|||
const [Grid, gridApi] = useVbenVxeGrid({ |
|||
formOptions, |
|||
gridEvents, |
|||
gridOptions, |
|||
}); |
|||
|
|||
const onDelete = (row: IdentitySessionDto) => { |
|||
Modal.confirm({ |
|||
centered: true, |
|||
content: $t('AbpIdentity.SessionWillBeRevokedMessage'), |
|||
onCancel: () => { |
|||
cancel(); |
|||
}, |
|||
onOk: async () => { |
|||
await revokeSessionApi(row.sessionId); |
|||
message.success($t('AbpIdentity.SuccessfullyRevoked')); |
|||
await gridApi.query(); |
|||
}, |
|||
title: $t('AbpUi.AreYouSure'), |
|||
}); |
|||
}; |
|||
|
|||
const onGetUsers = debounce(async (filter?: string) => { |
|||
const { items } = await getUserListApi({ filter }); |
|||
users.value = items; |
|||
}, 500); |
|||
|
|||
function onFieldChange(field: string, value?: SelectValue) { |
|||
gridApi.formApi.setFieldValue(field, value); |
|||
} |
|||
|
|||
onMounted(onGetUsers); |
|||
</script> |
|||
|
|||
<template> |
|||
<Grid :table-title="$t('AbpIdentity.IdentitySessions')"> |
|||
<template #form-userId="{ modelValue }"> |
|||
<Select |
|||
:default-active-first-option="false" |
|||
:field-names="{ label: 'userName', value: 'id' }" |
|||
:filter-option="false" |
|||
:options="users" |
|||
:placeholder="$t('ui.placeholder.select')" |
|||
:value="modelValue" |
|||
allow-clear |
|||
class="w-full" |
|||
show-search |
|||
@change="(val) => onFieldChange('userId', val)" |
|||
@search="onGetUsers" |
|||
/> |
|||
</template> |
|||
<template #device="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<span>{{ row.device }}</span> |
|||
<div class="pl-[5px]"> |
|||
<Tag v-if="row.sessionId === getMySessionId" color="#87d068"> |
|||
{{ $t('AbpIdentity.CurrentSession') }} |
|||
</Tag> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<template #action="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<Button |
|||
v-if="getAllowRevokeSession(row)" |
|||
:icon="h(DeleteOutlined)" |
|||
block |
|||
danger |
|||
type="link" |
|||
v-access:code="[IdentitySessionPermissions.Revoke]" |
|||
@click="onDelete(row)" |
|||
> |
|||
{{ $t('AbpIdentity.RevokeSession') }} |
|||
</Button> |
|||
</div> |
|||
</template> |
|||
</Grid> |
|||
</template> |
|||
|
|||
<style lang="scss" scoped></style> |
|||
@ -0,0 +1,158 @@ |
|||
<script setup lang="ts"> |
|||
import type { VxeGridProps } from '@abp/ui'; |
|||
|
|||
import type { IdentitySessionDto } from '../../types/sessions'; |
|||
|
|||
import { computed, nextTick, reactive, watch } from 'vue'; |
|||
|
|||
import { useAccess } from '@vben/access'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { useAbpStore } from '@abp/core'; |
|||
import { useVbenVxeGrid } from '@abp/ui'; |
|||
import { Button, Descriptions, Tag } from 'ant-design-vue'; |
|||
|
|||
import { IdentitySessionPermissions } from '../../constants/permissions'; |
|||
|
|||
const props = defineProps<{ |
|||
sessions: IdentitySessionDto[]; |
|||
}>(); |
|||
const emits = defineEmits<{ |
|||
(event: 'revoke', session: IdentitySessionDto): void; |
|||
}>(); |
|||
const DescriptionItem = Descriptions.Item; |
|||
|
|||
const { hasAccessByCodes } = useAccess(); |
|||
const abpStore = useAbpStore(); |
|||
/** 获取登录用户会话Id */ |
|||
const getMySessionId = computed(() => { |
|||
return abpStore.application?.currentUser.sessionId; |
|||
}); |
|||
/** 获取是否允许撤销会话 */ |
|||
const getAllowRevokeSession = computed(() => { |
|||
return (session: IdentitySessionDto) => { |
|||
if (getMySessionId.value === session.sessionId) { |
|||
return false; |
|||
} |
|||
return hasAccessByCodes([IdentitySessionPermissions.Revoke]); |
|||
}; |
|||
}); |
|||
|
|||
const gridOptions = reactive<VxeGridProps<IdentitySessionDto>>({ |
|||
columns: [ |
|||
{ |
|||
align: 'left', |
|||
slots: { content: 'deviceInfo' }, |
|||
type: 'expand', |
|||
width: 50, |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'device', |
|||
slots: { default: 'device' }, |
|||
title: $t('AbpIdentity.DisplayName:Device'), |
|||
width: 150, |
|||
}, |
|||
{ |
|||
align: 'left', |
|||
field: 'signedIn', |
|||
minWidth: 200, |
|||
title: $t('AbpIdentity.DisplayName:SignedIn'), |
|||
}, |
|||
{ |
|||
field: 'action', |
|||
fixed: 'right', |
|||
slots: { default: 'action' }, |
|||
title: $t('AbpUi.Actions'), |
|||
width: 120, |
|||
}, |
|||
], |
|||
expandConfig: { |
|||
padding: true, |
|||
trigger: 'default', |
|||
}, |
|||
exportConfig: {}, |
|||
keepSource: true, |
|||
pagerConfig: { |
|||
autoHidden: true, |
|||
}, |
|||
toolbarConfig: {}, |
|||
}); |
|||
|
|||
const [Grid, gridApi] = useVbenVxeGrid({ gridOptions }); |
|||
|
|||
watch( |
|||
() => props.sessions, |
|||
(sessions) => { |
|||
nextTick(() => { |
|||
gridApi.setGridOptions({ |
|||
data: sessions, |
|||
}); |
|||
}); |
|||
}, |
|||
{ |
|||
immediate: true, |
|||
}, |
|||
); |
|||
|
|||
function onDelete(session: IdentitySessionDto) { |
|||
emits('revoke', session); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Grid> |
|||
<template #device="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<span>{{ row.device }}</span> |
|||
<div class="pl-[5px]"> |
|||
<Tag v-if="row.sessionId === getMySessionId" color="#87d068"> |
|||
{{ $t('AbpIdentity.CurrentSession') }} |
|||
</Tag> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
<template #deviceInfo="{ row }"> |
|||
<Descriptions :colon="false" :column="2" bordered size="small"> |
|||
<DescriptionItem |
|||
:label="$t('AbpIdentity.DisplayName:SessionId')" |
|||
:span="2" |
|||
> |
|||
{{ row.sessionId }} |
|||
</DescriptionItem> |
|||
<DescriptionItem :label="$t('AbpIdentity.DisplayName:Device')"> |
|||
{{ row.device }} |
|||
</DescriptionItem> |
|||
<DescriptionItem :label="$t('AbpIdentity.DisplayName:DeviceInfo')"> |
|||
{{ row.deviceInfo }} |
|||
</DescriptionItem> |
|||
<DescriptionItem :label="$t('AbpIdentity.DisplayName:ClientId')"> |
|||
{{ row.clientId }} |
|||
</DescriptionItem> |
|||
<DescriptionItem :label="$t('AbpIdentity.DisplayName:IpAddresses')"> |
|||
{{ row.ipAddresses }} |
|||
</DescriptionItem> |
|||
<DescriptionItem :label="$t('AbpIdentity.DisplayName:SignedIn')"> |
|||
{{ row.signedIn }} |
|||
</DescriptionItem> |
|||
<DescriptionItem :label="$t('AbpIdentity.DisplayName:LastAccessed')"> |
|||
{{ row.lastAccessed }} |
|||
</DescriptionItem> |
|||
</Descriptions> |
|||
</template> |
|||
<template #action="{ row }"> |
|||
<div class="flex flex-row"> |
|||
<Button |
|||
v-if="getAllowRevokeSession(row)" |
|||
danger |
|||
size="small" |
|||
@click="onDelete(row)" |
|||
> |
|||
{{ $t('AbpIdentity.RevokeSession') }} |
|||
</Button> |
|||
</div> |
|||
</template> |
|||
</Grid> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,67 @@ |
|||
<script setup lang="ts"> |
|||
import type { IdentitySessionDto, IdentityUserDto } from '../../types'; |
|||
|
|||
import { defineAsyncComponent, ref } from 'vue'; |
|||
|
|||
import { useVbenDrawer } from '@vben/common-ui'; |
|||
import { $t } from '@vben/locales'; |
|||
|
|||
import { message, Modal } from 'ant-design-vue'; |
|||
|
|||
import { useUserSessionsApi } from '../../api/useUserSessionsApi'; |
|||
|
|||
const UserSessionTable = defineAsyncComponent( |
|||
() => import('../sessions/UserSessionTable.vue'), |
|||
); |
|||
|
|||
const sessions = ref<IdentitySessionDto[]>([]); |
|||
|
|||
const { cancel, getSessionsApi, revokeSessionApi } = useUserSessionsApi(); |
|||
|
|||
const [Drawer, drawerApi] = useVbenDrawer({ |
|||
class: 'w-[800px]', |
|||
onBeforeClose: cancel, |
|||
onCancel() { |
|||
drawerApi.close(); |
|||
}, |
|||
onConfirm: async () => {}, |
|||
onOpenChange: async (isOpen: boolean) => { |
|||
isOpen && (await onRefresh()); |
|||
}, |
|||
title: $t('AbpIdentity.IdentitySessions'), |
|||
}); |
|||
async function onRefresh() { |
|||
try { |
|||
drawerApi.setState({ loading: true }); |
|||
const dto = drawerApi.getData<IdentityUserDto>(); |
|||
const { items } = await getSessionsApi({ userId: dto.id }); |
|||
sessions.value = items; |
|||
} finally { |
|||
drawerApi.setState({ loading: false }); |
|||
} |
|||
} |
|||
async function onRevoke(session: IdentitySessionDto) { |
|||
Modal.confirm({ |
|||
centered: true, |
|||
content: $t('AbpIdentity.SessionWillBeRevokedMessage'), |
|||
iconType: 'warning', |
|||
onCancel: () => { |
|||
cancel(); |
|||
}, |
|||
onOk: async () => { |
|||
await revokeSessionApi(session.sessionId); |
|||
message.success($t('AbpIdentity.SuccessfullyRevoked')); |
|||
await onRefresh(); |
|||
}, |
|||
title: $t('AbpUi.AreYouSure'), |
|||
}); |
|||
} |
|||
</script> |
|||
|
|||
<template> |
|||
<Drawer> |
|||
<UserSessionTable :sessions="sessions" @revoke="onRevoke" /> |
|||
</Drawer> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -0,0 +1,25 @@ |
|||
import type { EntityDto, PagedAndSortedResultRequestDto } from '@abp/core'; |
|||
|
|||
interface IdentitySessionDto extends EntityDto<string> { |
|||
clientId?: string; |
|||
device: string; |
|||
deviceInfo: string; |
|||
ipAddresses?: string; |
|||
lastAccessed?: Date; |
|||
sessionId: string; |
|||
signedIn: Date; |
|||
userId: string; |
|||
} |
|||
|
|||
interface GetUserSessionsInput extends PagedAndSortedResultRequestDto { |
|||
clientId?: string; |
|||
device?: string; |
|||
userId?: string; |
|||
} |
|||
|
|||
interface GetMySessionsInput extends PagedAndSortedResultRequestDto { |
|||
clientId?: string; |
|||
device?: string; |
|||
} |
|||
|
|||
export type { GetMySessionsInput, GetUserSessionsInput, IdentitySessionDto }; |
|||
@ -0,0 +1 @@ |
|||
export * from './notifications'; |
|||
@ -0,0 +1,4 @@ |
|||
export const NotificationNames = { |
|||
/** 会话过期通知 */ |
|||
SessionExpiration: 'AbpIdentity.Session.Expiration', |
|||
}; |
|||
@ -0,0 +1,2 @@ |
|||
export * from './useNotifications'; |
|||
export * from './useNotificationSerializer'; |
|||
@ -0,0 +1,56 @@ |
|||
import type { Notification, NotificationInfo } from '../types'; |
|||
|
|||
import { useLocalization } from '@abp/core'; |
|||
|
|||
export function useNotificationSerializer() { |
|||
function deserialize(notificationInfo: NotificationInfo): Notification { |
|||
const { data } = notificationInfo; |
|||
let title = data.extraProperties.title; |
|||
let message = data.extraProperties.message; |
|||
let description = data.extraProperties.description; |
|||
if (data.extraProperties.L === true || data.extraProperties.L === 'true') { |
|||
const { L } = useLocalization([ |
|||
data.extraProperties.title.resourceName ?? |
|||
data.extraProperties.title.ResourceName, |
|||
data.extraProperties.message.resourceName ?? |
|||
data.extraProperties.message.ResourceName, |
|||
data.extraProperties.description?.resourceName ?? |
|||
data.extraProperties.description?.ResourceName ?? |
|||
'AbpUi', |
|||
]); |
|||
title = L( |
|||
data.extraProperties.title.name ?? data.extraProperties.title.Name, |
|||
data.extraProperties.title.values ?? data.extraProperties.title.Values, |
|||
); |
|||
message = L( |
|||
data.extraProperties.message.name ?? data.extraProperties.message.Name, |
|||
data.extraProperties.message.values ?? |
|||
data.extraProperties.message.Values, |
|||
); |
|||
if (description) { |
|||
description = L( |
|||
data.extraProperties.description.name ?? |
|||
data.extraProperties.description.Name, |
|||
data.extraProperties.description.values ?? |
|||
data.extraProperties.description.Values, |
|||
); |
|||
} |
|||
} |
|||
return { |
|||
contentType: notificationInfo.contentType, |
|||
creationTime: notificationInfo.creationTime, |
|||
data: data.extraProperties, |
|||
description, |
|||
lifetime: notificationInfo.lifetime, |
|||
message, |
|||
name: notificationInfo.name, |
|||
severity: notificationInfo.severity, |
|||
title, |
|||
type: notificationInfo.type, |
|||
}; |
|||
} |
|||
|
|||
return { |
|||
deserialize, |
|||
}; |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
import type { Notification, NotificationInfo } from '../types/notifications'; |
|||
|
|||
import { Events, useEventBus } from '@abp/core'; |
|||
import { useSignalR } from '@abp/signalr'; |
|||
import { notification } from 'ant-design-vue'; |
|||
|
|||
import { |
|||
NotificationContentType, |
|||
NotificationSeverity, |
|||
NotificationType, |
|||
} from '../types/notifications'; |
|||
import { useNotificationSerializer } from './useNotificationSerializer'; |
|||
|
|||
export function useNotifications() { |
|||
const { deserialize } = useNotificationSerializer(); |
|||
const { publish, subscribe, unSubscribe } = useEventBus(); |
|||
const { init, off, on, onStart, stop } = useSignalR(); |
|||
|
|||
/** 注册通知 */ |
|||
function register() { |
|||
_registerEvents(); |
|||
_init(); |
|||
} |
|||
|
|||
/** 释放通知 */ |
|||
function release() { |
|||
_releaseEvents(); |
|||
stop(); |
|||
} |
|||
|
|||
function _init() { |
|||
onStart(() => on(Events.GetNotification, _onNotifyReceived)); |
|||
init({ |
|||
autoStart: true, |
|||
serverUrl: '/signalr-hubs/notifications', |
|||
useAccessToken: true, |
|||
}); |
|||
} |
|||
|
|||
/** 注册通知事件 */ |
|||
function _registerEvents() { |
|||
subscribe(Events.UserLogout, stop); |
|||
} |
|||
|
|||
/** 释放通知事件 */ |
|||
function _releaseEvents() { |
|||
unSubscribe(Events.UserLogout, stop); |
|||
off(Events.GetNotification, _onNotifyReceived); |
|||
} |
|||
|
|||
/** 接收通知回调 */ |
|||
function _onNotifyReceived(notificationInfo: NotificationInfo) { |
|||
const notification = deserialize(notificationInfo); |
|||
if (notification.type === NotificationType.ServiceCallback) { |
|||
publish(notification.name, notification); |
|||
return; |
|||
} |
|||
publish(Events.NotificationRecevied, notification); |
|||
_notification(notification); |
|||
} |
|||
|
|||
/** 通知推送 */ |
|||
function _notification(notifier: Notification) { |
|||
let message = notifier.description; |
|||
switch (notifier.contentType) { |
|||
case NotificationContentType.Html: |
|||
case NotificationContentType.Json: |
|||
case NotificationContentType.Markdown: { |
|||
message = notifier.title; |
|||
break; |
|||
} |
|||
case NotificationContentType.Text: { |
|||
message = notifier.description; |
|||
} |
|||
} |
|||
switch (notifier.severity) { |
|||
case NotificationSeverity.Error: |
|||
case NotificationSeverity.Fatal: { |
|||
notification.error({ |
|||
description: message, |
|||
message: notifier.title, |
|||
}); |
|||
break; |
|||
} |
|||
case NotificationSeverity.Info: { |
|||
notification.info({ |
|||
description: message, |
|||
message: notifier.title, |
|||
}); |
|||
break; |
|||
} |
|||
case NotificationSeverity.Success: { |
|||
notification.success({ |
|||
description: message, |
|||
message: notifier.title, |
|||
}); |
|||
break; |
|||
} |
|||
case NotificationSeverity.Warn: { |
|||
notification.warning({ |
|||
description: message, |
|||
message: notifier.title, |
|||
}); |
|||
break; |
|||
} |
|||
} |
|||
} |
|||
|
|||
return { |
|||
register, |
|||
release, |
|||
}; |
|||
} |
|||
@ -1,4 +1,5 @@ |
|||
export * from './api'; |
|||
export * from './components'; |
|||
export * from './constants'; |
|||
export * from './hooks'; |
|||
export * from './types'; |
|||
|
|||
@ -0,0 +1,27 @@ |
|||
{ |
|||
"name": "@abp/signalr", |
|||
"version": "8.3.2", |
|||
"homepage": "https://github.com/colinin/abp-next-admin", |
|||
"bugs": "https://github.com/colinin/abp-next-admin/issues", |
|||
"repository": { |
|||
"type": "git", |
|||
"url": "git+https://github.com/colinin/abp-next-admin.git", |
|||
"directory": "packages/@abp/signalr" |
|||
}, |
|||
"license": "MIT", |
|||
"type": "module", |
|||
"sideEffects": [ |
|||
"**/*.css" |
|||
], |
|||
"exports": { |
|||
".": { |
|||
"types": "./src/index.ts", |
|||
"default": "./src/index.ts" |
|||
} |
|||
}, |
|||
"dependencies": { |
|||
"@abp/core": "workspace:*", |
|||
"@microsoft/signalr": "catalog:", |
|||
"@vben/stores": "workspace:*" |
|||
} |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './useSignalR'; |
|||
@ -0,0 +1,155 @@ |
|||
/* eslint-disable @typescript-eslint/no-non-null-assertion */ |
|||
import type { HubConnection, IHttpConnectionOptions } from '@microsoft/signalr'; |
|||
|
|||
import { useAccessStore } from '@vben/stores'; |
|||
|
|||
import { useEventBus } from '@abp/core'; |
|||
import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; |
|||
|
|||
interface SignalROptions { |
|||
/** 断线自动重连 */ |
|||
automaticReconnect?: boolean; |
|||
/** 初始化自动建立连接 */ |
|||
autoStart?: boolean; |
|||
/** 下次重试间隔(ms) */ |
|||
nextRetryDelayInMilliseconds?: number; |
|||
/** 服务端url */ |
|||
serverUrl: string; |
|||
/** 是否携带访问令牌 */ |
|||
useAccessToken?: boolean; |
|||
} |
|||
|
|||
export function useSignalR() { |
|||
const { publish, subscribe } = useEventBus(); |
|||
|
|||
let connection: HubConnection | null = null; |
|||
|
|||
/** 初始化SignalR */ |
|||
async function init({ |
|||
automaticReconnect = true, |
|||
autoStart = false, |
|||
nextRetryDelayInMilliseconds = 60_000, |
|||
serverUrl, |
|||
useAccessToken = true, |
|||
}: SignalROptions) { |
|||
const httpOptions: IHttpConnectionOptions = {}; |
|||
if (useAccessToken) { |
|||
const accessStore = useAccessStore(); |
|||
const token = accessStore.accessToken; |
|||
if (token) { |
|||
httpOptions.accessTokenFactory = () => |
|||
token.startsWith('Bearer ') ? token.slice(7) : token; |
|||
} |
|||
} |
|||
const connectionBuilder = new HubConnectionBuilder() |
|||
.withUrl(serverUrl, httpOptions) |
|||
.configureLogging(LogLevel.Warning); |
|||
if (automaticReconnect && nextRetryDelayInMilliseconds) { |
|||
connectionBuilder.withAutomaticReconnect({ |
|||
nextRetryDelayInMilliseconds: () => nextRetryDelayInMilliseconds, |
|||
}); |
|||
} |
|||
connection = connectionBuilder.build(); |
|||
if (autoStart) { |
|||
await start(); |
|||
} |
|||
} |
|||
|
|||
/** 启动连接 */ |
|||
async function start(): Promise<void> { |
|||
_throwIfNotInit(); |
|||
publish('signalR:beforeStart'); |
|||
try { |
|||
await connection!.start(); |
|||
publish('signalR:onStart'); |
|||
} catch (error) { |
|||
publish('signalR:onError', error); |
|||
} |
|||
} |
|||
|
|||
/** 关闭连接 */ |
|||
async function stop(): Promise<void> { |
|||
_throwIfNotInit(); |
|||
publish('signalR:beforeStop'); |
|||
try { |
|||
await connection!.stop(); |
|||
publish('signalR:onStop'); |
|||
} catch (error) { |
|||
publish('signalR:onError', error); |
|||
} |
|||
} |
|||
|
|||
/** 连接前事件 */ |
|||
function beforeStart<T = any>(callback: (event?: T) => void) { |
|||
subscribe('signalR:beforeStart', callback); |
|||
} |
|||
|
|||
/** 连接后事件 */ |
|||
function onStart<T = any>(callback: (event?: T) => void) { |
|||
subscribe('signalR:onStart', callback); |
|||
} |
|||
|
|||
/** 关闭连接前事件 */ |
|||
function beforeStop<T = any>(callback: (event?: T) => void) { |
|||
subscribe('signalR:beforeStop', callback); |
|||
} |
|||
|
|||
/** 关闭连接后事件 */ |
|||
function onStop<T = any>(callback: (event?: T) => void) { |
|||
subscribe('signalR:onStop', callback); |
|||
} |
|||
|
|||
/** 连接错误事件 */ |
|||
function onError(callback: (error?: Error) => void) { |
|||
subscribe('signalR:onError', callback); |
|||
} |
|||
|
|||
/** 订阅服务端消息 */ |
|||
function on(methodName: string, newMethod: (...args: any[]) => void): void { |
|||
connection?.on(methodName, newMethod); |
|||
} |
|||
|
|||
/** 注销服务端消息 */ |
|||
function off(methodName: string, method: (...args: any[]) => void): void { |
|||
connection?.off(methodName, method); |
|||
} |
|||
|
|||
/** 连接关闭事件 */ |
|||
function onClose(callback: (error?: Error) => void): void { |
|||
connection?.onclose(callback); |
|||
} |
|||
|
|||
/** 发送消息 */ |
|||
function send(methodName: string, ...args: any[]): Promise<void> { |
|||
_throwIfNotInit(); |
|||
return connection!.send(methodName, ...args); |
|||
} |
|||
|
|||
/** 调用函数 */ |
|||
function invoke<T = any>(methodName: string, ...args: any[]): Promise<T> { |
|||
_throwIfNotInit(); |
|||
return connection!.invoke(methodName, ...args); |
|||
} |
|||
|
|||
function _throwIfNotInit() { |
|||
if (connection === null) { |
|||
throw new Error('unable to send message, connection not initialized!'); |
|||
} |
|||
} |
|||
|
|||
return { |
|||
beforeStart, |
|||
beforeStop, |
|||
init, |
|||
invoke, |
|||
off, |
|||
on, |
|||
onClose, |
|||
onError, |
|||
onStart, |
|||
onStop, |
|||
send, |
|||
start, |
|||
stop, |
|||
}; |
|||
} |
|||
@ -0,0 +1 @@ |
|||
export * from './hooks'; |
|||
@ -0,0 +1,6 @@ |
|||
{ |
|||
"$schema": "https://json.schemastore.org/tsconfig", |
|||
"extends": "@vben/tsconfig/web.json", |
|||
"include": ["src"], |
|||
"exclude": ["node_modules"] |
|||
} |
|||
Loading…
Reference in new issue