列表页:分页查询与筛选
本节实现一个客户列表页面:展示客户数据,支持关键词搜索、状态筛选和分页加载。
- 使用
filter查询数据列表 - 使用
$contain实现关键词搜索 - 使用
$eq添加状态筛选 - 使用
currentPage/pageSize实现分页加载 - 预计时间:20 分钟
目标效果
我们要开发一个客户列表页面,功能包括:
- 展示客户数据:姓名、电话、公司、状态
- 支持按姓名、电话、公司搜索
- 支持按客户状态筛选
- 支持分页加载
AI 辅助开发
没有 Rabetbase 时,一个客户列表页往往不只是写页面。你还需要后端接口、数据库查询、分页参数、认证、跨域和前后端联调。
有了 Rabetbase,数据集、API、SDK 和认证已经准备好。前端通过 lovrabetClient.models.customers.filter() 就可以查询数据,不需要自己写后端接口,也不需要手写 SQL。
在 Claude Code 中输入:
用 rabetbase CLI 帮我创建一个客户列表页面,需要展示客户姓名、电话、公司、状态。
支持关键词搜索(搜姓名、电话、公司),支持按状态筛选,支持分页。
AI 通常会自动完成:
- 查询客户数据集结构,例如字段名、类型、枚举值。
- 生成 React 列表页组件。
- 使用
filter()查询列表数据。 - 使用
$contain实现模糊搜索。 - 使用
$eq实现状态筛选。 - 加上分页参数和分页控件。
手动实现
下面是手动编码步骤。你可以逐步实现,也可以直接查看最后的完整代码。
第一步:了解数据结构
写列表页前,需要先知道客户表有哪些字段。
如果你配置了 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();
搜索不生效怎么办?
重点检查三件事:
- 字段名是否正确,应该使用数据库字段名,不是中文显示名。
- 模糊匹配操作符是
$contain,不要写成$like。 - 数据库里是否存在匹配的数据。
分页总数不对怎么办?
result.total 返回的是符合当前筛选条件的总记录数,不是当前页的记录数。
下一步
继续实现 详情页:查看与编辑数据。