ysoseri.us

实习日志 / 中文 / 界面重构 / 员工工作台:从一面数据库镜像,到一个只突出一个动作的工作区

员工工作台:从一面数据库镜像,到一个只突出一个动作的工作区

中文 | English

报案端理顺以后,9 月 3 日轮到员工工作台。这一天的讨论最后落成一份很长的规格:前半是对现状的审查和目标结构,后半是和后端一起敲定的数据契约。下面按这个顺序讲。

起点:所有东西同时出现

当时的员工端由一个 employee/index.html 撑着。后来虽然把脚本和样式拆了出去,index.html 还有约 20KB,app.js 约 1,942 行,大量 DOM 查询、字符串模板、全局状态变量和 API 调用混在一起,styles.css 里叠着早期视觉方案、响应式补丁和后来的 token 覆盖。

结构上的后果很具体:新增一个案件状态,要同时改队列渲染、详情渲染、标签页和按钮逻辑;队列、详情、动作、对话、助手没法各自写组件测试;旧 CSS 还在参与级联,经常"视觉改了,旧样式又盖回来";URL、当前案件、当前标签和服务端版本号也没法保证一致。

使用上的后果更直接。打开工作台,员工同时看到左侧导航、队列筛选、统计卡、案件列表、悬停预览、详情标签、证据、历史、对话、人工审核表单、客户回复、Agent 模板和浮动助手。每一样都有用,可它们同时抢第一视觉层级,员工回答不了这几个问题:

  1. 现在需要处理什么?
  2. 应该先打开哪一单?
  3. 打开后最重要的阻塞是什么?
  4. 当前唯一的主动作是什么?
  5. 做完这个动作,会改变什么状态?

所以这次重构的目标我写成了一句话:工作台要从"把所有可用信息和操作同时展示的管理页面",变成"帮员工决定先处理哪一单、理解当前工作、完成一个明确动作、并留下可追溯记录的工作区"。换颜色、加卡片都解决不了这个问题。

信息按工作顺序分三层

整理的原则是不删信息,按员工的工作顺序分层:

  1. 工作摘要:案件身份、生命周期、当前责任方、已确认事实、缺失信息、风险信号、当前阻塞、主下一步。
  2. 可操作内容:当前状态允许的员工动作,每个动作写清目的、需要什么输入、会改变什么、会不会对外发送、要不要确认、失败或超时怎么继续。
  3. 参考资料:证据、完整字段、案件上下文、历史、完整对话、检索来源、第三方记录和审计轨迹。

同一时刻只突出一个主动作,其余动作可以并列,但不能和它用同样的视觉权重竞争。旧版本把证据审查、信号判断、结果记录和客户更新排成一个"三步引导流程",演示时好讲,正式使用时却让员工按一个固定流程走。新版改成由后端投影决定当前该做什么,前端只负责呈现清楚。

队列的问题在于几个不同层次的概念被摆在了一起。旧筛选项里有 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,也没有轮询。员工接手或发了消息,停在旧页面上的报案人什么都看不到,要刷新或重新进入才会更新。这个缺口不能被一张设计得很好的状态卡掩盖,所以我把它定成工作台重构的必要依赖:员工接手、发消息、解决都要产生可以补齐的事件,报案人那边按事件游标和版本号接收更新,断线以后能补上漏掉的事件。

谁能接手,谁能改什么

接手采用一个混合模型。完全由员工自己领取,会出现没人接的单;完全由系统自动指派,又可能把单子塞给当下没空处理的人。所以:

  1. 后端按工作类型、优先级、时限和员工的处理范围形成队列和推荐顺序;
  2. 员工明确点击 Accept claim 或"处理下一单";
  3. 用版本号比较和短时预留,防止两个人同时接手;
  4. 接手的人成为主负责人;
  5. 长时间没有有效活动,先提醒,再通过一个明确的事件退回队列;
  6. 有未提交的草稿、正在执行的外部动作或活跃的协作时,不允许静默重新分配;
  7. 转交和协作都记下请求、接受、拒绝、权限变化和时间;
  8. 正常的转交由当前主负责人接受,只有具备调度权限的员工,才能在主负责人无响应或时限要求下强制转交,并且要写明原因;
  9. 获批的协作者可以改普通字段、给报案人回消息,转交、关闭案件和高影响的决定仍归主负责人。

权限也逐项定了下来:

  • 没接手的案件默认只读;
  • 修改报案人已经确认过的事实,不能静默覆盖,要留下更正记录,重大更正要明确提示;
  • 保障、责任、欺诈这类高影响的内容,不能用通用的字段编辑器改,要走专门的审核或决定动作;
  • 工作流状态不提供任意的状态下拉框,只能由合法的业务动作产生状态迁移;
  • 员工可以发起重复、撤回、无响应等受控的关闭动作,但不能直接删除案件;未完成案件的真正删除由保留策略和定时任务执行。

所有真实写入、对外消息、第三方调用、接手和解决、协作和转交、关闭和高影响的决定,都要由运行时重新校验身份、权限、当前版本号、必要的同意和动作前提,并记录结果。模型的建议、员工 Agent 的草稿、前端的一个按钮,本身都不构成授权。

