跳到主要内容

列表页:分页查询与筛选

本节实现一个客户列表页面:展示客户数据,支持关键词搜索、状态筛选和分页加载。

本节你将学到
  • 使用 filter 查询数据列表
  • 使用 $contain 实现关键词搜索
  • 使用 $eq 添加状态筛选
  • 使用 currentPage / pageSize 实现分页加载
  • 预计时间:20 分钟

目标效果

我们要开发一个客户列表页面,功能包括:

  • 展示客户数据:姓名、电话、公司、状态
  • 支持按姓名、电话、公司搜索
  • 支持按客户状态筛选
  • 支持分页加载

AI 辅助开发

推荐方式

没有 Rabetbase 时,一个客户列表页往往不只是写页面。你还需要后端接口、数据库查询、分页参数、认证、跨域和前后端联调。

有了 Rabetbase,数据集、API、SDK 和认证已经准备好。前端通过 lovrabetClient.models.customers.filter() 就可以查询数据,不需要自己写后端接口,也不需要手写 SQL。

在 Claude Code 中输入:

用 rabetbase CLI 帮我创建一个客户列表页面,需要展示客户姓名、电话、公司、状态。
支持关键词搜索(搜姓名、电话、公司),支持按状态筛选,支持分页。

AI 通常会自动完成:

  1. 查询客户数据集结构,例如字段名、类型、枚举值。
  2. 生成 React 列表页组件。
  3. 使用 filter() 查询列表数据。
  4. 使用 $contain 实现模糊搜索。
  5. 使用 $eq 实现状态筛选。
  6. 加上分页参数和分页控件。

手动实现

下面是手动编码步骤。你可以逐步实现,也可以直接查看最后的完整代码。

第一步:了解数据结构

写列表页前,需要先知道客户表有哪些字段。

如果你配置了 MCP,可以直接问 AI:

帮我查一下客户表有哪些字段?

AI 会返回类似结果:

id: 客户 ID(主键)
name: 姓名
phone: 电话
company: 公司
status: 状态(枚举:potential / active / lost)
create_time: 创建时间

也可以查看 CLI 生成的 src/api/api.ts,里面会包含数据集配置:

export const LOVRABET_MODELS_CONFIG = {
models: [
{
datasetCode: "8d2dcbae08b54bdd84c00be558ed48df",
tableName: "customer",
name: "客户",
alias: "customers",
},
],
};
备注

后续示例使用 customers 这个模型别名。你也可以使用完整的 dataset_xxx 方式访问数据集。

第二步:写一个最简单的列表

先不考虑搜索和筛选,只查询并展示前 20 条客户数据。

src/pages/ 目录下创建 customer-list.tsx

// src/pages/customer-list.tsx

/**
* Title: 客户列表
*/
import { useEffect, useState } from "react";
import { lovrabetClient } from "../api/client";

export default function CustomerList() {
const [customers, setCustomers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
async function fetchCustomers() {
try {
const result = await lovrabetClient.models.customers.filter({
pageSize: 20,
});

setCustomers(result.tableData);
} catch (error) {
console.error("获取数据失败:", error);
} finally {
setLoading(false);
}
}

fetchCustomers();
}, []);

if (loading) {
return <div>加载中...</div>;
}

