feat: user management supports conditional filtering and sorting (#862)

#### What type of PR is this?

/kind feature

#### What this PR does / why we need it:

用户管理列表支持条件筛选和排序,适配:https://github.com/halo-dev/halo/pull/3320

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/3290

#### Screenshots:

<img width="922" alt="image" src="https://user-images.githubusercontent.com/21301288/219578426-de396dfb-7ece-496e-b740-d7a36321eafb.png">

#### Special notes for your reviewer:

测试方式:

1. Halo 需要切换到 https://github.com/halo-dev/halo/pull/3320 分支。
2. Console 需要 `pnpm build:packages`
3. 测试用户管理的关键词、条件、排序筛选是否工作正常。

#### Does this PR introduce a user-facing change?

```release-note
Console 端的用户列表支持条件筛选和排序。
```
pull/867/head
Ryan Wang 2023-02-20 12:18:18 +08:00 committed by GitHub
parent 87d7592b29
commit 194ff7f4ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 389 additions and 85 deletions

View File

@ -1,6 +1,5 @@
.gitignore
.npmignore
.openapi-generator-ignore
api.ts
api/api-console-halo-run-v1alpha1-attachment-api.ts
api/api-console-halo-run-v1alpha1-comment-api.ts

View File

@ -38,6 +38,8 @@ import { GrantRequest } from '../models'
// @ts-ignore
import { User } from '../models'
// @ts-ignore
import { UserList } from '../models'
// @ts-ignore
import { UserPermission } from '../models'
/**
* ApiConsoleHaloRunV1alpha1UserApi - axios parameter creator
@ -223,6 +225,85 @@ export const ApiConsoleHaloRunV1alpha1UserApiAxiosParamCreator = function (confi
options: localVarRequestOptions,
}
},
/**
* List users
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp
* @param {string} [keyword]
* @param {string} [role]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {number} [page] The page number. Zero indicates no page.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUsers: async (
sort?: Array<string>,
keyword?: string,
role?: string,
size?: number,
page?: number,
labelSelector?: Array<string>,
fieldSelector?: Array<string>,
options: AxiosRequestConfig = {},
): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/users`
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL)
let baseOptions
if (configuration) {
baseOptions = configuration.baseOptions
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options }
const localVarHeaderParameter = {} as any
const localVarQueryParameter = {} as any
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (sort) {
localVarQueryParameter['sort'] = Array.from(sort)
}
if (keyword !== undefined) {
localVarQueryParameter['keyword'] = keyword
}
if (role !== undefined) {
localVarQueryParameter['role'] = role
}
if (size !== undefined) {
localVarQueryParameter['size'] = size
}
if (page !== undefined) {
localVarQueryParameter['page'] = page
}
if (labelSelector) {
localVarQueryParameter['labelSelector'] = labelSelector
}
if (fieldSelector) {
localVarQueryParameter['fieldSelector'] = fieldSelector
}
setSearchParams(localVarUrlObj, localVarQueryParameter)
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}
localVarRequestOptions.headers = { ...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers }
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
}
},
/**
* Update current user profile, but password.
* @param {User} user
@ -328,6 +409,40 @@ export const ApiConsoleHaloRunV1alpha1UserApiFp = function (configuration?: Conf
const localVarAxiosArgs = await localVarAxiosParamCreator.grantPermission(name, grantRequest, options)
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)
},
/**
* List users
* @param {Array<string>} [sort] Sort property and direction of the list result. Supported fields: creationTimestamp
* @param {string} [keyword]
* @param {string} [role]
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {number} [page] The page number. Zero indicates no page.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listUsers(
sort?: Array<string>,
keyword?: string,
role?: string,
size?: number,
page?: number,
labelSelector?: Array<string>,
fieldSelector?: Array<string>,
options?: AxiosRequestConfig,
): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listUsers(
sort,
keyword,
role,
size,
page,
labelSelector,
fieldSelector,
options,
)
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)
},
/**
* Update current user profile, but password.
* @param {User} user
@ -403,6 +518,29 @@ export const ApiConsoleHaloRunV1alpha1UserApiFactory = function (
.grantPermission(requestParameters.name, requestParameters.grantRequest, options)
.then((request) => request(axios, basePath))
},
/**
* List users
* @param {ApiConsoleHaloRunV1alpha1UserApiListUsersRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listUsers(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiListUsersRequest = {},
options?: AxiosRequestConfig,
): AxiosPromise<UserList> {
return localVarFp
.listUsers(
requestParameters.sort,
requestParameters.keyword,
requestParameters.role,
requestParameters.size,
requestParameters.page,
requestParameters.labelSelector,
requestParameters.fieldSelector,
options,
)
.then((request) => request(axios, basePath))
},
/**
* Update current user profile, but password.
* @param {ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest} requestParameters Request parameters.
@ -474,6 +612,62 @@ export interface ApiConsoleHaloRunV1alpha1UserApiGrantPermissionRequest {
readonly grantRequest: GrantRequest
}
/**
* Request parameters for listUsers operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest
*/
export interface ApiConsoleHaloRunV1alpha1UserApiListUsersRequest {
/**
* Sort property and direction of the list result. Supported fields: creationTimestamp
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly sort?: Array<string>
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly keyword?: string
/**
*
* @type {string}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly role?: string
/**
* Size of one page. Zero indicates no limit.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly size?: number
/**
* The page number. Zero indicates no page.
* @type {number}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly page?: number
/**
* Label selector for filtering.
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly labelSelector?: Array<string>
/**
* Field selector for filtering.
* @type {Array<string>}
* @memberof ApiConsoleHaloRunV1alpha1UserApiListUsers
*/
readonly fieldSelector?: Array<string>
}
/**
* Request parameters for updateCurrentUser operation in ApiConsoleHaloRunV1alpha1UserApi.
* @export
@ -555,6 +749,31 @@ export class ApiConsoleHaloRunV1alpha1UserApi extends BaseAPI {
.then((request) => request(this.axios, this.basePath))
}
/**
* List users
* @param {ApiConsoleHaloRunV1alpha1UserApiListUsersRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1UserApi
*/
public listUsers(
requestParameters: ApiConsoleHaloRunV1alpha1UserApiListUsersRequest = {},
options?: AxiosRequestConfig,
) {
return ApiConsoleHaloRunV1alpha1UserApiFp(this.configuration)
.listUsers(
requestParameters.sort,
requestParameters.keyword,
requestParameters.role,
requestParameters.size,
requestParameters.page,
requestParameters.labelSelector,
requestParameters.fieldSelector,
options,
)
.then((request) => request(this.axios, this.basePath))
}
/**
* Update current user profile, but password.
* @param {ApiConsoleHaloRunV1alpha1UserApiUpdateCurrentUserRequest} requestParameters Request parameters.

View File

@ -17,20 +17,25 @@ import {
VStatusDot,
VLoading,
Toast,
IconRefreshLine,
VEmpty,
} from "@halo-dev/components";
import UserEditingModal from "./components/UserEditingModal.vue";
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
import GrantPermissionModal from "./components/GrantPermissionModal.vue";
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import { apiClient } from "@/utils/api-client";
import type { User, UserList } from "@halo-dev/api-client";
import type { Role, User, UserList } from "@halo-dev/api-client";
import { rbacAnnotations } from "@/constants/annotations";
import { formatDatetime } from "@/utils/date";
import { useRouteQuery } from "@vueuse/router";
import Fuse from "fuse.js";
import { usePermission } from "@/utils/permission";
import { useUserStore } from "@/stores/user";
import { useRoleStore } from "@/stores/role";
import { getNode } from "@formkit/core";
import FilterTag from "@/components/filter/FilterTag.vue";
import { useFetchRole } from "../roles/composables/use-role";
import FilterCleanButton from "@/components/filter/FilterCleanButton.vue";
const { currentUserHasPermission } = usePermission();
@ -53,16 +58,18 @@ const users = ref<UserList>({
const loading = ref(false);
const selectedUserNames = ref<string[]>([]);
const selectedUser = ref<User>();
const keyword = ref("");
const refreshInterval = ref();
const userStore = useUserStore();
const roleStore = useRoleStore();
let fuse: Fuse<User> | undefined = undefined;
const ANONYMOUSUSER_NAME = "anonymousUser";
const handleFetchUsers = async (options?: { mute?: boolean }) => {
const handleFetchUsers = async (options?: {
mute?: boolean;
page?: number;
}) => {
try {
clearInterval(refreshInterval.value);
@ -70,18 +77,22 @@ const handleFetchUsers = async (options?: { mute?: boolean }) => {
loading.value = true;
}
const { data } = await apiClient.extension.user.listv1alpha1User({
if (options?.page) {
users.value.page = options.page;
}
const { data } = await apiClient.user.listUsers({
page: users.value.page,
size: users.value.size,
keyword: keyword.value,
fieldSelector: [`name!=${ANONYMOUSUSER_NAME}`],
sort: [selectedSortItem.value?.value].filter(
(item) => !!item
) as string[],
role: selectedRole.value?.metadata.name,
});
users.value = data;
fuse = new Fuse(data.items, {
keys: ["spec.displayName", "metadata.name", "spec.email"],
useExtendedSearch: true,
threshold: 0.2,
});
users.value = data;
const deletedUsers = users.value.items.filter(
(user) => !!user.metadata.deletionTimestamp
@ -100,16 +111,6 @@ const handleFetchUsers = async (options?: { mute?: boolean }) => {
}
};
const keyword = ref("");
const searchResults = computed(() => {
if (!fuse || !keyword.value) {
return users.value.items;
}
return fuse?.search(keyword.value).map((item) => item.item);
});
const handlePaginationChange = async ({
page,
size,
@ -239,6 +240,62 @@ onMounted(() => {
editingModal.value = true;
}
});
// Filters
function handleKeywordChange() {
const keywordNode = getNode("keywordInput");
if (keywordNode) {
keyword.value = keywordNode._value as string;
}
handleFetchUsers({ page: 1 });
}
function handleClearKeyword() {
keyword.value = "";
handleFetchUsers({ page: 1 });
}
interface SortItem {
label: string;
value: string;
}
const SortItems: SortItem[] = [
{
label: "较近创建",
value: "creationTimestamp,desc",
},
{
label: "较早创建",
value: "creationTimestamp,asc",
},
];
const selectedSortItem = ref<SortItem>();
function handleSortItemChange(sortItem?: SortItem) {
selectedSortItem.value = sortItem;
handleFetchUsers({ page: 1 });
}
const { roles } = useFetchRole();
const selectedRole = ref<Role>();
function handleRoleChange(role?: Role) {
selectedRole.value = role;
handleFetchUsers({ page: 1 });
}
function handleClearFilters() {
selectedRole.value = undefined;
selectedSortItem.value = undefined;
keyword.value = "";
handleFetchUsers({ page: 1 });
}
const hasFilters = computed(() => {
return selectedRole.value || selectedSortItem.value || keyword.value;
});
</script>
<template>
<UserEditingModal
@ -308,49 +365,53 @@ onMounted(() => {
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 sm:w-auto">
<FormKit
<div class="flex w-full flex-1 items-center sm:w-auto">
<div
v-if="!selectedUserNames.length"
v-model="keyword"
class="flex items-center gap-2"
>
<FormKit
id="keywordInput"
outer-class="!p-0"
:model-value="keyword"
name="keyword"
placeholder="输入关键词搜索"
type="text"
@keyup.enter="handleKeywordChange"
></FormKit>
<FilterTag v-if="keyword" @close="handleClearKeyword()">
关键词{{ keyword }}
</FilterTag>
<FilterTag v-if="selectedRole" @close="handleRoleChange()">
角色{{
selectedRole.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || selectedRole.metadata.name
}}
</FilterTag>
<FilterTag
v-if="selectedSortItem"
@close="handleSortItemChange()"
>
排序{{ selectedSortItem.label }}
</FilterTag>
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
</div>
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch">
删除
</VButton>
</VSpace>
</div>
<div v-if="false" class="mt-4 flex sm:mt-0">
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">状态</span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="w-52 p-4">
<ul class="space-y-1">
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">正常</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">已禁用</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
@ -364,28 +425,19 @@ onMounted(() => {
<div class="w-52 p-4">
<ul class="space-y-1">
<li
v-for="(role, index) in roles"
:key="index"
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleRoleChange(role)"
>
<span class="truncate">Super Administrator</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">Administrator</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">Editor</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">Guest</span>
<span class="truncate">
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</span>
</li>
</ul>
</div>
@ -404,33 +456,67 @@ onMounted(() => {
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(sortItem, index) in SortItems"
:key="index"
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleSortItemChange(sortItem)"
>
<span class="truncate">较近登录</span>
</li>
<li
v-close-popper
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">较晚登录</span>
<span class="truncate">{{ sortItem.label }}</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchUsers()"
>
<IconRefreshLine
v-tooltip="`刷新`"
:class="{ 'animate-spin text-gray-900': loading }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</div>
</template>
<VLoading v-if="loading" />
<Transition v-else-if="!users.total" appear name="fade">
<VEmpty
message="当前没有符合筛选条件的用户,你可以尝试刷新或者创建新用户"
title="当前没有符合筛选条件的用户"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchUsers()"></VButton>
<VButton
v-permission="['system:users:manage']"
type="secondary"
@click="editingModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建用户
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(user, index) in searchResults" :key="index">
<li v-for="(user, index) in users.items" :key="index">
<VEntity :is-selected="checkSelection(user)">
<template
v-if="currentUserHasPermission(['system:users:manage'])"