详情页:查看与编辑数据
本节你将学到
- 用
getOne查询单条数据 - 用
update更新数据 - 用
delete删除数据 - 实现查看/编辑模式切换
预计时间:20 分钟
我们要做什么?
在上一节,我们实现了客户列表。现在来实现详情页:
- 展示单条客户的完整信息
- 支持编辑(点击切换到编辑模式)
- 支持删除(带确认)
- 操作成功后返回列表

第一步:配置路由
详情页需要通过 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 | 作用 | 返回值 |
|---|---|---|
getOne(id) | 获取单条数据 | 数据对象本身 |
update(id, data) | 更新数据 | 更新后的完整数据 |
delete(id) | 删除数据 | 无(仅 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()
});