2. 详情页
详情页:查看与编辑数据
ℹ️ 信息 — 本节你将学到
-
用
getOne查询单条数据 -
用
update更新数据 -
用
delete删除数据 -
实现查看/编辑模式切换预计时间:20 分钟
需求
在上一节,我们实现了客户列表。现在来实现详情页:
-
展示单条客户的完整信息
-
支持编辑(点击切换到编辑模式)
-
支持删除(带确认)
-
操作成功后返回列表
客户详情页面
AI 辅助开发(推荐)
**为什么 AI 能帮你做详情页?**没有 rabetbase,详情页需要 3 个后端接口:GET /customers/:id、PUT /customers/:id、DELETE /customers/:id。每个都要写 SQL、处理参数、加认证、处理错误、配跨域。后端同学写了 3 天,前端等了 3 天。联调第一天:查询接口返回格式对不上(后端包了 \{ data: \{\.\.\.\} \},前端直接取了顶层)。第二天:更新接口后端只接受 form\-urlencoded,前端发的是 JSON。第三天:删除接口报 403——后端忘了配 CORS 的 DELETE 方法。一个详情页,从写后端到联调跑通用了两周。有了 rabetbase:不用写后端接口。SDK 的 getOne\(id\)、update\(id, data\)、delete\(id\) 三行代码搞定查询、更新、删除。参数格式、返回值结构、认证方式全部内置。不用写 SQL、不用等后端、不用联调、不用配 CORS。
在 Claude Code 中输入:
用 rabetbase CLI 帮我创建一个客户详情页面,支持查看和编辑两种模式切换,有编辑和删除按钮。编辑时可以修改姓名、电话、公司、状态字段,保存后刷新数据。删除前需要确认。列表页点击行跳转到详情页。
AI 会做什么
AI 会自动生成完整的 React 组件代码,包含:
-
路由配置(
/customer/:id) -
getOne获取单条数据 -
查看/编辑模式切换
-
update保存编辑 -
delete带确认的删除 -
列表页跳转链接下方是 AI 生成的分步代码。
🔧 手动编码(备选)按下方步骤逐步创建文件,手动编写每个组件。
第一步:配置路由
详情页需要通过 URL 中的 ID 来获取数据,所以先配置路由。
// src/App.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import CustomerList from "./pages/customer-list";
import CustomerDetail from "./pages/customer-detail";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<CustomerList />} />
<Route path="/customer/:id" element={<CustomerDetail />} />
</Routes>
</BrowserRouter>
);
}
export default App;
路由参数说明
-
/customer/:id-:id是动态参数,可以在组件中通过useParams\(\)获取 -
访问
/customer/123时,id就是123
第二步:获取数据详情
创建详情页组件,用 getOne 获取单条数据:
// src/pages/customer-detail.tsx
/**
* Title: 客户详情
*/
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { lovrabetClient } from "../api/client";
// 客户数据类型
interface Customer {
id: number;
name: string;
phone: string;
company: string;
status: "potential" | "active" | "lost";
create_time: string;
update_time: string;
}
export default function CustomerDetail() {
// 从 URL 获取客户 ID
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
// 获取客户详情
useEffect(() => {
async function fetchCustomer() {
if (!id) return;
try {
const data = await lovrabetClient.models.customers.getOne<Customer>(id);
setCustomer(data);
} catch (error) {
console.error("获取数据失败:", error);
alert("数据不存在或已删除");
navigate("/");
} finally {
setLoading(false);
}
}
fetchCustomer();
}, [id]);
if (loading) {
return <div style={{ padding: 24 }}>加载中...</div>;
}
if (!customer) {
return <div style={{ padding: 24 }}>数据不存在</div>;
}
return (
<div style={{ padding: 24 }}>
<button onClick={() => navigate("/")}>← 返回列表</button>
<h1>客户详情</h1>
<div style={{ marginTop: 16 }}>
<p><strong>姓名:</strong>{customer.name}</p>
<p><strong>电话:</strong>{customer.phone}</p>
<p><strong>公司:</strong>{customer.company || "-"}</p>
<p><strong>状态:</strong>{customer.status}</p>
<p><strong>创建时间:</strong>{customer.create_time}</p>
</div>
</div>
);
}
代码解读
-
useParams\(\)- 获取 URL 中的:id参数 -
useNavigate\(\)- 编程式导航,用于跳转页面 -
\.getOne\<Customer\>\(id\)- 根据 ID 获取单条数据,支持泛型 -
getOne直接返回数据对象,不是\{ data: \.\.\. \}的包装结构
第三步:添加编辑功能
现在让用户可以编辑客户信息。我们用"查看模式"和"编辑模式"切换的方式实现:
// src/pages/customer-detail.tsx
/**
* Title: 客户详情
*/
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { lovrabetClient } from "../api/client";
interface Customer {
id: number;
name: string;
phone: string;
company: string;
status: "potential" | "active" | "lost";
create_time: string;
}
const STATUS_OPTIONS = [
{ value: "potential", label: "潜在客户" },
{ value: "active", label: "活跃客户" },
{ value: "lost", label: "流失客户" },
];
export default function CustomerDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
// 新增:编辑相关状态
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<Partial<Customer>>({});
const [saving, setSaving] = useState(false);
// 获取客户详情
useEffect(() => {
async function fetchCustomer() {
if (!id) return;
try {
const data = await lovrabetClient.models.customers.getOne<Customer>(id);
setCustomer(data);
setEditForm(data); // 初始化编辑表单
} catch (error) {
console.error("获取数据失败:", error);
alert("数据不存在");
navigate("/");
} finally {
setLoading(false);
}
}
fetchCustomer();
}, [id]);
// 新增:保存编辑
const handleSave = async () => {
if (!id) return;
setSaving(true);
try {
// 调用 update API
const updated = await lovrabetClient.models.customers.update(id, editForm);
setCustomer(updated); // 更新显示数据
setIsEditing(false); // 退出编辑模式
alert("保存成功!");
} catch (error) {
console.error("保存失败:", error);
alert("保存失败,请重试");
} finally {
setSaving(false);
}
};
// 新增:取消编辑
const handleCancel = () => {
setEditForm(customer || {}); // 恢复原始数据
setIsEditing(false);
};
if (loading) return <div style={{ padding: 24 }}>加载中...</div>;
if (!customer) return <div style={{ padding: 24 }}>数据不存在</div>;
return (
<div style={{ padding: 24 }}>
<button onClick={() => navigate("/")}>← 返回列表</button>
<h1>客户详情</h1>
{isEditing ? (
// 编辑模式
<div style={{ marginTop: 16 }}>
<div style={formGroupStyle}>
<label style={labelStyle}>姓名</label>
<input
style={inputStyle}
value={editForm.name || ""}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
</div>
<div style={formGroupStyle}>
<label style={labelStyle}>电话</label>
<input
style={inputStyle}
value={editForm.phone || ""}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
/>
</div>
<div style={formGroupStyle}>
<label style={labelStyle}>公司</label>
<input
style={inputStyle}
value={editForm.company || ""}
onChange={(e) => setEditForm({ ...editForm, company: e.target.value })}
/>
</div>
<div style={formGroupStyle}>
<label style={labelStyle}>状态</label>
<select
style={inputStyle}
value={editForm.status || ""}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value as any })}
>
{STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
<div style={{ marginTop: 24 }}>
<button onClick={handleSave} disabled={saving} style={{ marginRight: 8 }}>
{saving ? "保存中..." : "保存"}
</button>
<button onClick={handleCancel} disabled={saving}>
取消
</button>
</div>
</div>
) : (
// 查看模式
<div style={{ marginTop: 16 }}>
<p><strong>姓名:</strong>{customer.name}</p>
<p><strong>电话:</strong>{customer.phone}</p>
<p><strong>公司:</strong>{customer.company || "-"}</p>
<p><strong>状态:</strong>{
STATUS_OPTIONS.find(o => o.value === customer.status)?.label
}</p>
<p><strong>创建时间:</strong>{
new Date(customer.create_time).toLocaleString()
}</p>
<div style={{ marginTop: 24 }}>
<button onClick={() => setIsEditing(true)} style={{ marginRight: 8 }}>
编辑
</button>
</div>
</div>
)}
</div>
);
}
// 简单样式
const formGroupStyle: React.CSSProperties = { marginBottom: 16 };
const labelStyle: React.CSSProperties = { display: "block", marginBottom: 4, fontWeight: "bold" };
const inputStyle: React.CSSProperties = { padding: 8, width: 300 };
代码解读
-
update\(id, data\)- 更新指定 ID 的记录 -
只传需要修改的字段即可,不需要传全部字段
-
返回更新后的完整数据(包含自动更新的
update\_time) -
编辑时用独立的
editForm状态,取消时恢复原数据,避免污染显示
第四步:添加删除功能
删除操作要谨慎,加上确认弹窗:
// 在 CustomerDetail 组件中添加
// 删除客户
const handleDelete = async () => {
if (!id) return;
// 确认弹窗
const confirmed = window.confirm("确定要删除这个客户吗?此操作不可恢复!");
if (!confirmed) return;
try {
await lovrabetClient.models.customers.delete(id);
alert("删除成功!");
navigate("/"); // 返回列表
} catch (error) {
console.error("删除失败:", error);
alert("删除失败,请重试");
}
};
// 在 JSX 中添加删除按钮
<button onClick={handleDelete} style={{ marginLeft: 8, color: "red" }}>
删除
</button>
⚠️ 警告 — 注意删除功能只在 WebAPI 模式(Cookie 认证)下可用。如果你用的是 OpenAPI 模式(AccessKey 认证),删除操作会报错。这种情况下建议用"软删除"——添加一个 deleted 字段标记删除状态。
第五步:列表页添加跳转
最后,让列表页的每一行可以点击跳转到详情页:
// src/pages/customer-list.tsx 中修改
import { useNavigate } from "react-router-dom";
export default function CustomerList() {
const navigate = useNavigate();
// ... 其他代码 ...
return (
<table>
<tbody>
{customers.map((customer) => (
<tr
key={customer.id}
onClick={() => navigate(`/customer/${customer.id}`)}
style={{ cursor: "pointer" }} // 鼠标变成手型
>
<td>{customer.name}</td>
<td>{customer.phone}</td>
{/* ... */}
</tr>
))}
</tbody>
</table>
);
}
完整代码
点击查看完整代码
// src/pages/customer-detail.tsx
/**
* Title: 客户详情
*/
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { lovrabetClient } from "../api/client";
interface Customer {
id: number;
name: string;
phone: string;
company: string;
status: "potential" | "active" | "lost";
create_time: string;
update_time: string;
}
const STATUS_OPTIONS = [
{ value: "potential", label: "潜在客户" },
{ value: "active", label: "活跃客户" },
{ value: "lost", label: "流失客户" },
];
export default function CustomerDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [customer, setCustomer] = useState<Customer | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState<Partial<Customer>>({});
const [saving, setSaving] = useState(false);
// 获取数据
useEffect(() => {
async function fetchCustomer() {
if (!id) return;
setLoading(true);
try {
const data = await lovrabetClient.models.customers.getOne<Customer>(id);
setCustomer(data);
setEditForm(data);
} catch (error) {
console.error("获取数据失败:", error);
alert("数据不存在");
navigate("/");
} finally {
setLoading(false);
}
}
fetchCustomer();
}, [id]);
// 保存
const handleSave = async () => {
if (!id) return;
setSaving(true);
try {
const updated = await lovrabetClient.models.customers.update(id, editForm);
setCustomer(updated);
setIsEditing(false);
alert("保存成功!");
} catch (error) {
console.error("保存失败:", error);
alert("保存失败,请重试");
} finally {
setSaving(false);
}
};
// 取消编辑
const handleCancel = () => {
setEditForm(customer || {});
setIsEditing(false);
};
// 删除
const handleDelete = async () => {
if (!id) return;
if (!window.confirm("确定要删除吗?此操作不可恢复!")) return;
try {
await lovrabetClient.models.customers.delete(id);
alert("删除成功!");
navigate("/");
} catch (error) {
console.error("删除失败:", error);
alert("删除失败,请重试");
}
};
if (loading) return <div style={{ padding: 24 }}>加载中...</div>;
if (!customer) return <div style={{ padding: 24 }}>数据不存在</div>;
const getStatusLabel = (status: string) =>
STATUS_OPTIONS.find(o => o.value === status)?.label || status;
return (
<div style={{ padding: 24, maxWidth: 600 }}>
<button onClick={() => navigate("/")}>← 返回列表</button>
<h1>{isEditing ? "编辑客户" : "客户详情"}</h1>
{isEditing ? (
<div style={{ marginTop: 16 }}>
<FormField label="姓名">
<input
value={editForm.name || ""}
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
/>
</FormField>
<FormField label="电话">
<input
value={editForm.phone || ""}
onChange={(e) => setEditForm({ ...editForm, phone: e.target.value })}
/>
</FormField>
<FormField label="公司">
<input
value={editForm.company || ""}
onChange={(e) => setEditForm({ ...editForm, company: e.target.value })}
/>
</FormField>
<FormField label="状态">
<select
value={editForm.status || ""}
onChange={(e) => setEditForm({ ...editForm, status: e.target.value as any })}
>
{STATUS_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</FormField>
<div style={{ marginTop: 24 }}>
<button onClick={handleSave} disabled={saving}>
{saving ? "保存中..." : "保存"}
</button>
<button onClick={handleCancel} style={{ marginLeft: 8 }}>取消</button>
</div>
</div>
) : (
<div style={{ marginTop: 16 }}>
<InfoRow label="姓名" value={customer.name} />
<InfoRow label="电话" value={customer.phone} />
<InfoRow label="公司" value={customer.company || "-"} />
<InfoRow label="状态" value={getStatusLabel(customer.status)} />
<InfoRow label="创建时间" value={new Date(customer.create_time).toLocaleString()} />
<div style={{ marginTop: 24 }}>
<button onClick={() => setIsEditing(true)}>编辑</button>
<button onClick={handleDelete} style={{ marginLeft: 8, color: "red" }}>
删除
</button>
</div>
</div>
)}
</div>
);
}
// 辅助组件
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ marginBottom: 16 }}>
<label style={{ display: "block", marginBottom: 4, fontWeight: "bold" }}>{label}</label>
<div>{children}</div>
</div>
);
}
function InfoRow({ label, value }: { label: string; value: string }) {
return (
<p style={{ margin: "8px 0" }}>
<strong>{label}:</strong>{value}
</p>
);
}
本节小结
你学会了:
API | 作用 | 返回值 |
| 获取单条数据 | 数据对象本身 |
| 更新数据 | 更新后的完整数据 |
| 删除数据 | 无(仅 WebAPI 模式) |
常见问题
Q: getOne 和 filter 返回的数据有什么区别?
-
getOne\(id\)- 直接返回数据对象\{ id, name, \.\.\. \} -
filter\(\)- 返回分页结构\{ tableData, total, \.\.\. \}**Q: 更新时需要传所有字段吗?**不需要。只传要修改的字段,其他字段保持不变。这样还能避免并发修改时的数据覆盖问题。**Q: 删除功能报错怎么办?**删除功能只在 WebAPI 模式(Cookie 认证)下可用。如果你用的是 OpenAPI 模式,建议用"软删除":
// 软删除:标记为已删除,而不是物理删除
await lovrabetClient.models.customers.update(id, {
status: "deleted",
deleted_at: new Date().toISOString()
});