多个案件标签

员工可以同时打开多个案件。同一个员工的客户端里,同一单只能有一个标签,再次打开就切到已有的那个,否则同一个浏览器里会出现两份草稿、两个版本号和互相冲突的待确认动作。不同员工可以同时打开同一单,但不是负责人的人默认只读,并且能看到当前负责人、最后活动时间,以及申请协作或转交的入口。

每个标签保存自己的位置、展开状态、消息草稿和待确认动作,浏览器刷新以后全部恢复。服务端版本号一变,用悬浮通知明确提示,不能静默覆盖员工的草稿。

标签开多了会占内存,但浏览器没有可靠的、跨浏览器的内存压力 API,按一个不稳定的指标去猜上限并不靠谱。我的做法是把"逻辑上打开"和"内存里完整加载"分开:所有打开的标签只把轻量状态写进本地存储,当前标签和少数相邻标签保留完整数据,较久没访问的标签释放大块的证据、消息和检索数据,切回来时按保存的案件 ID 和版本号重新加载。标签列表一直都在,员工不会因为内存管理丢掉工作上下文。

对话:一单只有一个对外会话

员工端要管理的会话分两类:绑定在案件上的、和报案人之间的业务对话;员工和工作台 Agent 之间的会话。两类都放在 Claim conversations 里管理,但要清楚标出类型。从列表打开一个业务对话,跳进对应案件标签的对话区域,员工处理案件时不必离开案件的主流程。

一单案件可能因为报案人暂停、恢复、新开对话而留下多段历史会话,但同一时刻只有一段对报案人的活跃会话。多个员工协作时,都进同一段会话,按发送者区分;员工之间需要讨论,用内部备注或员工动作记录,不另开一段容易和客户对话混淆的"第二会话"。

员工的 Agent:只出草稿,不执行

核实现状时,员工 Agent 其实还没有真正存在:现有的 Agent 接口是给报案端用的,带的是车险报案的提示词和受限的案件上下文,不接受多单上下文,模型的工具调用会被拒绝;持久化里只有绑定案件的会话记录,没有员工 Agent 自己的会话、轮次或建议记录。

定下来的方向是,员工 Agent 是工作台级别的全局能力,不跟着案件标签重置,不用在客户对话里打 @agent,员工直接用自然语言问。最关键的一条是上下文必须显式:Agent 不能把当前页面或当前打开的案件隐式当成上下文,员工每次都要选清楚范围,一共四种:

  • 不附加案件;
  • 附加一单;
  • 附加多单;
  • 查询所有有权限访问的数据。

数据模型分三张记录:

记录内容
StaffAgentSession会话 ID、员工 ID、标题、状态、显式范围、创建和更新时间、最后活动、版本
StaffAgentTurn员工的问题、Agent 的回复、回答类型、用到的案件 ID 和版本号、来源、限制、模型和提示词的出处
StaffAgentSuggestion建议 ID、所属轮次、目标案件、建议种类、动作代码、草稿内容、基于的版本号、来源、需要的权限和确认、状态、有效期

每个回答都要标明它是信息解释、处理建议、客户回复草稿、待确认动作还是已执行动作。Agent 的接口只生成解释、建议和草稿,不提供一个能绕过正式业务接口的万能执行入口。草稿变成真正的动作,要走七步:

  1. 员工点 Use draft;
  2. 草稿填进对应的客户消息、员工动作、第三方请求或决定组件;
  3. 界面显示目标案件、接收方、要共享的数据、依据的来源和预期的状态变化;
  4. 员工编辑并确认;
  5. 运行时重新校验权限、负责关系、版本号、同意和动作前提;
  6. 通过对应的正式接口执行;
  7. 这条建议标记为已用、已放弃、已过期或已失效,并关联实际的执行结果。

员工因此能用上 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 和字段说明,确认以后按契约直接改代码、文档和测试,再通知后端负责人。

集中确认的契约

契约先写下七条原则:

  1. 案件状态仍是当前案件事实的唯一权威来源;
  2. 生命周期、工作项、负责关系和跟进是正式的业务记录,不由前端计算;
  3. 队列、优先级、工作摘要和允许的动作,是后端根据权威记录生成的、按角色裁剪过的投影;
  4. 一单可以有多个工作项,但队列只选一个当前最优先的工作作为主入口;
  5. 风险信号始终只是待关注的信号,不代表欺诈、责任、保障或拒赔的结论;
  6. 列表只返回判断"要不要打开"的摘要,完整记录按需加载;
  7. 所有投影都带版本号,所有写操作都基于这个版本号重新校验。

然后是一组枚举:

枚举取值
生命周期 lifecycle_statedraft_active、waiting_customer、waiting_external、staff_support、professional_review、ready_to_create、creating、created、withdrawn、expired、purged_or_anonymised
负责关系 ownership.stateunassigned、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 对话和低认知压力为主,输入框是主入口;工作台以任务排序、案件判断、可追溯的操作和多案件并行为主,案件队列和当前主动作是主入口。

前两天讨论时说的"共享性格,不共享密度",到这里才算有了具体的样子。