React 管理后台实战 · 列表里突然出现一堆空白行?PIPL 擦除后前端怎么处理才不露馅
各位看官,管理后台里但凡涉及个人手机号、微信这类敏感信息,迟早要面对一件事:用户(或合规要求)要你按 PIPL 把一条记录里的个人数据彻底擦除 。这活儿后端要做得干净(我之前在《敏感数据防泄露 PII 脱敏与审计日志》里写过后端怎么脱敏、怎么留审计),但前端要是不配合,照样露馅------擦除之后列表里全是空白行、详情还弹得出明文、编辑按钮还能点,那擦了个寂寞。
我这个项目就踩过这么一个真实坑:擦除完,普通列表里凭空多出来一堆"姓名空、电话空、微信空"的幽灵行,运营一眼就看出来了------"这什么情况?数据怎么没了?"其实数据是被合规清掉了,但前端没把擦除态当回事。今天就聊聊前端这边怎么把 PIPL 擦除接得严丝合缝。

一、擦除不是删库,是"打码 + 置空 + 留痕"
先把概念掰清楚,免得后面代码看晕。PIPL 要的"擦除"和"删除"是两码事:
| 动作 | 数据还在吗 | 个人字段 | 审计 | 适用场景 |
|---|---|---|---|---|
| 软删除 | 在 | 原样 | 有 | 误删可恢复、保留业务关联 |
| 硬删除 | 不在 | --- | 弱 | 彻底不要这条 |
| PIPL 擦除 | 在 | 置空/打码 | 强 | 合规要求清除个人数据,但业务记录不能断 |
关键点:业务记录不能断 。比如一条成交记录,手机号被擦了,但"谁在什么时候成交的、成交了什么"这条业务线还得在,否则财务对账全乱。所以后端做的是把 phone、wechat 这些个人字段置空(或不可逆打码),然后打一个 erasedAt 时间戳。前端要做三件事:列表显形、详情兜底、操作收敛。
二、列表显形:别让擦除记录混进正常列表
这是我最想讲的一块,也是那个真实坑的现场。
后端列表接口返回的记录里,已擦除的和没擦除的混在一起------因为默认查询没说"只要没擦除的"。我一开始写的列表查询长这样:
ts
// 数据记录列表查询参数(节选)
export interface RecordQuery {
page: number
size: number
q?: string
level__in?: string
projectId?: string
ownerId?: string
// erased?: 0 | 1 ← 当时压根没用上
}
// 列表页拼查询参数
const query: RecordQuery = useMemo(() => ({
page,
size: PAGE_SIZE,
...(committed.q ? { q: String(committed.q) } : {}),
...(committed.level__in ? { level__in: String(committed.level__in) } : {}),
}), [page, committed.q, committed.level__in])
问题就出在:默认不传 erased,后端就不过滤,已擦除记录(个人字段全空)也一起返回了。于是运营在列表里看到一堆"姓名空、电话空、微信空"的行------技术上看是数据被合规清了,业务上看就是"怎么多出这么多空的怪行"。
修复很简单,但也容易忘:
ts
const query: RecordQuery = useMemo(() => ({
page,
size: PAGE_SIZE,
erased: 0, // 默认只拉"未擦除",幽灵行不进普通列表
...(committed.q ? { q: String(committed.q) } : {}),
...(committed.level__in ? { level__in: String(committed.level__in) } : {}),
...(showErased ? { erased: 1 } : {}), // 提供"查看已擦除"开关时才拉 1
}), [page, committed.q, committed.level__in, showErased])
一个 erased: 0 的默认值,幽灵行立刻消失。再补一个"查看已擦除"的开关,合规同学要审计时切到 erased: 1 就能看到。
这又回到我《后台列表的筛选条件,刷新就丢了?URL 做单一数据源彻底解决》里说的:筛选态最好收口到 URL。这个
showErased开关我同样放进 URL 参数,刷新、分享都不丢。