return (
<div>
<h1>客户列表</h1>

<table>
<thead>
<tr>
<th>姓名</th>
<th>电话</th>
<th>公司</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{customers.map((customer) => (
<tr key={customer.id}>
<td>{customer.name}</td>
<td>{customer.phone}</td>
<td>{customer.company || "-"}</td>
<td>{customer.status}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

代码解读

  • lovrabetClient.models.customers:访问客户数据集,customers 是模型别名。
  • filter():查询列表数据,返回分页结果。
  • result.tableData:当前页的数据数组。

验证效果

启动项目:

rabetbase run start

打开浏览器访问 http://localhost:5173,应该能看到客户列表。

第三步:添加关键词搜索

接下来添加搜索框,让用户可以按姓名、电话或公司搜索客户。

// src/pages/customer-list.tsx

/**
* Title: 客户列表
*/
import { useEffect, useState } from "react";
import { lovrabetClient } from "../api/client";

export default function CustomerList() {
const [customers, setCustomers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState("");

useEffect(() => {
async function fetchCustomers() {
setLoading(true);

try {
const result = await lovrabetClient.models.customers.filter({
where: keyword
? {
$or: [
{ name: { $contain: keyword } },
{ phone: { $contain: keyword } },
{ company: { $contain: keyword } },
],
}
: undefined,
pageSize: 20,
});

setCustomers(result.tableData);
} catch (error) {
console.error("获取数据失败:", error);
} finally {
setLoading(false);
}
}

fetchCustomers();
}, [keyword]);

return (
<div>
<h1>客户列表</h1>

<input
type="text"
placeholder="搜索姓名、电话或公司..."
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ marginBottom: 16, padding: 8, width: 300 }}
/>

<table>{/* 表格内容保持不变 */}</table>
</div>
);
}

代码解读

  • $or:或条件,满足任意一个条件即可。
  • $contain:模糊匹配,类似 SQL 的 LIKE
  • useEffect 的依赖数组包含 keyword,关键词变化时会重新查询。

第四步:添加状态筛选

除了关键词搜索,还可以按客户状态筛选。

// src/pages/customer-list.tsx

/**
* Title: 客户列表
*/
import { useEffect, useState } from "react";
import { lovrabetClient } from "../api/client";

export default function CustomerList() {
const [customers, setCustomers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");

useEffect(() => {
async function fetchCustomers() {
setLoading(true);

try {
const conditions: any[] = [];

if (status) {
conditions.push({ status: { $eq: status } });
}

if (keyword) {
conditions.push({
$or: [
{ name: { $contain: keyword } },
{ phone: { $contain: keyword } },
{ company: { $contain: keyword } },
],
});
}

const result = await lovrabetClient.models.customers.filter({
where: conditions.length > 0 ? { $and: conditions } : undefined,
pageSize: 20,
});

setCustomers(result.tableData);
} catch (error) {
console.error("获取数据失败:", error);
} finally {
setLoading(false);
}
}

fetchCustomers();
}, [keyword, status]);

return (
<div>
<h1>客户列表</h1>

<div style={{ marginBottom: 16 }}>
<input
type="text"
placeholder="搜索..."
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ padding: 8, width: 200 }}
/>

<select
value={status}
onChange={(event) => setStatus(event.target.value)}
style={{ marginLeft: 8, padding: 8 }}
>
<option value="">全部状态</option>
<option value="potential">潜在客户</option>
<option value="active">活跃客户</option>
<option value="lost">流失客户</option>
</select>
</div>

<table>{/* 表格内容保持不变 */}</table>
</div>
);
}

代码解读

  • $and:与条件,所有条件都需要满足。
  • $eq:等于,精确匹配。
  • 搜索和筛选同时生效时,用 $and 连接多个条件。

第五步:添加分页

数据量变大后,需要加分页参数和分页控件。

// src/pages/customer-list.tsx

/**
* Title: 客户列表
*/
import { useEffect, useState } from "react";
import { lovrabetClient } from "../api/client";

export default function CustomerList() {
const [customers, setCustomers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;

useEffect(() => {
async function fetchCustomers() {
setLoading(true);

try {
const conditions: any[] = [];

if (status) {
conditions.push({ status: { $eq: status } });
}

if (keyword) {
conditions.push({
$or: [
{ name: { $contain: keyword } },
{ phone: { $contain: keyword } },
{ company: { $contain: keyword } },
],
});
}

const result = await lovrabetClient.models.customers.filter({
where: conditions.length > 0 ? { $and: conditions } : undefined,
currentPage: page,
pageSize,
orderBy: [{ id: "desc" }],
});

setCustomers(result.tableData);
setTotal(result.total);
} catch (error) {
console.error("获取数据失败:", error);
} finally {
setLoading(false);
}
}

fetchCustomers();
}, [keyword, status, page]);

useEffect(() => {
setPage(1);
}, [keyword, status]);

const totalPages = Math.ceil(total / pageSize);

return (
<div>
<h1>客户列表</h1>

<div style={{ marginBottom: 16 }}>
<input
type="text"
placeholder="搜索..."
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ padding: 8, width: 200 }}
/>

<select
value={status}
onChange={(event) => setStatus(event.target.value)}
style={{ marginLeft: 8, padding: 8 }}
>
<option value="">全部状态</option>
<option value="potential">潜在客户</option>
<option value="active">活跃客户</option>
<option value="lost">流失客户</option>
</select>
</div>

<table>
<thead>
<tr>
<th>姓名</th>
<th>电话</th>
<th>公司</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={4}>加载中...</td>
</tr>
) : customers.length === 0 ? (
<tr>
<td colSpan={4}>暂无数据</td>
</tr>
) : (
customers.map((customer) => (
<tr key={customer.id}>
<td>{customer.name}</td>
<td>{customer.phone}</td>
<td>{customer.company || "-"}</td>
<td>{customer.status}</td>
</tr>
))
)}
</tbody>
</table>

<div style={{ marginTop: 16 }}>
<span>
{total} 条,第 {page}/{totalPages || 1}
</span>
<button
disabled={page <= 1}
onClick={() => setPage((current) => current - 1)}
style={{ marginLeft: 8 }}
>
上一页
</button>
<button
disabled={page >= totalPages}
onClick={() => setPage((current) => current + 1)}
style={{ marginLeft: 8 }}
>
下一页
</button>
</div>
</div>
);
}

