跳到主要内容

详情页:查看与编辑数据

本节你将学到
  • 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: getOnefilter 返回的数据有什么区别?

  • 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()
});

下一步