halo/ui/console-src/modules/system/users/UserList.vue

555 lines
16 KiB
Vue
Raw Normal View History

<script lang="ts" setup>
import { useFetchRole } from "@/composables/use-role";
import { rbacAnnotations } from "@/constants/annotations";
import { useUserStore } from "@/stores/user";
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { ListedUser, User } from "@halo-dev/api-client";
import { consoleApiClient, coreApiClient } from "@halo-dev/api-client";
import {
Dialog,
IconAddCircle,
IconLockPasswordLine,
IconRefreshLine,
IconUserFollow,
IconUserSettings,
Toast,
VAvatar,
VButton,
VCard,
VDropdownItem,
VEmpty,
VEntity,
VEntityField,
VLoading,
VPageHeader,
VPagination,
VSpace,
VStatusDot,
VTag,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import { useRouteQuery } from "@vueuse/router";
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import GrantPermissionModal from "./components/GrantPermissionModal.vue";
import UserCreationModal from "./components/UserCreationModal.vue";
import UserEditingModal from "./components/UserEditingModal.vue";
import UserPasswordChangeModal from "./components/UserPasswordChangeModal.vue";
const { currentUserHasPermission } = usePermission();
const { t } = useI18n();
const checkedAll = ref(false);
2022-07-18 03:52:13 +00:00
const editingModal = ref<boolean>(false);
const creationModal = ref<boolean>(false);
2022-07-18 03:52:13 +00:00
const passwordChangeModal = ref<boolean>(false);
const grantPermissionModal = ref<boolean>(false);
const selectedUserNames = ref<string[]>([]);
const selectedUser = ref<User>();
const keyword = useRouteQuery<string>("keyword", "");
const userStore = useUserStore();
const ANONYMOUSUSER_NAME = "anonymousUser";
const DELETEDUSER_NAME = "ghost";
// Filters
const { roles } = useFetchRole();
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const selectedRoleValue = useRouteQuery<string | undefined>("role");
const selectedSortValue = useRouteQuery<string | undefined>("sort");
function handleClearFilters() {
selectedRoleValue.value = undefined;
selectedSortValue.value = undefined;
}
const hasFilters = computed(() => {
return selectedRoleValue.value || selectedSortValue.value;
});
watch(
() => [selectedRoleValue.value, selectedSortValue.value, keyword.value],
() => {
page.value = 1;
}
);
const total = ref(0);
const {
data: users,
isLoading,
isFetching,
refetch,
} = useQuery<ListedUser[]>({
queryKey: [
"users",
page,
size,
keyword,
selectedSortValue,
selectedRoleValue,
],
queryFn: async () => {
const { data } = await consoleApiClient.user.listUsers({
page: page.value,
size: size.value,
keyword: keyword.value,
fieldSelector: [
`name!=${ANONYMOUSUSER_NAME}`,
`name!=${DELETEDUSER_NAME}`,
],
sort: [selectedSortValue.value].filter(Boolean) as string[],
role: selectedRoleValue.value,
refactor: method parameters of api client (halo-dev/console#605) <!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind improvement /milestone 2.0 <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind optimization 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 修改 api-client 的请求参数结构,改为所有参数由一个对象包裹,而不是将各个参数作为方法的参数,防止因为后端参数结构发生改变,或者生成 api-client 时参数顺序发生改变导致请求异常。如: ```diff await apiClient.extension.storage.group.updatestorageHaloRunV1alpha1Group( - formState.value.metadata.name, - formState.value + { + name: formState.value.metadata.name, + group: formState.value, + } ); ``` #### Which issue(s) this PR fixes: <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> None #### Screenshots: <!-- 如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。 If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR. eg. Before: ![screenshot-before](https://user-images.githubusercontent.com/screenshot.png) After: ![screenshot-after](https://user-images.githubusercontent.com/screenshot.png) --> None #### Special notes for your reviewer: /cc @halo-dev/sig-halo-admin #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```
2022-09-06 02:26:11 +00:00
});
total.value = data.total;
return data.items;
},
refetchInterval(data) {
const hasDeletingData = data?.some(
(user) => !!user.user.metadata.deletionTimestamp
);
return hasDeletingData ? 1000 : false;
},
onSuccess() {
selectedUser.value = undefined;
},
});
const handleDelete = async (user: User) => {
Dialog.warning({
title: t("core.user.operations.delete.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
try {
await coreApiClient.user.deleteUser({
name: user.metadata.name,
});
Toast.success(t("core.common.toast.delete_success"));
} catch (e) {
console.error("Failed to delete user", e);
} finally {
await refetch();
}
},
});
};
const handleDeleteInBatch = async () => {
Dialog.warning({
title: t("core.user.operations.delete_in_batch.title"),
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: async () => {
const userNamesToDelete = selectedUserNames.value.filter(
(name) => name != userStore.currentUser?.metadata.name
);
await Promise.all(
userNamesToDelete.map((name) => {
return coreApiClient.user.deleteUser({
name,
});
})
);
await refetch();
selectedUserNames.value.length = 0;
Toast.success(t("core.common.toast.delete_success"));
},
});
};
watch(selectedUserNames, (newValue) => {
checkedAll.value =
newValue.length ===
users.value?.filter(
(user) => user.user.metadata.name !== userStore.currentUser?.metadata.name
).length;
});
const checkSelection = (user: User) => {
return (
user.metadata.name === selectedUser.value?.metadata.name ||
selectedUserNames.value.includes(user.metadata.name)
);
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedUserNames.value =
users.value
?.filter((user) => {
return (
user.user.metadata.name !== userStore.currentUser?.metadata.name
);
})
.map((user) => {
return user.user.metadata.name;
}) || [];
} else {
selectedUserNames.value.length = 0;
}
};
const handleOpenCreateModal = (user: User) => {
selectedUser.value = user;
2022-07-18 03:52:13 +00:00
editingModal.value = true;
};
const handleOpenPasswordChangeModal = (user: User) => {
selectedUser.value = user;
passwordChangeModal.value = true;
};
const handleOpenGrantPermissionModal = (user: User) => {
selectedUser.value = user;
grantPermissionModal.value = true;
};
feat: add attachment management support (halo-dev/console#600) <!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /milestone 2.0 <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind optimization 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 增加附件管理的功能,适配 https://github.com/halo-dev/halo/pull/2354 #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2330 <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> #### Screenshots: None <!-- 如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。 If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR. eg. Before: ![screenshot-before](https://user-images.githubusercontent.com/screenshot.png) After: ![screenshot-after](https://user-images.githubusercontent.com/screenshot.png) --> #### Special notes for your reviewer: todo list: - [x] 根据分组筛选附件列表。 - [x] 非图片文件支持显示占位图。 - [x] 完善选择附件组件。 - [ ] ~~附件引用关系查询。~~ #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```
2022-09-04 17:06:11 +00:00
// Route query action
const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => {
if (routeQueryAction.value === "create") {
creationModal.value = true;
feat: add attachment management support (halo-dev/console#600) <!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /milestone 2.0 <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind optimization 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 增加附件管理的功能,适配 https://github.com/halo-dev/halo/pull/2354 #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/2330 <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> #### Screenshots: None <!-- 如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。 If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR. eg. Before: ![screenshot-before](https://user-images.githubusercontent.com/screenshot.png) After: ![screenshot-after](https://user-images.githubusercontent.com/screenshot.png) --> #### Special notes for your reviewer: todo list: - [x] 根据分组筛选附件列表。 - [x] 非图片文件支持显示占位图。 - [x] 完善选择附件组件。 - [ ] ~~附件引用关系查询。~~ #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```
2022-09-04 17:06:11 +00:00
}
});
function onCreationModalClose() {
creationModal.value = false;
routeQueryAction.value = undefined;
}
function onEditingModalClose() {
editingModal.value = false;
selectedUser.value = undefined;
}
function onPasswordChangeModalClose() {
passwordChangeModal.value = false;
refetch();
}
function onGrantPermissionModalClose() {
grantPermissionModal.value = false;
selectedUser.value = undefined;
refetch();
}
</script>
<template>
<UserEditingModal
v-if="editingModal && selectedUser"
:user="selectedUser"
@close="onEditingModalClose"
2022-07-18 03:52:13 +00:00
/>
<UserCreationModal v-if="creationModal" @close="onCreationModalClose" />
2022-07-18 03:52:13 +00:00
<UserPasswordChangeModal
v-if="passwordChangeModal"
:user="selectedUser"
@close="onPasswordChangeModalClose"
/>
<GrantPermissionModal
v-if="grantPermissionModal"
:user="selectedUser"
@close="onGrantPermissionModalClose"
/>
<VPageHeader :title="$t('core.user.title')">
<template #icon>
<IconUserSettings class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton
v-permission="['system:roles:view']"
:route="{ name: 'Roles' }"
size="sm"
type="default"
>
<template #icon>
<IconUserFollow class="h-full w-full" />
</template>
{{ $t("core.user.actions.roles") }}
</VButton>
<VButton :route="{ name: 'AuthProviders' }" size="sm" type="default">
<template #icon>
<IconLockPasswordLine class="h-full w-full" />
</template>
{{ $t("core.user.actions.identity_authentication") }}
</VButton>
<VButton
v-permission="['system:users:manage']"
type="secondary"
@click="creationModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col flex-wrap items-start gap-4 sm:flex-row sm:items-center"
>
<div
v-permission="['system:users:manage']"
class="hidden items-center sm:flex"
>
<input
v-model="checkedAll"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput v-if="!selectedUserNames.length" v-model="keyword" />
<VSpace v-else>
<VButton type="danger" @click="handleDeleteInBatch">
{{ $t("core.common.buttons.delete") }}
</VButton>
</VSpace>
</div>
<VSpace spacing="lg" class="flex-wrap">
<FilterCleanButton
v-if="hasFilters"
@click="handleClearFilters"
/>
<FilterDropdown
v-model="selectedRoleValue"
:label="$t('core.user.filters.role.label')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
},
...roles.map((role) => {
return {
label:
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name,
value: role.metadata.name,
};
}),
]"
/>
<FilterDropdown
v-model="selectedSortValue"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t('core.user.filters.sort.items.create_time_desc'),
value: 'metadata.creationTimestamp,desc',
},
{
label: t('core.user.filters.sort.items.create_time_asc'),
value: 'metadata.creationTimestamp,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="refetch()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{
'animate-spin text-gray-900': isFetching,
}"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!users?.length" appear name="fade">
<VEmpty
:message="$t('core.user.empty.message')"
:title="$t('core.user.empty.title')"
>
<template #actions>
<VSpace>
<VButton @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton
v-permission="['system:users:manage']"
type="secondary"
@click="editingModal = true"
>
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.common.buttons.new") }}
</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 users" :key="index">
<VEntity :is-selected="checkSelection(user.user)">
<template
v-if="currentUserHasPermission(['system:users:manage'])"
#checkbox
>
<input
v-model="selectedUserNames"
:value="user.user.metadata.name"
name="post-checkbox"
type="checkbox"
:disabled="
user.user.metadata.name ===
userStore.currentUser?.metadata.name
"
/>
</template>
<template #start>
<VEntityField>
<template #description>
<VAvatar
:alt="user.user.spec.displayName"
:src="user.user.spec.avatar"
size="md"
></VAvatar>
</template>
</VEntityField>
<VEntityField
:title="user.user.spec.displayName"
:description="user.user.metadata.name"
:route="{
name: 'UserDetail',
params: { name: user.user.metadata.name },
}"
/>
</template>
<template #end>
<VEntityField>
<template #description>
<div
v-for="(role, roleIndex) in user.roles"
:key="roleIndex"
class="flex items-center"
>
<VTag>
{{
role.metadata.annotations?.[
rbacAnnotations.DISPLAY_NAME
] || role.metadata.name
}}
</VTag>
</div>
</template>
</VEntityField>
<VEntityField v-if="user.user.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(user.user.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template
v-if="currentUserHasPermission(['system:users:manage'])"
#dropdownItems
>
<VDropdownItem @click="handleOpenCreateModal(user.user)">
{{ $t("core.user.operations.update_profile.title") }}
</VDropdownItem>
<VDropdownItem
@click="handleOpenPasswordChangeModal(user.user)"
>
{{ $t("core.user.operations.change_password.title") }}
</VDropdownItem>
<VDropdownItem
v-if="
userStore.currentUser?.metadata.name !==
user.user.metadata.name
"
@click="handleOpenGrantPermissionModal(user.user)"
>
{{ $t("core.user.operations.grant_permission.title") }}
</VDropdownItem>
<VDropdownItem
v-if="
userStore.currentUser?.metadata.name !==
user.user.metadata.name
"
type="danger"
@click="handleDelete(user.user)"
>
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</li>
</ul>
</Transition>
<template #footer>
<VPagination
v-model:page="page"
v-model:size="size"
:total="total"
:page-label="$t('core.components.pagination.page_label')"
:size-label="$t('core.components.pagination.size_label')"
:total-label="
$t('core.components.pagination.total_label', { total: total })
"
:size-options="[20, 30, 50, 100]"
/>
</template>
</VCard>
</div>
</template>