代码解读

  • currentPage:当前页码,从 1 开始。
  • pageSize:每页条数。
  • result.total:符合筛选条件的总记录数。
  • orderBy:排序规则,desc 为降序,asc 为升序。
  • 搜索或筛选变化时,需要把页码重置到第一页。

完整代码

把上面的逻辑整合后,完整页面如下:

// src/pages/customer-list.tsx

/**
* Title: 客户列表
*/
import { useEffect, useState } from "react";
import { lovrabetClient } from "../api/client";

interface Customer {
id: number;
name: string;
phone: string;
company: string;
status: "potential" | "active" | "lost";
create_time: string;
}

const STATUS_LABELS: Record<Customer["status"], string> = {
potential: "潜在客户",
active: "活跃客户",
lost: "流失客户",
};

export default function CustomerList() {
const [customers, setCustomers] = useState<Customer[]>([]);
const [loading, setLoading] = useState(true);
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const pageSize = 20;

useEffect(() => {
async function fetchCustomers() {
setLoading(true);

try {
const conditions: any[] = [];

if (status) {
conditions.push({ status: { $eq: status } });
}

if (keyword) {
conditions.push({
$or: [
{ name: { $contain: keyword } },
{ phone: { $contain: keyword } },
{ company: { $contain: keyword } },
],
});
}

const result = await lovrabetClient.models.customers.filter({
where: conditions.length > 0 ? { $and: conditions } : undefined,
currentPage: page,
pageSize,
orderBy: [{ create_time: "desc" }],
});

setCustomers(result.tableData || []);
setTotal(result.total || 0);
} catch (error) {
console.error("获取数据失败:", error);
} finally {
setLoading(false);
}
}

fetchCustomers();
}, [keyword, status, page]);

useEffect(() => {
setPage(1);
}, [keyword, status]);

const totalPages = Math.ceil(total / pageSize);

return (
<div style={{ padding: 24 }}>
<h1>客户列表</h1>

<div style={{ marginBottom: 16, display: "flex", gap: 8 }}>
<input
type="text"
placeholder="搜索姓名、电话或公司..."
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
style={{ padding: 8, width: 240 }}
/>
<select
value={status}
onChange={(event) => setStatus(event.target.value)}
style={{ padding: 8 }}
>
<option value="">全部状态</option>
<option value="potential">潜在客户</option>
<option value="active">活跃客户</option>
<option value="lost">流失客户</option>
</select>
</div>

<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr style={{ backgroundColor: "#f5f5f5" }}>
<th style={thStyle}>姓名</th>
<th style={thStyle}>电话</th>
<th style={thStyle}>公司</th>
<th style={thStyle}>状态</th>
<th style={thStyle}>创建时间</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} style={tdStyle}>
加载中...
</td>
</tr>
) : customers.length === 0 ? (
<tr>
<td colSpan={5} style={tdStyle}>
暂无数据
</td>
</tr>
) : (
customers.map((customer) => (
<tr key={customer.id}>
<td style={tdStyle}>{customer.name}</td>
<td style={tdStyle}>{customer.phone}</td>
<td style={tdStyle}>{customer.company || "-"}</td>
<td style={tdStyle}>{STATUS_LABELS[customer.status]}</td>
<td style={tdStyle}>
{new Date(customer.create_time).toLocaleDateString()}
</td>
</tr>
))
)}
</tbody>
</table>

<div
style={{ marginTop: 16, display: "flex", alignItems: "center", gap: 8 }}
>
<span>{total}</span>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
上一页
</button>
<span>
{page} / {totalPages || 1}
</span>
<button
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
下一页
</button>
</div>
</div>
);
}

const thStyle: React.CSSProperties = {
padding: 12,
textAlign: "left",
borderBottom: "1px solid #ddd",
};

const tdStyle: React.CSSProperties = {
padding: 12,
borderBottom: "1px solid #eee",
};

本节小结

知识点说明
filter()查询数据列表
where查询条件
$eq等于
$contain模糊匹配
$and / $or逻辑连接
currentPage / pageSize分页参数
orderBy排序

常见问题

为什么用 customers,而不是 dataset_xxx

customers 是在 src/api/api.ts 中配置的模型别名,方便记忆。你也可以使用完整的 datasetCode

// 标准方式:使用完整 datasetCode
lovrabetClient.models.dataset_8d2dcbae08b54bdd84c00be558ed48df.filter();

// 别名方式:更易读
lovrabetClient.models.customers.filter();

搜索不生效怎么办?

重点检查三件事:

  1. 字段名是否正确,应该使用数据库字段名,不是中文显示名。
  2. 模糊匹配操作符是 $contain,不要写成 $like
  3. 数据库里是否存在匹配的数据。

分页总数不对怎么办?

result.total 返回的是符合当前筛选条件的总记录数,不是当前页的记录数。

下一步

继续实现 详情页:查看与编辑数据