三、详情兜底:已擦除就别再弹明文
列表解决了,详情抽屉还有个隐患。后端在"列表接口"里已经把 phone 脱敏成 138****0001 了,但详情接口为了审核方便返回的是明文 。正常记录没事,已擦除记录的 phone 被后端置成了空串------这时候前端要是还按"有值才显示"的逻辑渲染,反而没问题;怕的是有些同学图省事,在详情里直接拿原始字段拼"复制手机号"按钮,擦除记录的空串一复制就露了。
我的做法是用 erasedAt 这个标记做总闸:
tsx
export const RecordDetailDrawer = ({ recordId, onClose }) => {
const { data } = useRecordDetail(recordId)
const role = useAuthStore((s) => s.user?.role)
const isTA = role === 'tenant_admin' // 仅租户管理员能擦除
const [eraseOpen, setEraseOpen] = useState(false)
const handleErase = useCallback(async () => {
if (!recordId) return
await eraseRecord(recordId) // POST /tenant/records/:id/erase
toast.success('已按 PIPL 要求清除该记录全部个人数据')
// 擦完同时刷新详情和列表,避免抽屉里还残留旧明文
qc.invalidateQueries({ queryKey: ['record', recordId] })
qc.invalidateQueries({ queryKey: ['records'] })
}, [recordId, qc])
// 头部:擦除过就显示"已擦除"灰标,而不是等级标签
return (
<div>
<h3>{data.name}</h3>
{data.erasedAt ? (
<span className="text-xs italic">已擦除</span>
) : (
<StatusTag>{levelLabel[data.level]}</StatusTag>
)}
<p>{data.phone || '---'}</p> {/* 擦除后 phone 为空,显示占位符而非空串 */}
{/* 修改等级按钮:已擦除直接 disabled */}
<Button disabled={!!data.erasedAt}>修改等级</Button>
{/* 擦除入口:仅 TA 可见 */}
{isTA && <Button variant="destructive" onClick={() => setEraseOpen(true)}>擦除</Button>}
</div>
)
}
三个细节:
erasedAt优先于业务标签:擦过就显示"已擦除"灰标,运营一眼知道这行是合规清过的,不是 bug。- 空值用
---占位:别让界面出现一串空单元格,既难看又像数据丢了。 - 擦除入口只在详情里、且只有租户管理员能点 ------配合《多角色后台的菜单和路由怎么按角色收口》那套角色权限,普通员工连按钮都看不到。
四、客户端脱敏兜底:别完全信后端
后端列表返回的是脱敏串,但有两个例外场景会拿到明文:详情接口 和导出文件 。前者刚才说了用 erasedAt 兜底;后者(导出)我在《后端异步导出做好了,前端下载却总是失败?》里讲过前端怎么接。但万一哪天后端忘了脱敏、或者你要把明文在界面上"按需显示",前端手里得有一份脱敏函数防身:
ts
// 手机号脱敏:138****1234(保留前 3 后 4)
export function maskPhone(phone: string): string {
const digits = phone.replace(/\D/g, '')
if (digits.length < 7) return phone
return `${digits.slice(0, 3)}****${digits.slice(-4)}`
}
// 微信/联系方式脱敏:首尾各留 1 位,中间星号
export function maskContact(value: string): string {
const v = value.trim()
if (v.length <= 2) return v
return `${v[0]}${'*'.repeat(Math.min(v.length - 2, 6))}${v[v.length - 1]}`
}
这套函数我放在 lib/mask.ts,列表默认走后端脱敏串,详情/导出等明文场景用它在客户端再打一道码。属于"防御性编程"------后端脱敏是主防线,前端兜底防止任何一处漏网。
五、审计留痕:擦除动作本身也要可见
合规最看重"可证明你擦了"。光数据库里有个 erasedAt 不够,得在操作日志里能查到"谁、在什么时候、擦了哪条"。我的审计页专门给擦除动作加了视觉标记:
| 动作 | 日志文案 | 视觉标记 |
|---|---|---|
| 改等级 | 将记录等级改为 VIP |
普通 |
| 分配 | 分配给 张三 |
普通 |
| 擦除 | 擦除了 |
🔴 红色 + ⚠️ 个人数据已清除 提示 |
这样审计同学翻开日志,哪条被擦了一目了然,出合规检查直接截图就能交差。

六、三个容易翻车的地方
把上面串起来,前端做 PIPL 擦除最容易漏的就三处:
| 翻车点 | 现象 | 修法 |
|---|---|---|
列表默认不过滤 erased |
幽灵空白行混入正常列表 | 默认查 erased: 0,开关查 erased: 1 |
| 详情仍渲染明文/空串 | 复制按钮复制出空、空单元格像丢数据 | erasedAt 做总闸,--- 占位 |
| 擦除后不刷缓存 | 抽屉里还残留旧明文 | 擦完 invalidate 详情 + 列表两个 key |
说白了,PIPL 擦除前端不是"调一个删除接口"那么简单,它是列表过滤 + 详情兜底 + 客户端脱敏 + 操作收敛 + 审计留痕一整套配合。后端把数据清干净只是第一步,前端不接好,合规该露还是露。
相关阅读
- Node 后端实战 · 敏感数据防泄露 PII 脱敏与审计日志
- React 管理后台实战 · 后台列表的筛选条件,刷新就丢了?URL 做单一数据源彻底解决
- React 管理后台实战 · 多角色后台的菜单和路由怎么按角色收口?配置表驱动,新增权限不碰业务代码
- React 管理后台实战 · 后端异步导出做好了,前端下载却总是失败?轮询进度 + 双形态下载一次搞定
- React 管理后台实战 · 前端请求层怎么写?401 静默刷新、token 并发竞争、强制改密拦截一次说清
- React 管理后台实战 · 列表批量操作总被后端拒?一个 runBatchChunks 兜住 50 条上限
- Node 后端实战 · 多租户 SaaS 怎么防止租户串数据?接口门禁 + 租户隔离 + 行级权限三层实战
- Node 后端实战 · 列表查询到底怎么写?一个通用 DSL 封装,过滤分页排序一次搞定
本文由 FungLeo 主导,Deepseek 优化校阅,转发请注明首发地址,谢谢大家!