实习日志 / 中文 / 界面重构 / 员工工作台:从一面数据库镜像,到一个只突出一个动作的工作区
员工工作台:从一面数据库镜像,到一个只突出一个动作的工作区
中文 | English
报案端理顺以后,9 月 3 日轮到员工工作台。这一天的讨论最后落成一份很长的规格:前半是对现状的审查和目标结构,后半是和后端一起敲定的数据契约。下面按这个顺序讲。
起点:所有东西同时出现
当时的员工端由一个 employee/index.html 撑着。后来虽然把脚本和样式拆了出去,index.html 还有约 20KB,app.js 约 1,942 行,大量 DOM 查询、字符串模板、全局状态变量和 API 调用混在一起,styles.css 里叠着早期视觉方案、响应式补丁和后来的 token 覆盖。
结构上的后果很具体:新增一个案件状态,要同时改队列渲染、详情渲染、标签页和按钮逻辑;队列、详情、动作、对话、助手没法各自写组件测试;旧 CSS 还在参与级联,经常"视觉改了,旧样式又盖回来";URL、当前案件、当前标签和服务端版本号也没法保证一致。
使用上的后果更直接。打开工作台,员工同时看到左侧导航、队列筛选、统计卡、案件列表、悬停预览、详情标签、证据、历史、对话、人工审核表单、客户回复、Agent 模板和浮动助手。每一样都有用,可它们同时抢第一视觉层级,员工回答不了这几个问题:
- 现在需要处理什么?
- 应该先打开哪一单?
- 打开后最重要的阻塞是什么?
- 当前唯一的主动作是什么?
- 做完这个动作,会改变什么状态?
所以这次重构的目标我写成了一句话:工作台要从"把所有可用信息和操作同时展示的管理页面",变成"帮员工决定先处理哪一单、理解当前工作、完成一个明确动作、并留下可追溯记录的工作区"。换颜色、加卡片都解决不了这个问题。
信息按工作顺序分三层
整理的原则是不删信息,按员工的工作顺序分层:
- 工作摘要:案件身份、生命周期、当前责任方、已确认事实、缺失信息、风险信号、当前阻塞、主下一步。
- 可操作内容:当前状态允许的员工动作,每个动作写清目的、需要什么输入、会改变什么、会不会对外发送、要不要确认、失败或超时怎么继续。
- 参考资料:证据、完整字段、案件上下文、历史、完整对话、检索来源、第三方记录和审计轨迹。
同一时刻只突出一个主动作,其余动作可以并列,但不能和它用同样的视觉权重竞争。旧版本把证据审查、信号判断、结果记录和客户更新排成一个"三步引导流程",演示时好讲,正式使用时却让员工按一个固定流程走。新版改成由后端投影决定当前该做什么,前端只负责呈现清楚。
队列的问题在于几个不同层次的概念被摆在了一起。旧筛选项里有 All claims、Urgent requests、Human requests、Awaiting evidence、Professional review、Ready to progress,卡片上又挂着分配情况、标签和 AI 可信度。我把它们拆成三层:先按当前的处理责任或待办工作分,再在同一类里按优先级排,最后用标签、风险信号和筛选辅助查找。
这里有两条特意定下的规矩。
欺诈不单独成队列。 一单案件可以带多个标签,如果 Agent 的一个尚未确认的判断变成了一个叫 Fraud 的默认队列,员工还没打开案件,心里就先有了结论。风险信号只能作为信号或标签出现在工作摘要里,员工看过证据和来源再自己判断。
未完成的案件是正式案件。 用户中途离开、没有继续推进的报案,优先级可以低,但必须留在队列里,不折叠、不隐藏,并显示中断在哪、已经保存了什么、缺什么、最后一次有效互动、下次跟进时间和自动清理的期限。
关键信息也不能只靠悬停显示。触屏没有悬停,键盘用户不该被迫去探索看不见的内容,悬停预览还会挡住其他案件。队列里的每一条默认就要给出足够判断"要不要打开"的信息,更多内容用明确的按钮、展开或进入案件标签。
两个关于排序的追问
讨论里被问到两个问题,我的回答后来都成了规则。
员工能不能手动调优先级? 意思是能不能不记录任何原因,直接用下拉框把一单改成 urgent。我的回答是不提供这种任意修改。员工应该记录一个新的事实:新的紧急情况、风险判断、服务时限的变化,后端据此重新计算优先级。如果以后真的需要人工覆盖,也要做成一个带原因、操作人、有效期和审计记录的正式动作,不能是一次普通的字段编辑。
前端能不能自己排序? 员工当然可以点"按更新时间"或"按优先级"来查看,这不是问题。不允许的是前端私自发明业务规则,比如:看到欺诈标签就置顶,缺失字段越多越紧急,所有未完成案件一律沉底,最新更新就等于最优先。后端返回可解释的优先级等级、排序值和原因,前端只按这些已经投影好的字段响应员工的选择。
打开不等于接手
员工接手一单,必须是一个明确的、能被感知的动作,并且要更新共享的案件状态,报案人那边才能看到"有人在处理了"。打开一单只改变查看的上下文,不等于接手,也不能产生任何业务上的副作用。
当时核实了一遍人工协助的生命周期:
queued -> accepted -> in_progress -> resolved
报案人请求协助后是 queued;员工接受后变成 accepted,报案人的下一步状态变为 human_support_in_progress;员工发出第一条报案人可见的消息后变成 in_progress;解决以后,报案人看到一条安全的更新或新的下一步。后端这条链路的测试是通过的。
但核实时也发现两个缺口。报案端对 queued 和 accepted 有完整的状态卡,到了 in_progress 只剩一行 "A Northwind staff member is now assisting you."。更大的问题是没有实时同步:报案端没有 WebSocket、SSE,也没有轮询。员工接手或发了消息,停在旧页面上的报案人什么都看不到,要刷新或重新进入才会更新。这个缺口不能被一张设计得很好的状态卡掩盖,所以我把它定成工作台重构的必要依赖:员工接手、发消息、解决都要产生可以补齐的事件,报案人那边按事件游标和版本号接收更新,断线以后能补上漏掉的事件。
谁能接手,谁能改什么
接手采用一个混合模型。完全由员工自己领取,会出现没人接的单;完全由系统自动指派,又可能把单子塞给当下没空处理的人。所以:
- 后端按工作类型、优先级、时限和员工的处理范围形成队列和推荐顺序;
- 员工明确点击
Accept claim或"处理下一单"; - 用版本号比较和短时预留,防止两个人同时接手;
- 接手的人成为主负责人;
- 长时间没有有效活动,先提醒,再通过一个明确的事件退回队列;
- 有未提交的草稿、正在执行的外部动作或活跃的协作时,不允许静默重新分配;
- 转交和协作都记下请求、接受、拒绝、权限变化和时间;
- 正常的转交由当前主负责人接受,只有具备调度权限的员工,才能在主负责人无响应或时限要求下强制转交,并且要写明原因;
- 获批的协作者可以改普通字段、给报案人回消息,转交、关闭案件和高影响的决定仍归主负责人。
权限也逐项定了下来:
- 没接手的案件默认只读;
- 修改报案人已经确认过的事实,不能静默覆盖,要留下更正记录,重大更正要明确提示;
- 保障、责任、欺诈这类高影响的内容,不能用通用的字段编辑器改,要走专门的审核或决定动作;
- 工作流状态不提供任意的状态下拉框,只能由合法的业务动作产生状态迁移;
- 员工可以发起重复、撤回、无响应等受控的关闭动作,但不能直接删除案件;未完成案件的真正删除由保留策略和定时任务执行。
所有真实写入、对外消息、第三方调用、接手和解决、协作和转交、关闭和高影响的决定,都要由运行时重新校验身份、权限、当前版本号、必要的同意和动作前提,并记录结果。模型的建议、员工 Agent 的草稿、前端的一个按钮,本身都不构成授权。
多个案件标签
员工可以同时打开多个案件。同一个员工的客户端里,同一单只能有一个标签,再次打开就切到已有的那个,否则同一个浏览器里会出现两份草稿、两个版本号和互相冲突的待确认动作。不同员工可以同时打开同一单,但不是负责人的人默认只读,并且能看到当前负责人、最后活动时间,以及申请协作或转交的入口。
每个标签保存自己的位置、展开状态、消息草稿和待确认动作,浏览器刷新以后全部恢复。服务端版本号一变,用悬浮通知明确提示,不能静默覆盖员工的草稿。
标签开多了会占内存,但浏览器没有可靠的、跨浏览器的内存压力 API,按一个不稳定的指标去猜上限并不靠谱。我的做法是把"逻辑上打开"和"内存里完整加载"分开:所有打开的标签只把轻量状态写进本地存储,当前标签和少数相邻标签保留完整数据,较久没访问的标签释放大块的证据、消息和检索数据,切回来时按保存的案件 ID 和版本号重新加载。标签列表一直都在,员工不会因为内存管理丢掉工作上下文。
对话:一单只有一个对外会话
员工端要管理的会话分两类:绑定在案件上的、和报案人之间的业务对话;员工和工作台 Agent 之间的会话。两类都放在 Claim conversations 里管理,但要清楚标出类型。从列表打开一个业务对话,跳进对应案件标签的对话区域,员工处理案件时不必离开案件的主流程。
一单案件可能因为报案人暂停、恢复、新开对话而留下多段历史会话,但同一时刻只有一段对报案人的活跃会话。多个员工协作时,都进同一段会话,按发送者区分;员工之间需要讨论,用内部备注或员工动作记录,不另开一段容易和客户对话混淆的"第二会话"。
员工的 Agent:只出草稿,不执行
核实现状时,员工 Agent 其实还没有真正存在:现有的 Agent 接口是给报案端用的,带的是车险报案的提示词和受限的案件上下文,不接受多单上下文,模型的工具调用会被拒绝;持久化里只有绑定案件的会话记录,没有员工 Agent 自己的会话、轮次或建议记录。
定下来的方向是,员工 Agent 是工作台级别的全局能力,不跟着案件标签重置,不用在客户对话里打 @agent,员工直接用自然语言问。最关键的一条是上下文必须显式:Agent 不能把当前页面或当前打开的案件隐式当成上下文,员工每次都要选清楚范围,一共四种:
- 不附加案件;
- 附加一单;
- 附加多单;
- 查询所有有权限访问的数据。
数据模型分三张记录:
| 记录 | 内容 |
|---|---|
StaffAgentSession | 会话 ID、员工 ID、标题、状态、显式范围、创建和更新时间、最后活动、版本 |
StaffAgentTurn | 员工的问题、Agent 的回复、回答类型、用到的案件 ID 和版本号、来源、限制、模型和提示词的出处 |
StaffAgentSuggestion | 建议 ID、所属轮次、目标案件、建议种类、动作代码、草稿内容、基于的版本号、来源、需要的权限和确认、状态、有效期 |
每个回答都要标明它是信息解释、处理建议、客户回复草稿、待确认动作还是已执行动作。Agent 的接口只生成解释、建议和草稿,不提供一个能绕过正式业务接口的万能执行入口。草稿变成真正的动作,要走七步:
- 员工点
Use draft; - 草稿填进对应的客户消息、员工动作、第三方请求或决定组件;
- 界面显示目标案件、接收方、要共享的数据、依据的来源和预期的状态变化;
- 员工编辑并确认;
- 运行时重新校验权限、负责关系、版本号、同意和动作前提;
- 通过对应的正式接口执行;
- 这条建议标记为已用、已放弃、已过期或已失效,并关联实际的执行结果。
员工因此能用上 Agent 的全部能力,模型写的一段文字也永远不会被误当成一个已经执行的业务动作。
资料分三级展开
证据、检索结果、保单、历史、第三方数据,这些已经有一部分接口,但都不成熟。它们不能堆成一张巨大的表单,所以按三级展开:
- 第一级,队列判断:每一单只给打开决策需要的东西,编号、负责人和接手状态、优先级、事故摘要、最优先的缺失项、最重要的风险信号、未读消息、等待中的服务、最后活动时间。
- 第二级,案件工作区:默认显示案件头、当前摘要、
Needs attention和唯一的主动作。其他资料按业务语义分到 Fields、Conversation、Evidence、Reference checks、External services、Activity 几个区域。这些名字表示信息职责,不锁死最终的列布局。 - 第三级,单条完整记录:点开一条证据、信号、检索结果或外部请求,在详情面板、抽屉或独立子路由里看完整的来源、时间、可信度、限制、历史和相关动作。
每类内容的默认表达也定了:风险信号进 Needs attention,显示等级、图标、加重的字体和"需要复核"的语义;字段按分支和业务分组,默认突出缺失、冲突、待确认和最近的变化;保单、历史和检索结果可以归到 Reference checks,但每一条都要保留具体的来源类型,不能合成一团模糊的"AI 资料"。某个模块的依赖不可用,就在那个模块里说清楚什么不可用、为什么、员工现在能做什么,不能让一个局部故障拖垮整页。视觉上不做卡片套卡片,优先用分组、间距、小标题、列表和详情面板。
员工登录和路由
员工登录当时还主要靠开发和测试用的合成 token,没有和报案端对等的员工账号、会话、登录登出和身份生命周期。定下来的方向是:员工账号由组织或控制面预先创建,不开放自助注册;员工身份和报案人身份分开存储、分开会话、分开角色和撤销边界;浏览器端用安全的会话机制,以后可以通过身份适配器接入企业的 OIDC。
路由也正式化了:
| 路由 | 含义 |
|---|---|
/staff/login | 员工登录 |
/workbench | 默认的案件队列和工作区 |
/workbench/claims/:claimId/:section? | 指定案件,可以直达某个区域 |
/workbench/conversations | 全部可管理的会话 |
/workbench/conversations/:sessionId | 某段会话 |
/workbench/agent/sessions/:sessionId | 某段员工 Agent 会话 |
进入案件的路由要校验身份、访问权限、负责或协作关系和当前版本号。没有权限、案件不存在、案件已归档、会话失效,各用各的明确状态,不共用一个通用错误页。浏览器的前进后退只能恢复合法的查看上下文,不能恢复一个已经过期的写操作。控制面是独立的产品和权限入口,不嵌进工作台。
前端不能自己定后端字段
讨论到数据结构时,我给重构定了一条程序上的规矩:工作台和它的后端都可以改,重构可以根据员工的实际工作提出增删字段,没必要照搬现有契约;但后端字段不能由前端重构者在没讨论的情况下自己决定。所以文档里每个字段都区分三种状态:已经确认的、从代码里核实的现状、还待讨论的建议。
核实下来,现有的列表投影问题很多:优先级复用了人工协助的优先级 HandoffPriority;面向报案人的"下一步由谁负责"和员工内部的分配混在同一个字段里;完整的待补证据清单直接塞进列表;缺失信息、风险摘要、未读消息、接手状态和服务时限都没有稳定的类型化摘要。详情投影一次返回大量完整数组,其中几项还是弱类型的通用字典。
路由、迁移方式、标签、移动端、token、测试、三级展开、Agent 范围、员工账号、接手模型和实时同步这些产品方向,讨论到这里已经一致,不再逐项反复。剩下的字段契约做一次集中确认:重构者给出可读的候选 JSON 和字段说明,确认以后按契约直接改代码、文档和测试,再通知后端负责人。
集中确认的契约
契约先写下七条原则:
- 案件状态仍是当前案件事实的唯一权威来源;
- 生命周期、工作项、负责关系和跟进是正式的业务记录,不由前端计算;
- 队列、优先级、工作摘要和允许的动作,是后端根据权威记录生成的、按角色裁剪过的投影;
- 一单可以有多个工作项,但队列只选一个当前最优先的工作作为主入口;
- 风险信号始终只是待关注的信号,不代表欺诈、责任、保障或拒赔的结论;
- 列表只返回判断"要不要打开"的摘要,完整记录按需加载;
- 所有投影都带版本号,所有写操作都基于这个版本号重新校验。
然后是一组枚举:
| 枚举 | 取值 |
|---|---|
生命周期 lifecycle_state | draft_active、waiting_customer、waiting_external、staff_support、professional_review、ready_to_create、creating、created、withdrawn、expired、purged_or_anonymised |
负责关系 ownership.state | unassigned、reserved、assigned |
| 当前员工的访问关系 | read_only、primary、coworker、dispatcher(只是投影,不持久化) |
| 工作优先级 | routine、standard、high、urgent、immediate |
| 缺失信息的关注度 | required_now(阻塞当前主动作)、needed_next(影响紧接着的动作)、follow_up(以后需要) |
| 风险信号的关注等级 | notice、review_required、urgent_review、immediate_action |
| 风险信号的状态 | open、under_review、dismissed、resolved |
| 动作可用性 | available、confirmation_required、blocked |
| 确认等级 | none、explicit、high_impact |
几处取舍值得说一下。新的 lifecycle_state 和原来的 workflow_state 是两个维度,迁移时不能拿一个覆盖另一个。routine 用来表示仍然有效、只是当前服务顺序靠后的工作,比如还没到跟进时间的未完成案件,它不表示这单不重要。风险信号用"关注等级",没有用"严重程度",因为它表达的是员工需要多快去看,和这个信号为真的概率无关;即使一个信号进了 under_review,也不能显示成已确认的欺诈。
队列里一条记录的结构从扁平字段改成了分组:
{
"claim_id": "clm_01J4Y7Q2AW",
"display_reference": "NW-1042",
"revision": 42,
"claimant": { "customer_id": "cus_1042", "display_name": "…" },
"incident": {
"family": "motor",
"summary": "Rear-end collision on Queen Street; vehicle remains drivable."
},
"lifecycle_state": "staff_support",
"workflow_state": "professional_review",
"ownership": {
"state": "unassigned",
"current_staff_access": "read_only",
"pending_cowork_requests": 0,
"pending_transfer_requests": 0
},
"priority_projection": {
"level": "high",
"rank": 120,
"reasons": [
{
"code": "CLAIMANT_WAITING_FOR_STAFF",
"summary": "The claimant has requested staff assistance.",
"source_refs": ["hnd_01J4Y7XG2C"]
}
],
"due_at": "2026-09-03T03:30:00Z",
"is_overdue": false
},
"work_summary": {
"queue_key": "claimant_support",
"current_work_item": { "type": "human_support", "status": "open", "owner_role": "claims_professional" },
"primary_action_code": "human.accept_handoff",
"missing_information": [
{
"kind": "field",
"code": "other_party.registration",
"attention": "needed_next",
"blocked_action": "external.request_assessment",
"responsible_party": "claimant"
}
],
"risk_signals": [
{
"code": "HISTORY_INCONSISTENCY_REVIEW",
"attention_level": "review_required",
"status": "open",
"summary": "A prior record may refer to a different insured item."
}
],
"unread_claimant_messages": 1
}
}
display_reference 是员工能扫读的编号,不拿数据库主键冒充。报案人信息在队列里只留识别所需的最少字段。事故摘要由后端生成并附来源,前端不自己拼字段。reasons 让员工知道这单为什么排在这里,但不暴露模型的推理过程。缺失信息可以是字段、证据、一次确认或一个外部结果,不局限于表单字段。
详情里最重要的是 allowed_actions。模型拿不到按钮的配置权,前端也不能按字符串随便调接口。这份数据是运行时在当前的员工身份、案件版本、负责关系、生命周期、工作项、同意状态和注册表版本下算出来的一份动作投影:
{
"action_code": "conversation.send_claimant_message",
"target_ref": "ses_01J4Y7RPN8",
"label": "Reply to claimant",
"purpose": "Continue the accepted claimant support conversation.",
"availability": "available",
"confirmation": { "level": "explicit", "message": "This message will be visible to the claimant." },
"expected_effects": ["message.append", "handoff.mark_in_progress"],
"based_on_revision": 42
}
action_code 必须来自动作注册表;expected_effects 只用于给员工预览和给测试断言,不能代替真实的执行结果;based_on_revision 让过期的动作在执行前就被拒绝。blocked 的动作只在解释当前阻塞确实有价值时才返回,不把所有不可用的按钮都堆到页面上。
详情默认只返回各区域的摘要(字段多少、几项需要关注、对话几段、未读几条),完整记录通过分页的子资源按需加载:
| 资源 | 详情里默认给什么 | 展开时的接口 |
|---|---|---|
| 案件核心、负责关系、优先级、工作摘要 | 完整 | GET /api/v1/workbench/claims/{claim_id} |
| 动态字段 | 数量和需关注数 | …/fields |
| 会话 / 消息 | 数量和未读摘要 | …/sessions、…/sessions/{session_id}/messages |
| 证据 | 摘要 | …/evidence |
| 保单、历史、检索结果 | 参考摘要 | …/retrievals |
| 风险和复核信号 | 最高关注度的摘要 | …/signals |
| 工作项 | 当前工作项 | …/work-items |
| 外部服务 | 等待摘要 | …/external-requests |
| 审计和活动 | 最近活动时间 | …/events |
每个子资源都分页,并带可用性和限制的元数据。一个子资源不可用,只影响对应的区域,整个详情不会因此失败。
最后是"谁负责"。原来的 ResponsibleParty 里有一个 northwind,可 Northwind 是买这个产品、拥有业务流程的组织,算不上一个具体的工作责任方。新的取值是 claimant、claims_professional、external_party、system。Agent 不能成为责任方,模型不能拥有业务责任;具体某位员工的负责关系只放在 ownership 和工作项里;system 只表示已经授权、正在由运行时或已配置的系统执行的工作。
历史数据里的 northwind 不能全局替换字符串,要按当时的语义迁移:人工协助、专业审核、员工更新改成 claims_professional;已授权、由运行时执行的立案或内部系统步骤改成 system;在等第三方回复的改成 external_party;在等报案人提供或确认的改成 claimant。从来源记录里判断不出语义的,阻止自动迁移,转人工检查,不去猜。
渐进地替换旧工作台
迁移是渐进的:先拆功能边界和 token,再逐个模块打磨;迁移期间旧的 employee/index.html 弃用但暂时不删;全部真实功能迁完、验收通过以后,再删掉旧的正式实现,旧页面只留在仓库外的归档里;依赖旧静态 HTML 的测试可以重写或删除,但重写必须验证新的、用户能观察到的行为,不能为了通过测试去复制旧实现。最终不允许有两套并行的正式工作台。
目录按职责拆开:
employee/src/
app/router/ app/workbench-state/
components/
GlobalNavigation/ Queue/ ClaimTabs/ ClaimSummary/
ClaimActions/ ClaimReference/ ClaimConversation/
StaffAgent/ Status/
api/ styles/ main.jsx
视觉上,工作台继续使用和报案端共享的 token 文件,颜色、字号角色、字重、圆角、阴影和状态语义大部分共用。员工端可以更密集,最低字号先按 14px 验证,但关键状态、主动作和风险内容不能为了密度缩到难以阅读。密度本身也要做成 token:导航宽度、队列行高、单元格内边距、区块间距、紧凑控件高度、标签页高度,不能在组件里逐个像素地调。
当天的执行结果:工作台的 React 和 Vite 应用、独立的员工登录边界、正式路由、案件队列、分页、筛选、案件标签、详情、渐进展开的资源区、Claim conversations 和员工 Agent 都完成了;员工 Agent 支持显式选择零到多单的上下文,会话持久化,显示来源和限制,输出草稿。旧的 index.html 不再承载正式工作台,访问时会指向新的组件化工作台。前端和后端的测试、构建、lint、格式检查和类型检查都通过了,其中后端全量测试 1,297 项。
还剩下的一块是跨端的实时同步:报案人那边的事件接口已经有了,但工作台这一轮只做到了版本号的轮询提示,浏览器级的实时验收要等事件链路稳定以后再做。
两端的同与不同
工作台和报案端共享产品性格、token、按钮层级、状态语义、错误模型、无障碍原则,也共享"Agent 只建议、运行时执行"的边界和"可预测导航、明确返回"的状态保护。但它不复制报案端的布局。报案端以 Agent 对话和低认知压力为主,输入框是主入口;工作台以任务排序、案件判断、可追溯的操作和多案件并行为主,案件队列和当前主动作是主入口。
前两天讨论时说的"共享性格,不共享密度",到这里才算有了具体的样子。