列表与偏好
使用 useListService 连接提供分页结果的后端。页面拥有控件、列、选择与 CRUD;组合式函数协调查询状态、取消和保存的偏好。
准备后端与页面
按后端示例提供 /api/app/documentation-catalog。这个教学端点支持 filter、category、minPrice、sorting、skipCount 和 maxResultCount,返回 { items, totalCount }。使用拥有 AbpIdentity.Users 权限的用户登录。
创建 src/pages/CataloguePage.vue,复制以下代码。端点来自配套示例,不是每个 ABP 应用自带的接口。保留生成应用中的 Core 与 Basic Theme 提供者;布局已经渲染确认与通知宿主。
<template>
<AbpPage title="BookStore::Books">
<form class="row g-3 mb-3" @submit.prevent="list.get()">
<AbpFormField v-slot="{ id }" class="col-md-5" label="Search">
<AbpInput :id="id" v-model="list.filter.value" placeholder="Book name" />
</AbpFormField>
<AbpFormField v-slot="{ id }" class="col-md-3" label="Category">
<AbpSelect
:id="id"
v-model="category"
:options="categories"
placeholder="All categories"
clearable
/>
</AbpFormField>
<AbpFormField v-slot="{ id }" class="col-md-2" label="Minimum price">
<AbpInput :id="id" v-model="minPrice" type="number" :min="0" :max="1000" />
</AbpFormField>
<div class="col-md-2 d-flex align-items-end">
<AbpButton variant="secondary" @click="resetFilters">Reset filters</AbpButton>
</div>
</form>
<div class="d-flex flex-wrap gap-2 mb-3">
<AbpButton @click="createBook">New book</AbpButton>
<AbpButton variant="secondary" :disabled="loading" @click="list.getWithoutPageReset()"
>Refresh current page</AbpButton
>
<span role="status">Selected: {{ selected.length }}</span>
</div>
<p v-if="error" role="alert">
The query failed. Previous rows are kept until a successful retry.
<AbpButton
size="sm"
variant="secondary"
:disabled="loading"
@click="list.getWithoutPageReset()"
>Retry</AbpButton
>
</p>
<AbpDataTable
v-model:sort-key="list.sortKey.value"
v-model:sort-order="list.sortOrder.value"
v-model:selected="selected"
:columns="columns"
:data="items"
record-key="id"
selectable
:loading="loading"
caption="Filtered book catalogue"
>
<template #cell-price="{ value }">{{ Number(value).toFixed(2) }}</template>
<template #cell-actions="{ row }">
<AbpGridActions :record="row" :actions="rowActions" :disabled="saving || deleting" />
</template>
<template #empty>No books match these filters. Create one or reset the filters.</template>
</AbpDataTable>
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mt-3">
<span>{{ localization.t('AbpUi::PagerInfo{0}{1}{2}', first, last, totalCount) }}</span>
<AbpPagination
v-model:page="list.page.value"
v-model:page-size="list.maxResultCount.value"
:total="totalCount"
show-size-selector
/>
</div>
<AbpModal v-model:visible="visible" title="Book" :dirty="form.dirty" :busy="saving">
<form id="book-editor" @submit.prevent="save">
<AbpFormField v-slot="{ id }" label="Name" :errors="fieldErrors('name')" required>
<AbpInput :id="id" v-model="form.controls.name.value" :disabled="saving" />
</AbpFormField>
<AbpFormField v-slot="{ id }" label="Category" :errors="fieldErrors('category')" required>
<AbpSelect
:id="id"
v-model="form.controls.category.value"
:options="categories"
:disabled="saving"
/>
</AbpFormField>
<AbpFormField v-slot="{ id }" label="Price" :errors="fieldErrors('price')" required>
<AbpInput
:id="id"
v-model="form.controls.price.value"
type="number"
:min="0"
:max="1000"
:disabled="saving"
/>
</AbpFormField>
<p v-for="message in form.unmatchedServerErrors" :key="message" role="alert">
{{ message }}
</p>
</form>
<template #footer="{ close }">
<AbpButton variant="secondary" :disabled="saving" @click="close">Cancel</AbpButton>
<AbpButton type="submit" form="book-editor" :loading="saving">Save</AbpButton>
</template>
</AbpModal>
</AbpPage>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import {
AbpDataTable,
AbpGridActions,
AbpPage,
type AbpTableColumn,
type RowAction,
} from '@lsw-abpvue/components';
import {
inject,
RestService,
useListService,
useLocalization,
type PagedResultDto,
} from '@lsw-abpvue/core';
import {
AbpButton,
AbpFormField,
AbpInput,
AbpModal,
AbpPagination,
AbpSelect,
ConfirmationStatus,
useAbpForm,
useConfirmation,
useServerValidation,
useValidationMessages,
Validators,
} from '@lsw-abpvue/theme-shared';
interface Book {
id: string;
name: string;
category: string;
price: number;
}
type BookInput = { name: string; category: string; price: number | null };
const endpoint = '/api/app/documentation-catalog';
const rest = inject(RestService);
const localization = useLocalization();
const confirmation = useConfirmation();
const messages = useValidationMessages();
const list = useListService({
persistKey: 'Documentation.Catalogue',
sortKey: 'name',
sortOrder: 'asc',
});
const category = ref<string | null>(null);
const minPrice = ref<number | null>(null);
const selected = ref<string[]>([]);
const categories = [
{ value: 'fiction', label: 'Fiction' },
{ value: 'reference', label: 'Reference' },
];
watch([list.sortKey, list.sortOrder, list.maxResultCount], () => {
list.page.value = 0;
});
watch([category, minPrice], () => list.get());
watch([list.filter, category, minPrice], () => {
selected.value = [];
});
const { items, totalCount, error } = list.hookToQuery((query, signal) =>
rest.request<never, PagedResultDto<Book>>(
{
method: 'GET',
url: endpoint,
params: {
...query,
category: category.value ?? undefined,
minPrice: minPrice.value ?? undefined,
},
},
{ signal },
),
);
const loading = computed(() => list.requestStatus.value === 'loading');
const columns: AbpTableColumn<Book>[] = [
{ id: 'actions', header: 'Actions' },
{ id: 'name', header: 'Name', sortable: true },
{ id: 'category', header: 'Category' },
{ id: 'price', header: 'Price', sortable: true },
];
const first = computed(() =>
totalCount.value ? list.page.value * list.maxResultCount.value + 1 : 0,
);
const last = computed(() =>
Math.min((list.page.value + 1) * list.maxResultCount.value, totalCount.value),
);
function resetFilters(): void {
list.filter.value = '';
category.value = null;
minPrice.value = null;
list.get();
}
const form = useAbpForm<BookInput>({
name: { value: '', validators: [Validators.required(), Validators.maxLength(128)] },
category: { value: 'fiction', validators: [Validators.required()] },
price: { value: 0, validators: [Validators.required(), Validators.range(0, 1000)] },
});
useServerValidation(form);
const fieldErrors = (key: keyof BookInput) =>
form.controls[key].touched ? messages(form.controls[key].errors) : [];
const visible = ref(false);
const editingId = ref<string | null>(null);
const saving = ref(false);
const deleting = ref(false);
const loadingDetail = ref(false);
const operation = new AbortController();
onBeforeUnmount(() => operation.abort());
const rowActions = computed<RowAction<Book>[]>(() => [
{ text: 'Edit', action: editBook, disabled: loadingDetail.value },
{ text: 'Delete', action: deleteBook, btnClass: 'text-danger' },
]);
function createBook(): void {
editingId.value = null;
form.reset({ name: '', category: 'fiction', price: 0 });
visible.value = true;
}
async function editBook(book: Book): Promise<void> {
if (loadingDetail.value || saving.value) return;
loadingDetail.value = true;
try {
const detail = await rest.request<never, Book>(
{ method: 'GET', url: `${endpoint}/${book.id}` },
{ signal: operation.signal },
);
if (operation.signal.aborted) return;
editingId.value = detail.id;
form.reset(detail);
visible.value = true;
} catch {
/* Framework handlers report failure without opening an incomplete editor. */
} finally {
loadingDetail.value = false;
}
}
async function save(): Promise<void> {
if (saving.value || !form.validate()) return;
saving.value = true;
const id = editingId.value;
try {
await rest.request<BookInput, Book>(
{ method: id ? 'PUT' : 'POST', url: id ? `${endpoint}/${id}` : endpoint, body: form.value },
{ signal: operation.signal },
);
if (operation.signal.aborted) return;
visible.value = false;
if (id) list.getWithoutPageReset();
else list.get();
} catch {
/* Keep the draft and server validation errors for correction. */
} finally {
saving.value = false;
}
}
async function deleteBook(book: Book): Promise<void> {
if (deleting.value) return;
deleting.value = true;
try {
if (
(await confirmation.warn(
{ key: 'BookStore::DeleteBook', defaultValue: `Delete ${book.name}?` },
'AbpUi::AreYouSure',
)) !== ConfirmationStatus.confirm
)
return;
await rest.request<never, void>(
{ method: 'DELETE', url: `${endpoint}/${book.id}` },
{ signal: operation.signal },
);
if (operation.signal.aborted) return;
selected.value = selected.value.filter(id => id !== book.id);
const lastPage = Math.max(0, Math.ceil((totalCount.value - 1) / list.maxResultCount.value) - 1);
if (list.page.value > lastPage) list.page.value = lastPage;
else list.getWithoutPageReset();
} catch {
/* A failed deletion keeps the rows and selection. */
} finally {
deleting.value = false;
}
}
</script>按后端教程注册 /catalogue,增加 BookStore::Books 本地化文本。其他标签使用英文常量以便观察查询流程;实际应用可通过计算属性本地化它们。
搜索、组合与重置筛选
输入 Search 会修改 list.filter,触发防抖查询。Category 与 Minimum price 是额外的 ref,查询函数将它们加入请求,监听器调用 get() 重置页码。三个条件在后端统计和分页前共同生效。只过滤下载的一页会造成总数错误。
创建分类为 Reference、价格为 20 的 Atlas,以及分类为 Fiction、价格为 5 的 Novel。搜索 Atlas、选择 Reference 并把最低价格设为 10,应只显示 Atlas。Reset filters 清空所有条件并回到第零页。清空最低价格表示没有价格条件,零则是实际条件值。
hookToQuery 会发起首次请求,不要再通过 onMounted 初始化同一查询。Vue 批处理同步状态变化,查询监听器协调最终状态。查询函数收到的 AbortSignal 会传给 RestService。
服务端排序与分页
表格发出排序变化,不重排服务端记录。示例在注册查询之前监听 sortKey、sortOrder 和每页数量,并重置页码,避免使用旧页偏移请求新的排序。
页码从零开始:第 1 页、每页 10 条发送 skipCount: 10。分页总数绑定服务端 totalCount,不能使用 items.length。只有后端支持列 id 时才开启排序。示例允许 name、price,并用 id 作为次级排序以稳定分页。
第三次点击排序会清除方向。查询中只有字段名、没有方向时,配套后端按升序处理。请明确自己的后端默认行为,不要假定控件会排序数据。
刷新、重试与空结果
get() 回到第零页,getWithoutPageReset() 重复当前查询。Refresh 与 Retry 使用后者。查询失败保留之前的记录并暴露 error;成功但没有匹配项时显示 empty 插槽。
停止示例后端并点击 Refresh 可以查看失败状态,重新启动后点击 Retry。通用 HTTP 处理器也可能报告异常;页面消息解释列表状态,而不是重复同一异常。不要把错误转换为空的成功响应。
新增、编辑与删除
New book 重置页面拥有的表单。Edit 先读取完整详情,再打开对话框。保存前验证,忙碌状态阻止重复提交;失败保留草稿和字段错误。Cancel 调用模态框页脚受保护的关闭方法。
新增后用 get() 回首页,编辑后用 getWithoutPageReset() 保留当前页。当前筛选继续生效,所以新增或修改成功的记录可能不再匹配。
Delete 先确认、等待服务端完成,再从选择中移除 id。新总数不再覆盖当前页时,页面退回最后一个有效页并查询。删除失败保留记录和选择。使用每页 5 条、共 6 条匹配记录,进入第二页并删除最后一条,可以检查是否回到第一页。
选择与保存的偏好
record-key="id" 保证排序后的身份稳定。选择 id 与查询状态分开:示例在筛选变化时清空,翻页时保留。表头复选框只改变当前可见页的 id。批量端点仍须重新授权每个 id。
persistKey: 'Documentation.Catalogue' 按用户保存每页数量和排序,不保存筛选、页码或选择。调整数量和排序后刷新页面检查恢复;退出只清理当前用户的偏好。页面结构变化时迁移已有值或使用新的稳定 key,不要使用翻译后的标签。
AbpExtensibleTable 可以共用列表 key 保存隐藏列。普通业务页面拥有自己的列,不需要扩展容器。
排查查询
页面结果不正确时检查实际 URL 与响应,确认后端接受全部条件,在 skip/take 前排序,并且 totalCount 描述筛选后的集合。检查只有一份查询注册,查询函数和参数计算没有递归调用 get()。