17 · nuqs URL 状态:让工作台可以刷新、返回和分享#
小陈把工作台筛成“紧急、分配给我、第 2 页”,打开详情后希望回来仍是这组条件,也希望能复制链接。把筛选只放进 useState,刷新就会丢;全塞进 Zustand 又不方便分享。当前项目把这类浏览意图放进 URL,由 nuqs 管理。
1. URL 是可序列化的浏览条件#
概念示例,不是真实团队链接:
/?team=<团队UUID>&priority=URGENT&assignment=mine&page=2&pageSize=10&q=登录URL 保存条件,不保存查到的工单,也不保存未发送回复或认证令牌。复制给别人时,对方仍要登录并通过授权,结果受对方身份影响。
尤其 assignment=mine 表示“当前请求人的成员关系”。同一个链接给另一位客服,不保证看到相同工单;分享的是筛选意图,不是数据快照。
2. parser 如何把字符串变成组件可用状态#
url-state.ts 集中定义:
| 参数 | 解析后值 | 默认或无效输入行为 |
|---|---|---|
page |
1~100000 整数 | hook 使用默认 1 |
pageSize |
1~50 整数 | hook 使用默认 10 |
status |
工单状态枚举 | 无有效枚举时为 null |
priority |
优先级枚举 | 无有效枚举时为 null |
assignment |
all / mine / unassigned | 默认 all |
q |
不超过 100 字符的字符串 | 默认空字符串 |
自定义数字 parser 先用 /^\d+$/ 限制字符串形状,再解析和检查范围。所以 1.5、-1、2abc 不会被当成合法页码。
parse() 本身在失败时返回 null,.withDefault(...) 让 hook 使用缺省值;并不是测试直接调用 .parse('-1') 就会得到 1。枚举没有设置默认状态 OPEN,无有效状态表示不附加状态筛选。
解析回落不等于地址栏上的每个非法参数都会立刻被自动清理。先检查组件实际使用的值,再区分原始 URL 和序列化后的 URL。
3. useQueryStates 如何连接 UI 与查询#
TicketList 使用:
// 源码节选
const [filters, setFilters] = useQueryStates(ticketSearchParsers, {
history: 'push',
shallow: true,
});filters 是解析后的状态,setFilters 用来更新这组参数。当前 team 在 TeamWorkspace 用单独的 useQueryState('team', parseAsString) 管理;它不在 ticketSearchParsers 内。teamParser 虽被导出,当前工作区并没有直接使用这个导出。
providers.tsx 的 NuqsAdapter 将 nuqs 接到 Next.js App Router。页面仍是当前框架的客户端交互组件,不能绕过安装版本的路由与渲染约定。
history: 'push'让这类条件更新进入浏览历史,支持后退、前进恢复条件。shallow: true让这些更新侧重客户端 URL 状态,不要求为此重跑 Server Component 数据读取。
“shallow”不等于“不会发网络请求”。解析值变了,组件会给 TanStack Query 传新输入;查询键变化后,它仍可按缓存策略发起 tRPC 请求。
4. 筛选更新为什么总是配合 page: 1#
// 源码节选:状态筛选
onChange={(value) => {
void setFilters({ status: value ?? null, page: 1 });
}}从第 8 页筛成只有 1 页结果的条件,继续保留页码会显示空页。useQueryStates 让相关参数作为一次逻辑更新提交,避免组件先使用新筛选与旧页码这个中间组合。
页大小变化也回到第一页;普通翻页则保留当前页大小。setFilters(null) 重置这组工单参数,不会因为 team 恰好也在 URL 中就把它一起清空。
搜索使用 Input.Search.onSearch 提交条件,不是每输入一个字符就立即查询。组件以 key={q} 配合 defaultValue={q},让前进后退恢复 q 时输入框也重新反映 URL 值。
5. Query 与服务端怎样继续处理条件#
列表查询输入包括当前团队、分页、状态、优先级、assignment 和 q。可空状态转为 undefined,表示未指定筛选;不是给数据库传一个“null 状态”。
服务端过滤器 在团队和客户归属条件之外继续叠加:标题包含搜索、优先级、状态和负责人条件。当前搜索只查标题,并使用大小写不敏感匹配,不会自动搜索描述或所有回复。
mine 对应当前 Membership.id,unassigned 对应空负责人。客户界面隐藏分配筛选,但即使手改 URL,服务端创建者和团队边界仍在,不能通过筛选扩大可见范围。
6. 详情链接与返回链接怎样保留上下文#
列表行用 useSearchParams() 读取当前参数,转成新 URLSearchParams,带到详情链接;详情的返回列表链接再带回这些参数。这里是读取快照后构造新对象,不是直接修改 useSearchParams 返回的只读对象。
| 操作路径 | 当前保留的条件 | 原因 |
|---|---|---|
| 列表行 → 已有工单详情 → 返回列表 | 当前查询参数及团队 | 链接显式传递 |
| 创建工单成功 → 新详情 | 团队 | 创建成功导航只带 team |
| 切换团队 → 工作台 | 新团队 | 工作区导航到根路径,不承诺保留旧筛选 |
| 详情里的回复分页 | 不在 URL | Conversation 自己的局部 state |
因此“所有动作都会无损恢复所有筛选”并不是当前实现。要分享某张已有工单并保留列表条件,应从当前列表行打开详情,而不是假设所有跳转都拼接同一套参数。
拓展阅读URL 校验为什么不能替代接口校验?
nuqs parser 让浏览器在坏链接下仍能得到合理状态;Zod 再保护真正的 HTTP 输入。外部脚本不需要经过页面 parser,合法 UUID 也不代表有团队资格。URL、schema、授权分别处理交互、输入合同和业务访问,三层都要存在。
7. Suspense 与当前 Next.js 边界#
项目附带的 useSearchParams 指南说明它属于客户端只读查询参数 Hook;页面在预渲染场景下还要考虑 Suspense 边界。
首页 与 详情页 都使用 Suspense。它们不是让每个 Query loading 自动消失的魔法:工作台仍显式处理加载、错误、空结果。遇到构建提示 URL 数据缺少边界,应按当前安装版本文档修复,不要直接搬旧版路由的 shallow 写法。
8. 本章练习#
在有数据的本地工作台切换筛选、翻页,复制链接刷新;打开列表里的详情,再返回;然后用浏览器后退和前进观察 q、下拉框与表格输入是否一致。再手工输入 page=-1 或非法状态,确认解析回落且接口没有读取超量数据。
练习:把同一条 assignment=mine 链接发给另一客服,为什么结果可能不同?
动手练习查看参考答案
参考答案:URL 保存“分配给当前我”的相对条件;服务根据对方会话与团队成员关系计算 mine,还要执行权限过滤。它既不是负责人 ID 的固定快照,也不是访问授权。
完整链路是:URL → nuqs 解析 → UI 与 Query 输入 → tRPC / Zod → 成员权限和筛选条件 → 数据库 → Query 缓存 → 表格;用户操作再经 setFilters 写回 URL。