feat: record the post query conditions in the route query parameters (#4102)

#### What type of PR is this?

/area console
/kind feature
/milestone 2.8.x

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

在文章数据管理列表页面路由中记录查询条件,包括分页信息、筛选信息等。可以保证在刷新页面或者从文章编辑页面返回时保留之前的查询状态。

<img width="1758" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/270948d6-d585-4b36-ad3a-93064cf47548">

TODO:

- [x] 记录筛选条件,因为路由参数只能使用基本类型,但是原来的筛选条件的变量都是完整对象。

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

Fixes #4098 

#### Special notes for your reviewer:

需要测试:

1. 文章管理列表的所有筛选项是否可以正常工作。
2. 尝试设置部分筛选,然后刷新页面,观察筛选条件是否正常保留。

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

```release-note
Console 端的文章管理列表支持在地址栏记录筛选条件。
```
pull/4212/head
Ryan Wang 2023-07-12 14:17:20 +08:00 committed by GitHub
parent 197096305b
commit bb0a5f114a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 135 additions and 106 deletions

View File

@ -67,10 +67,10 @@
"@uppy/status-bar": "^3.1.2",
"@uppy/vue": "^1.0.2",
"@uppy/xhr-upload": "^3.2.0",
"@vueuse/components": "^9.6.0",
"@vueuse/core": "^9.6.0",
"@vueuse/router": "^9.6.0",
"@vueuse/shared": "^9.6.0",
"@vueuse/components": "^10.2.0",
"@vueuse/core": "^10.2.0",
"@vueuse/router": "^10.2.0",
"@vueuse/shared": "^10.2.0",
"axios": "^0.27.2",
"codemirror": "^6.0.1",
"colorjs.io": "^0.4.3",

View File

@ -1,7 +1,6 @@
<script lang="ts" setup>
import { computed } from "vue";
import { IconArrowLeft, IconArrowRight } from "../../icons/icons";
import { ref, watch } from "vue";
import { useOffsetPagination } from "@vueuse/core";
const props = withDefaults(
defineProps<{
@ -22,71 +21,69 @@ const props = withDefaults(
}
);
const page = ref(props.page);
const size = ref(props.size);
const total = ref(props.total);
watch([() => props.page, () => props.size, () => props.total], () => {
page.value = props.page;
size.value = props.size;
total.value = props.total;
});
const emit = defineEmits<{
(event: "update:page", page: number): void;
(event: "update:size", size: number): void;
(event: "change", value: { page: number; size: number }): void;
}>();
const onPageChange = ({
currentPage,
currentPageSize,
}: {
currentPage: number;
currentPageSize: number;
}) => {
emit("update:page", currentPage);
emit("update:size", currentPageSize);
emit("change", {
page: currentPage,
size: currentPageSize,
});
const totalPages = computed(() => Math.ceil(props.total / props.size));
const hasNext = computed(() => props.page < totalPages.value);
const hasPrevious = computed(() => props.page > 1);
const onPageChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const page = Number(target.value);
emit("update:page", page);
emit("change", { page, size: props.size });
};
const {
currentPage,
currentPageSize,
pageCount,
isFirstPage,
isLastPage,
prev,
next,
} = useOffsetPagination({
total: total,
page: page,
pageSize: size,
onPageChange: onPageChange,
onPageSizeChange: onPageChange,
});
const onSizeChange = (event: Event) => {
const target = event.target as HTMLSelectElement;
const size = Number(target.value);
emit("update:size", size);
// reset page to 1
emit("update:page", 1);
emit("change", { page: 1, size });
};
const previous = () => {
if (hasPrevious.value) {
const page = props.page - 1;
emit("update:page", page);
emit("change", { page: page, size: props.size });
}
};
const next = () => {
if (hasNext.value) {
const page = props.page + 1;
emit("update:page", page);
emit("change", { page: page, size: props.size });
}
};
</script>
<template>
<div class="bg-white flex items-center justify-between">
<div class="flex-1 flex justify-between sm:!hidden items-center">
<span
<button
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 cursor-pointer"
@click="prev"
:disabled="!hasPrevious"
@click="previous"
>
<IconArrowLeft />
</span>
<span class="text-sm text-gray-500">
{{ currentPage }} / {{ pageCount }}
</span>
<span
</button>
<span class="text-sm text-gray-500"> {{ page }} / {{ totalPages }} </span>
<button
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 cursor-pointer"
:disabled="!hasNext"
@click="next"
>
<IconArrowRight />
</span>
</button>
</div>
<div class="hidden sm:flex-1 sm:flex sm:items-center items-center gap-2">
<nav
@ -95,14 +92,14 @@ const {
>
<button
class="relative h-8 outline-none inline-flex items-center px-2 py-1.5 rounded-l-base border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 cursor-pointer disabled:cursor-not-allowed"
:disabled="isFirstPage"
@click="prev"
:disabled="!hasPrevious"
@click="previous"
>
<IconArrowLeft />
</button>
<button
class="relative h-8 outline-none inline-flex items-center px-2 py-1.5 rounded-r-base border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 cursor-pointer disabled:cursor-not-allowed"
:disabled="isLastPage"
:disabled="!hasNext"
@click="next"
>
<IconArrowRight />
@ -110,21 +107,23 @@ const {
</nav>
<div class="inline-flex items-center gap-2">
<select
v-model="currentPage"
:disabled="pageCount === 0"
:value="page"
:disabled="totalPages === 0"
class="h-8 border outline-none rounded-base px-2 text-gray-800 text-sm border-gray-300"
@change="onPageChange"
>
<option v-if="pageCount === 0" :value="0">0 / 0</option>
<option v-for="i in pageCount" :key="i" :value="i">
{{ i }} / {{ pageCount }}
<option v-if="totalPages === 0" :value="0">0 / 0</option>
<option v-for="i in totalPages" :key="i" :value="i">
{{ i }} / {{ totalPages }}
</option>
</select>
<span class="text-sm text-gray-500">{{ pageLabel }}</span>
</div>
<div class="inline-flex items-center gap-2">
<select
v-model="currentPageSize"
:value="size"
class="h-8 border outline-none rounded-base px-2 text-gray-800 text-sm border-gray-300"
@change="onSizeChange"
>
<option
v-for="(sizeOption, index) in sizeOptions"

View File

@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@ -104,17 +108,17 @@ importers:
specifier: ^3.2.0
version: 3.2.0(@uppy/core@3.2.0)
'@vueuse/components':
specifier: ^9.6.0
version: 9.6.0(vue@3.2.45)
specifier: ^10.2.0
version: 10.2.0(vue@3.2.45)
'@vueuse/core':
specifier: ^9.6.0
version: 9.6.0(vue@3.2.45)
specifier: ^10.2.0
version: 10.2.0(vue@3.2.45)
'@vueuse/router':
specifier: ^9.6.0
version: 9.6.0(vue-router@4.1.6)(vue@3.2.45)
specifier: ^10.2.0
version: 10.2.0(vue-router@4.1.6)(vue@3.2.45)
'@vueuse/shared':
specifier: ^9.6.0
version: 9.6.0(vue@3.2.45)
specifier: ^10.2.0
version: 10.2.0(vue@3.2.45)
axios:
specifier: ^0.27.2
version: 0.27.2
@ -2781,6 +2785,11 @@ packages:
resolution: {integrity: sha512-+lhQggrLvlQ/O5OmIYAc9gadcYXMoaDi0Doef+X/f6TLZFr9PTMjOpBWmpwNNHi026e54jckntUn6GzqDtIN4w==}
engines: {node: '>= 16'}
/@intlify/shared@9.3.0-beta.24:
resolution: {integrity: sha512-AKxJ8s7eKIQWkNaf4wyyoLRwf4puCuQgjSChlDJm5JBEt6T8HGgnYTJLRXu6LD/JACn3Qwu6hM/XRX1c9yvjmQ==}
engines: {node: '>= 16'}
dev: true
/@intlify/unplugin-vue-i18n@0.9.3(rollup@2.79.1)(vue-i18n@9.3.0-beta.19):
resolution: {integrity: sha512-23DMh2r0qA7UZfaQhF09ZHhifgTyKcbmVsCo+qHvu9q1EU8OF18VlhxMHMksDR5NBDvRXj3Lmu8lT84XDrUlSw==}
engines: {node: '>= 14.16'}
@ -2797,7 +2806,7 @@ packages:
optional: true
dependencies:
'@intlify/bundle-utils': 5.5.0(vue-i18n@9.3.0-beta.19)
'@intlify/shared': 9.3.0-beta.19
'@intlify/shared': 9.3.0-beta.24
'@rollup/pluginutils': 5.0.2(rollup@2.79.1)
'@vue/compiler-sfc': 3.2.47
debug: 4.3.4(supports-color@8.1.1)
@ -3998,8 +4007,8 @@ packages:
resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==}
dev: false
/@types/web-bluetooth@0.0.16:
resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
/@types/web-bluetooth@0.0.17:
resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==}
dev: false
/@types/yauzl@2.10.0:
@ -4616,50 +4625,50 @@ packages:
- typescript
dev: true
/@vueuse/components@9.6.0(vue@3.2.45):
resolution: {integrity: sha512-Jqv1g68PtBC8Nnp8u2rpf6qku8cslr381fvlY1uUZa75zI2imxPLglhOWA/dBtMjla4L3nmaf9S7PAzXJnwH9w==}
/@vueuse/components@10.2.0(vue@3.2.45):
resolution: {integrity: sha512-fpGtxx8G3BCJUoTd6d4xI7qELvm4nwVKLZYgIVdv7weqprKWwK5IO+t3LULovPuS7W2guVZgpyMy9NkD4qa2Bw==}
dependencies:
'@vueuse/core': 9.6.0(vue@3.2.45)
'@vueuse/shared': 9.6.0(vue@3.2.45)
vue-demi: 0.13.11(vue@3.2.45)
'@vueuse/core': 10.2.0(vue@3.2.45)
'@vueuse/shared': 10.2.0(vue@3.2.45)
vue-demi: 0.14.5(vue@3.2.45)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/core@9.6.0(vue@3.2.45):
resolution: {integrity: sha512-qGUcjKQXHgN+jqXEgpeZGoxdCbIDCdVPz3QiF1uyecVGbMuM63o96I1GjYx5zskKgRI0FKSNsVWM7rwrRMTf6A==}
/@vueuse/core@10.2.0(vue@3.2.45):
resolution: {integrity: sha512-aHBnoCteIS3hFu7ZZkVB93SanVDY6t4TIb7XDLxJT/HQdAZz+2RdIEJ8rj5LUoEJr7Damb5+sJmtpCwGez5ozQ==}
dependencies:
'@types/web-bluetooth': 0.0.16
'@vueuse/metadata': 9.6.0
'@vueuse/shared': 9.6.0(vue@3.2.45)
vue-demi: 0.13.11(vue@3.2.45)
'@types/web-bluetooth': 0.0.17
'@vueuse/metadata': 10.2.0
'@vueuse/shared': 10.2.0(vue@3.2.45)
vue-demi: 0.14.5(vue@3.2.45)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/metadata@9.6.0:
resolution: {integrity: sha512-sIC8R+kWkIdpi5X2z2Gk8TRYzmczDwHRhEFfCu2P+XW2JdPoXrziqsGpDDsN7ykBx4ilwieS7JUIweVGhvZ93w==}
/@vueuse/metadata@10.2.0:
resolution: {integrity: sha512-IR7Mkq6QSgZ38q/2ZzOt+Zz1OpcEsnwE64WBumDQ+RGKrosFCtUA2zgRrOqDEzPBXrVB+4HhFkwDjQMu0fDBKw==}
dev: false
/@vueuse/router@9.6.0(vue-router@4.1.6)(vue@3.2.45):
resolution: {integrity: sha512-3TIZPX5smlimSNlTm+K3ESRTkA2VBHnwMintNrw4Z+WK5bh1UAh7lcBQluiGg3LJjkrMXYfuO7IPdU+a8NRnFA==}
/@vueuse/router@10.2.0(vue-router@4.1.6)(vue@3.2.45):
resolution: {integrity: sha512-vks2xHCZWeKaFWfgfmrzi6kJ0cTHvRCHzPUzmjvfbmzDrB2YOS1ymCPjfu1LH3E82H+RyebaSRhTeoDc3AsEFw==}
peerDependencies:
vue-router: '>=4.0.0-rc.1'
dependencies:
'@vueuse/shared': 9.6.0(vue@3.2.45)
vue-demi: 0.13.11(vue@3.2.45)
'@vueuse/shared': 10.2.0(vue@3.2.45)
vue-demi: 0.14.5(vue@3.2.45)
vue-router: 4.1.6(vue@3.2.45)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: false
/@vueuse/shared@9.6.0(vue@3.2.45):
resolution: {integrity: sha512-/eDchxYYhkHnFyrb00t90UfjCx94kRHxc7J1GtBCqCG4HyPMX+krV9XJgVtWIsAMaxKVU4fC8NSUviG1JkwhUQ==}
/@vueuse/shared@10.2.0(vue@3.2.45):
resolution: {integrity: sha512-dIeA8+g9Av3H5iF4NXR/sft4V6vys76CpZ6hxwj8eMXybXk2WRl3scSsOVi+kQ9SX38COR7AH7WwY83UcuxbSg==}
dependencies:
vue-demi: 0.13.11(vue@3.2.45)
vue-demi: 0.14.5(vue@3.2.45)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
@ -11288,6 +11297,21 @@ packages:
vue: 3.2.45
dev: false
/vue-demi@0.14.5(vue@3.2.45):
resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
dependencies:
vue: 3.2.45
dev: false
/vue-eslint-parser@9.3.0(eslint@8.43.0):
resolution: {integrity: sha512-48IxT9d0+wArT1+3wNIy0tascRoywqSUe2E1YalIC1L8jsUGe5aJQItWfRok7DVFGz3UYvzEI7n5wiTXsCMAcQ==}
engines: {node: ^14.17.0 || >=16.0.0}

View File

@ -34,6 +34,7 @@ import { usePermission } from "@/utils/permission";
import { postLabels } from "@/constants/labels";
import { useMutation, useQuery } from "@tanstack/vue-query";
import { useI18n } from "vue-i18n";
import { useRouteQuery } from "@vueuse/router";
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
import CategoryFilterDropdown from "@/components/filter/CategoryFilterDropdown.vue";
import TagFilterDropdown from "@/components/filter/TagFilterDropdown.vue";
@ -47,13 +48,24 @@ const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]);
// Filters
const selectedVisible = ref();
const selectedPublishStatus = ref();
const selectedSort = ref();
const selectedCategory = ref();
const selectedTag = ref();
const selectedContributor = ref();
const keyword = ref("");
const page = useRouteQuery<number>("page", 1, {
transform: Number,
});
const size = useRouteQuery<number>("size", 20, {
transform: Number,
});
const selectedVisible = useRouteQuery<
"PUBLIC" | "INTERNAL" | "PRIVATE" | undefined
>("visible");
const selectedPublishStatus = useRouteQuery<string | undefined>("status");
const selectedSort = useRouteQuery<string | undefined>("sort");
const selectedCategory = useRouteQuery<string | undefined>("category");
const selectedTag = useRouteQuery<string | undefined>("tag");
const selectedContributor = useRouteQuery<string | undefined>("contributor");
const keyword = useRouteQuery<string>("keyword", "");
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
watch(
() => [
@ -90,12 +102,6 @@ const hasFilters = computed(() => {
);
});
const page = ref(1);
const size = ref(20);
const total = ref(0);
const hasPrevious = ref(false);
const hasNext = ref(false);
const {
data: posts,
isLoading,
@ -143,7 +149,7 @@ const {
page: page.value,
size: size.value,
visible: selectedVisible.value,
sort: [selectedSort.value?.sort].filter(Boolean) as string[],
sort: [selectedSort.value].filter(Boolean) as string[],
keyword: keyword.value,
category: categories,
tag: tags,