Quellcode durchsuchen

完善前端服务评价、登录恢复与 Agent 运行配置

增加客户服务评价及员工查看入口,展示工单回访记录。三个前端入口分别保存会话凭据,刷新后重新验证身份和权限。

每次创建 Agent 运行时读取店铺当前发布配置,已有会话保留聊天上下文,运行中的配置快照保持稳定。同步发布页面、接口说明和集成检查。

补充前端交互与设计依据,完善 Windows 渠道工作进程的信号处理及生命周期检查。
leon vor 2 Wochen
Ursprung
Commit
8d4df71ae1
41 geänderte Dateien mit 2273 neuen und 77 gelöschten Zeilen
  1. 8 1
      README.md
  2. 292 0
      docs/design/iteration-01-engineering.md
  3. 32 0
      docs/design/wireframes-verification.md
  4. 114 0
      docs/design/wireframes/README.md
  5. 16 0
      docs/design/wireframes/admin-login.js
  6. 320 0
      docs/design/wireframes/app.js
  7. 44 0
      docs/design/wireframes/conversation-list-presentation.md
  8. 41 0
      docs/design/wireframes/customer.js
  9. 14 0
      docs/design/wireframes/h5-login-presentation.md
  10. 99 0
      docs/design/wireframes/implementation-baseline.md
  11. 42 0
      docs/design/wireframes/index.html
  12. 23 0
      docs/design/wireframes/management.js
  13. 30 0
      docs/design/wireframes/notifications.js
  14. 42 0
      docs/design/wireframes/product-layout.js
  15. 61 0
      docs/design/wireframes/shared.js
  16. 40 0
      docs/design/wireframes/style-preview/README.md
  17. 51 0
      docs/design/wireframes/style-preview/index.html
  18. 60 0
      docs/design/wireframes/style-preview/style.css
  19. 123 0
      docs/design/wireframes/wireframes.css
  20. 13 0
      docs/design/wireframes/workspace.js
  21. 3 1
      frontend/README.md
  22. 59 2
      frontend/data-sources.md
  23. 1 0
      frontend/package.json
  24. 18 4
      frontend/src/App.js
  25. 2 2
      frontend/src/api.js
  26. 148 0
      frontend/src/components/TicketFeedback.js
  27. 91 30
      frontend/src/main.js
  28. 3 1
      frontend/src/pages/customer.js
  29. 4 2
      frontend/src/pages/merchant.js
  30. 2 0
      frontend/src/services/customer.js
  31. 1 0
      frontend/src/services/merchant.js
  32. 32 0
      frontend/src/session.js
  33. 283 0
      frontend/tests/session-refresh.mjs
  34. 68 0
      scripts/verify_host_channel_worker.py
  35. 5 6
      src/zhifu/modules/agent_runtime/service.py
  36. 1 3
      src/zhifu/modules/channels/service.py
  37. 4 5
      src/zhifu/modules/conversations/api.py
  38. 1 0
      src/zhifu/modules/conversations/models.py
  39. 2 1
      src/zhifu/modules/merchant/api.py
  40. 39 7
      src/zhifu/operations/worker.py
  41. 41 12
      tests/test_merchant.py

+ 8 - 1
README.md

@@ -526,7 +526,7 @@ uv run python -m zhifu.modules.knowledge.maintenance
 uv run python -m zhifu.modules.platform.delivery
 ```
 
-Windows 开发使用上述模块入口;容器使用 `zhifu.operations.worker` 统一处理停止信号。前端 `API_TARGET` 设置为 `http://127.0.0.1:18000`。同一数据库由选定的宿主机或容器工作进程负责处理,调试时确认实际运行的进程数量。
+Windows 开发使用上述模块入口;渠道投递的持续运行入口与容器工作进程共用 `zhifu.operations.worker`,维护运行心跳并处理停止信号。这个统一入口在 Windows 支持通过 Ctrl+C 或 Ctrl+Break 正常停止,在 Linux 容器支持 SIGTERM 和 SIGINT;停止时等待业务任务清理连接并删除本进程心跳。前端 `API_TARGET` 设置为 `http://127.0.0.1:18000`。同一数据库由选定的宿主机或容器工作进程负责处理,调试时确认实际运行的进程数量。
 
 宿主机完整发布检查可以指定实际地址:
 
@@ -536,6 +536,13 @@ uv run python -m zhifu.operations.release_check --api-url http://127.0.0.1:18000
 
 ## 检查与测试
 
+渠道投递进程的生命周期验收要求当前没有启用渠道和待投递记录,使用真实 MySQL 与 Redis 检查启动、停止和心跳清理。宿主机检查在 Windows 终端中发送 Ctrl+Break,容器检查发送 SIGTERM;两项检查均设置 25 秒启动期限和 10 秒停止期限:
+
+```powershell
+uv run python scripts/verify_host_channel_worker.py
+uv run python scripts/verify_channel_worker.py
+```
+
 代码、依赖和接口定义检查:
 
 ```powershell

+ 292 - 0
docs/design/iteration-01-engineering.md

@@ -0,0 +1,292 @@
+# 首个迭代工程详细设计
+
+状态:设计完成,待应用实现与集成验证。依据:[需求规格](../requirements/iteration-01-requirements.md)、[验收标准](../requirements/iteration-01-acceptance.md)。
+
+## 1. 模块与运行进程
+
+采用模块化应用,API、Agent worker、后台 worker 分别部署与扩容。模块通过应用服务调用,跨进程使用持久化任务及领域事件。数据库事务属于应用服务,HTTP handler 与 Agent 工具不直接修改业务表。
+
+| 模块 | 所有权 | 对外服务 |
+|---|---|---|
+| identity | 租户、店铺、应用、员工授权、客户身份关联 | authenticate、authorize、resolve_subject |
+| channels | 渠道绑定、Inbox、消息归一化、回调投递 | accept_message、deliver_event |
+| conversations | 会话、消息、控制权、输入事件 | append_message、take_over、release_control |
+| after_sales | 工单、方案、确认、审批、数量占用 | create_proposal、confirm、decide、prepare_operation |
+| integrations | OMS 连接器、外部操作、结果查询 | submit_exchange、reconcile_operation |
+| knowledge_memory | 文档发布、检索、客户记忆与删除状态 | retrieve、read_memory、update_memory、delete_memory |
+| agent_runtime | run、等待、执行租约、图版本 | enqueue、resume、cancel、execute |
+| model_governance | 路由、调用预算、实际用量 | reserve_budget、invoke、settle_usage |
+| platform_governance | 发布清单、Outbox、审计、运行配置 | publish、audit、dispatch |
+
+API 进程处理认证、参数验证、短事务及 SSE。模型和外部网络请求均设置截止时间;数据库事务内不等待这些请求。Agent worker 处理推理和图推进,后台 worker 处理文档导入、记忆提取、Outbox、回调、外部结果查询。长任务与交互任务使用独立并发额度。
+
+## 2. 数据库设计约定
+
+MySQL 是业务状态、授权、执行权与操作记录的事实来源。Redis 分别提供缓存、持久检查点及记忆索引;Milvus 保存可重新生成的知识向量。外部订单投影只是查询加速数据。
+
+主键使用应用生成的 UUID,MySQL 保存为 BINARY(16),API 输出标准 UUID 字符串。时间使用 UTC DATETIME(6),业务版本使用 BIGINT UNSIGNED,数量使用正整数;金额如需扩展使用最小货币单位整数并带 currency。业务状态使用 VARCHAR(32),在服务和数据库 CHECK 中限制合法值。
+
+租户数据必含 tenant_id。关联对象使用 UNIQUE(tenant_id,id) 与复合外键 (tenant_id,parent_id),阻止跨租户关联。查询统一接收服务器建立的 AuthContext,禁止仅凭 id 查询租户对象。删除使用显式生命周期操作;审批和审计记录不覆盖历史。
+
+通用可变实体包含 created_at、updated_at、version;业务更新带 expected_version 并验证受影响行数。列表采用 (created_at,id) 游标分页,默认 20、上限 100。JSON 用于不可变方案内容、引用和事件数据;授权字段、状态、版本及调度条件使用独立列。
+
+### 2.1 身份、会话与规则表
+
+| 表 | 关键字段 | 唯一约束与索引 |
+|---|---|---|
+| tenants | id、status、quota_policy_id | 主键 id |
+| stores | tenant_id、id、name、status | tenant_id,id |
+| client_apps | tenant_id、id、credential_ref、status | tenant_id,id;凭据引用保存在密钥系统 |
+| app_store_grants | tenant_id、app_id、store_id、scope | tenant_id,app_id,store_id,scope |
+| staff_grants | tenant_id、staff_id、store_id、permission、status | tenant_id,staff_id,store_id,permission |
+| customer_subjects | tenant_id、id、status、memory_epoch、memory_revision、consent_version | tenant_id,id |
+| channel_bindings | tenant_id、id、store_id、app_id、key_id、secret_ref、status | tenant_id,id |
+| customer_identities | tenant_id、binding_id、external_user_id、subject_id、verification_id | tenant_id,binding_id,external_user_id |
+| identity_verifications | tenant_id、id、subject_id、method、verified_at、expires_at、evidence_ref | tenant_id,id;证据脱敏与限制访问 |
+| conversations | tenant_id、id、store_id、subject_id、binding_id、control_owner、control_version、active_run_id | tenant_id,subject_id,created_at,id |
+| messages | tenant_id、id、conversation_id、sequence、role、content_ref、control_version | tenant_id,conversation_id,sequence |
+| input_events | tenant_id、id、conversation_id、actor_id、event_type、payload、payload_hash、consumed_by | tenant_id,id |
+| rule_versions | tenant_id、rule_id、version、status、effective_at、definition、document_version_id | tenant_id,rule_id,version |
+| knowledge_versions | tenant_id、id、document_id、version、status、object_ref、content_hash | tenant_id,document_id,version |
+| releases | tenant_id、id、graph_version、state_schema_version、prompt_version、tool_version、knowledge_manifest、rule_manifest、model_route_version | tenant_id,id |
+
+渠道客户标识使用大小写敏感比较。身份关联只接受两端验证通过的证明,并在同一事务更新映射和审计。撤销身份关联立即影响新请求,已有业务记录保留原主体及授权审计。
+
+### 2.2 售后与外部操作表
+
+| 表 | 关键字段 | 唯一约束与索引 |
+|---|---|---|
+| order_projections | tenant_id、connector_id、external_order_id、subject_id、source_version、observed_at、payload | tenant_id,connector_id,external_order_id |
+| tickets | tenant_id、id、store_id、subject_id、conversation_id、status、current_proposal_id、version | tenant_id,subject_id,created_at,id;tenant_id,store_id,status,created_at,id |
+| proposals | tenant_id、id、ticket_id、current_version、created_by | tenant_id,id |
+| proposal_versions | tenant_id、proposal_id、version、order_id、item_id、quantity、reason、rule_manifest、order_version、content_hash | tenant_id,proposal_id,version |
+| confirmations | tenant_id、proposal_id、proposal_version、subject_id、input_event_id、confirmed_at | tenant_id,proposal_id,proposal_version |
+| approvals | tenant_id、id、proposal_id、proposal_version、submitted_by、status、decided_by、decision_reason、version | tenant_id,proposal_id,proposal_version;tenant_id,status,created_at,id |
+| item_guards | tenant_id、connector_id、order_id、item_id、version | tenant_id,connector_id,order_id,item_id |
+| reservations | tenant_id、id、operation_id、connector_id、order_id、item_id、quantity、status | tenant_id,operation_id;tenant_id,connector_id,order_id,item_id,status |
+| external_operations | tenant_id、id、proposal_id、proposal_version、action_type、request_hash、status、external_id、lease_token、lease_until、next_check_at、last_error | tenant_id,proposal_id,proposal_version,action_type;status,next_check_at,id |
+
+proposal_versions 的业务内容不可修改。只有 DRAFT 或 NEEDS_REVISION 允许创建修订;已确认且等待审批的方案需要显式撤回到修订流程,作废旧审批并清除其执行资格,然后生成新版本。已有外部操作时禁止修订。客户确认、主管审批、准备操作都检查 tickets.current_proposal_id 与 proposals.current_version。
+
+item_guards 是每个订单明细的稳定加锁行,首次插入处理唯一键竞争后重新读取并加锁。reservations 状态为 HELD、CONSUMED、RELEASED:未知结果保持 HELD;外部受理后转 CONSUMED;外部确定失败且不会迟到执行时转 RELEASED。已受理数量由 OMS 当前可申请数量反映,计算本地可提交数量时只减尚未被 OMS 确认受理的 HELD,禁止重复扣减。
+
+### 2.3 运行与可靠投递表
+
+| 表 | 关键字段 | 唯一约束与索引 |
+|---|---|---|
+| agent_runs | tenant_id、id、conversation_id、status、release_id、version、lease_token、lease_until、checkpoint_ref、next_attempt_at、deadline_at | status,next_attempt_at,id;tenant_id,conversation_id,created_at,id |
+| run_waits | tenant_id、run_id、interrupt_id、expected_type、proposal_id、proposal_version、run_version、status、input_event_id | tenant_id,run_id,interrupt_id |
+| run_events | tenant_id、run_id、sequence、type、payload、created_at | tenant_id,run_id,sequence |
+| api_idempotency | tenant_id、app_id、actor_id、route、key、request_hash、status、resource_id、response、expires_at | tenant_id,app_id,actor_id,route,key |
+| channel_inbox | tenant_id、binding_id、external_message_id、payload_hash、payload、status、next_attempt_at | tenant_id,binding_id,external_message_id |
+| outbox_events | event_id、tenant_id、aggregate_id、aggregate_version、event_type、schema_version、payload、status、next_attempt_at | event_id;status,next_attempt_at,event_id |
+| consumer_receipts | consumer_name、event_id、processed_at | consumer_name,event_id |
+| webhook_deliveries | tenant_id、id、subscription_id、event_id、status、attempt_count、next_attempt_at | tenant_id,subscription_id,event_id;status,next_attempt_at,id |
+| memory_jobs | tenant_id、id、subject_id、epoch、revision、source_event_id、status | tenant_id,subject_id,epoch,source_event_id |
+| model_attempts | tenant_id、id、run_id、provider_request_id、reserved_tokens、actual_tokens、status、pricing_version | tenant_id,id;provider_request_id 按供应商范围去重 |
+| audit_records | tenant_id、id、actor_id、action、resource_type、resource_id、request_id、before_version、after_version、created_at | tenant_id,resource_id,created_at,id |
+
+api_idempotency 增加 actor_id,防止同一 H5 应用中的不同客户复用 key 读取他人响应;这进一步限定需求中的应用级范围。过期 API 记录清理不影响外部操作的永久业务去重。输入正文包含敏感信息时加密存储并按照数据保留策略清理。
+
+```mermaid
+erDiagram
+    tenants ||--o{ stores : owns
+    stores ||--o{ channel_bindings : exposes
+    customer_subjects ||--o{ customer_identities : verifies
+    customer_subjects ||--o{ conversations : uses
+    conversations ||--o{ agent_runs : executes
+    conversations ||--o{ tickets : creates
+    tickets ||--o{ proposals : contains
+    proposals ||--|{ proposal_versions : versions
+    proposal_versions ||--o| confirmations : confirmed
+    proposal_versions ||--o| approvals : reviewed
+    proposal_versions ||--o{ external_operations : performs
+    external_operations ||--o| reservations : reserves
+    agent_runs ||--o{ run_waits : waits
+    agent_runs ||--o{ run_events : streams
+```
+
+## 3. 事务与并发控制
+
+统一加锁顺序:当前授权控制行 → conversation → run → ticket → proposal → approval → item_guard → operation。按需取用,多个同类资源按主键排序。短事务发生并发冲突时,由调用方使用同一幂等键进行有界重试;参数、权限、规则错误直接返回。
+
+### T1 输入受理与执行创建
+
+验签或验证登录身份后,事务插入 Inbox / 幂等记录,锁定 conversation,保存 input_event。无 active_run 时创建 QUEUED run 和 Outbox;存在等待 run 时只接受该等待类型的输入。接管会话只保存供人工处理的输入。提交事务后返回受理结果。RabbitMQ 只是唤醒信号,定时扫描 MySQL 的到期任务可补充遗漏的唤醒。
+
+### T2 确认与审批
+
+确认事务校验客户、当前方案版本、当前规则有效性,写 confirmation、审批记录、工单 WAITING_REVIEW、审计及 Outbox。审批事务核验主管权限、提交人分离、方案版本与客户确认,写不可重复决定、工单状态及 Outbox。事件驱动等待 run 恢复,调用端无需伪造恢复参数。
+
+### T3 准备外部操作
+
+先在事务外读取 OMS 最新版本。事务内锁定上述相关记录,校验权限、会话控制权、审批、规则撤销状态及订单明细占用,建立 HELD reservation、PREPARED operation、PROCESSING 工单和 Outbox。提交后连接器携带 stable operation_id 与 expected_order_version 调用 OMS。外部条件更新处理读取和写入之间的订单变化。
+
+权限撤销、规则撤销与操作准备使用相同的对应授权或规则行锁,确定提交顺序。准备操作事务是平台的业务授权提交点;提交后的操作作为已授权业务继续跟踪,接管与取消不得将其解释为尚未开始。对已提交操作提出终止要求需要 OMS 提供独立撤销流程。
+
+### T4 外部结果登记
+
+OMS 返回明确受理后,锁定 operation 和关联 ticket,以当前版本更新外部标识、CONSUMED reservation、审计和 Outbox。响应超时转 UNKNOWN,工单 RESULT_UNKNOWN,保留占用并调度查询。收到履约完成回调可直接完成工单,即使此前处于 RESULT_UNKNOWN;检查外部版本,晚到受理响应不能覆盖已完成结果。
+
+查询 NOT_FOUND 不足以证明原提交不会迟到。只有 OMS 对该 operation_id 记录了不可再执行的终止结果,才释放占用。重发始终使用同一 operation_id 和 request_hash;缺少持久幂等、条件更新或结果查询能力的连接器禁止自动执行写操作,转人工处理。
+
+### T5 接管、取消与消息发布
+
+接管事务锁 conversation,增加 control_version、更新 owner,取消未完成 run 并记录事件。操作准备和 Agent 对客消息入库必须在同一 conversation 锁下核验版本。发布通道按会话串行处理控制事件和消息,对旧版本尚未发送的内容进行过滤。已交给网络的字节可能在接管后到达,H5 依据 control_version 丢弃旧版本内容;外部渠道只承诺接管后不再受理新的 Agent 发送动作。
+
+取消工单与准备操作争用同一 ticket 锁;已有 operation 时返回 OPERATION_IN_PROGRESS。取消 run 只终止后续图执行,不撤销外部业务。人工释放控制权后,新客户输入创建新 run。
+
+## 4. 对外 API
+
+基础路径 /openapi/v1,员工配置与平台治理使用 /admin/v1。H5 使用安全登录会话并执行 CSRF 校验;服务调用使用限权访问令牌;外部回调使用签名。AuthContext 包含 tenant_id、app_id、actor_type、actor_id、subject_id、store_ids、scopes、request_id,由认证层创建,禁止模型或请求正文提供。
+
+所有写接口要求 Idempotency-Key;变更已有对象同时要求 expected_version。JSON 禁止未知业务字段,数量为正整数。错误响应固定为 {code,message,request_id,details,retryable},details 不包含其他租户对象信息。查询越权对象与不存在对象统一返回 404。
+
+| 方法与路径 | 主要输入 | 权限与响应 |
+|---|---|---|
+| POST /conversations | channel_binding_id | conversations:write;201 conversation_id、version |
+| POST /conversations/{id}/runs | message、client_message_id | 客户本人;202 run_id、status、events_url |
+| GET /orders | cursor、limit | 当前客户订单;员工需 orders:read 与店铺范围 |
+| GET /orders/{id} | 无 | 返回当前订单、source_version、observed_at |
+| POST /after-sales/proposals | conversation_id、order_id、item_id、quantity、reason | 售后创建权限;201 proposal_id、proposal_version、ticket_id、引用 |
+| POST /after-sales/proposals/{id}/revisions | expected_version、业务字段 | 当前主体或授权客服;201 新版本,作废旧确认与审批资格 |
+| POST /after-sales/proposals/{id}/confirm | proposal_version、expected_ticket_version | 当前客户确认;200 confirmation_id、approval_id、ticket_status |
+| GET /approvals | status、cursor、limit | 审批权限与店铺过滤 |
+| POST /approvals/{id}/decisions | expected_version、proposal_version、decision、reason | 当前主管且非提交人;200 决定结果 |
+| GET /tickets/{id} | 无 | 本人或授权员工;当前状态、版本与可执行动作 |
+| POST /tickets/{id}/cancel | expected_version、reason | 当前客户或授权客服;200 当前状态 |
+| POST /conversations/{id}/takeover | expected_control_version | 授权客服;200 owner、control_version |
+| POST /conversations/{id}/release | expected_control_version | 当前接管客服或主管;200 control_version |
+| POST /conversations/{id}/messages | text、expected_control_version | 接管客服;201 message_id |
+| GET /runs/{id} | 无 | 当前主体或授权员工;状态、等待类型与版本 |
+| GET /runs/{id}/events | Last-Event-ID | SSE,按 run 内 sequence 续传 |
+| POST /runs/{id}/inputs | interrupt_id、expected_run_version、answer | 当前客户;201 input_event_id,后台自动请求恢复 |
+| POST /runs/{id}/resume | input_event_id、interrupt_id、expected_run_version | 内部恢复权限;202,同一事件幂等 |
+| POST /runs/{id}/cancel | expected_version、reason | 当前客户或授权员工;200 状态 |
+| GET /me/memories | cursor、limit | 本人;当前 epoch 的可读记忆 |
+| PUT /me/memories/{id} | expected_revision、content | 本人;200 新 revision |
+| DELETE /me/memories | expected_epoch | 本人;200 新 epoch,后台清理旧内容 |
+| POST /webhooks/v1/channels/{binding_id}/messages | 签名消息 envelope | 202 receipt_id;路径独立于基础路径 |
+
+规则发布使用 POST /admin/v1/rules/{id}/versions,激活使用 POST /admin/v1/releases,撤销使用 POST /admin/v1/rules/{id}/versions/{version}/revoke;均需商家配置权限、expected_version 与审计。知识导入通过受控对象上传后 POST /admin/v1/knowledge/imports 创建异步任务。配置发布必须引用已完成索引的知识版本。
+
+Webhook 订阅通过 /admin/v1/webhook-subscriptions 管理,重投使用 POST /admin/v1/webhook-deliveries/{id}/retry。租户、应用、店铺与角色管理遵守 identity 的同一授权规则;平台配置权限不自动授予客户数据读取权限。
+
+| HTTP | code | 调用方行为 |
+|---|---|---|
+| 401 / 403 | UNAUTHENTICATED / FORBIDDEN | 完成认证或申请相应授权 |
+| 404 | RESOURCE_NOT_FOUND | 不重试相同查询 |
+| 409 | IDEMPOTENCY_CONFLICT | 检查请求键和参数 |
+| 409 | PROPOSAL_VERSION_CONFLICT / VERSION_CONFLICT | 重新读取资源再执行用户动作 |
+| 409 | CONVERSATION_WAITING / HUMAN_CONTROLLED | 展示等待动作或人工状态 |
+| 409 | OPERATION_IN_PROGRESS / QUANTITY_CONFLICT | 查询工单或调整申请 |
+| 422 | RULE_NOT_SATISFIED / INVALID_INPUT_EVENT | 展示业务原因与修正字段 |
+| 429 | TENANT_QUOTA_EXCEEDED | 遵守 Retry-After |
+| 503 | DEPENDENCY_UNAVAILABLE | 保留幂等键,有界重试 |
+
+EXTERNAL_RESULT_UNKNOWN 是已受理工单的业务状态提示,查询接口返回 200 并提供 operation_id 和下一次检查时间,客户端不得因此创建新申请。SSE 返回 event_id、event_type、sequence、control_version;过期游标在建立流之前返回 410 EVENT_CURSOR_EXPIRED,客户端查询当前快照。
+
+## 5. Webhook 与事件协议
+
+渠道签名字符串为 timestamp + 换行 + nonce + 换行 + HTTP 方法 + 换行 + 原始路径 + 换行 + SHA256(raw_body)。使用 HMAC-SHA256,头部提供 X-Key-Id、X-Timestamp、X-Nonce、X-Signature。签名常量时间比较,时间允许窗口为 300 秒,nonce 在窗口内去重;绑定决定租户及应用。业务重投使用新 nonce 和同一 external_message_id。密钥轮换保留明确的双密钥有效期。
+
+入站 envelope 包含 external_message_id、external_user_id、occurred_at、type、payload;type 为 message、proposal_confirmation 或 clarification。客户动作只在渠道确实提供已认证用户身份且本地映射有效时受理,普通聊天文本不转换为确认记录。
+
+领域事件示例:
+
+```json
+{
+  "event_id": "01951000-0000-7000-8000-000000000001",
+  "event_type": "after_sale.approved",
+  "schema_version": 1,
+  "tenant_id": "01951000-0000-7000-8000-000000000002",
+  "aggregate_type": "ticket",
+  "aggregate_id": "01951000-0000-7000-8000-000000000003",
+  "aggregate_version": 4,
+  "occurred_at": "2026-09-19T12:00:00Z",
+  "trace_id": "8c37a78437a84603a629b45ea6257f97",
+  "payload": {"proposal_id": "01951000-0000-7000-8000-000000000004", "proposal_version": 1}
+}
+```
+
+| 事件 | 生产者 | 消费行为 |
+|---|---|---|
+| conversation.input_received | channels / conversations | 唤醒对应 run |
+| after_sale.proposal_confirmed | after_sales | 恢复等待确认的 run,通知审批 |
+| after_sale.approved / rejected | after_sales | 恢复等待审批的 run,或调度人工流程的执行 |
+| after_sale.operation_prepared | after_sales | 提交指定 operation |
+| after_sale.result_changed | integrations | 更新通知与工单查询投影 |
+| conversation.taken_over | conversations | 终止后续 Agent 动作与旧版本输出 |
+| memory.deleted | knowledge_memory | 清理旧 epoch 索引和内容 |
+| knowledge.published | knowledge_memory | 更新可用发布版本 |
+
+Outbox 发布采用至少一次投递,只有 broker 确认且路由有效才标记已发送。消费者在业务事务中插入 consumer_receipts 并更新业务,提交后 ACK。外部 I/O 使用独立 operation 或 delivery 状态机;收据不能在外部提交前使业务失去恢复入口。
+
+回调复用相同签名规范,额外提供 delivery_id。自动重试采用带随机扰动的指数退避,最多 12 次且最长 24 小时,超过限制进入人工处理队列。接收方按 event_id 去重,按 aggregate_version 处理乱序。订阅地址限制 HTTPS,解析与连接时均阻止内网、环回及链路本地地址,禁止自动跟随重定向,凭据保存在密钥系统。
+
+## 6. LangGraph 状态与执行流程
+
+图状态字段:run_id、conversation_id、release_id、state_schema_version、intent、candidate_order_ids、selected_order_id、missing_fields、rule_refs、proposal_id、proposal_version、interrupt_id、input_event_id、operation_id、control_version、step_count、token_budget_remaining、last_error_code。身份从运行时重新建立的 AuthContext 注入;checkpoint 不作为权限证据。
+
+| 节点 | 输入与产物 | 转移条件 |
+|---|---|---|
+| load_context | 读取当前身份、控制权、发布清单与授权记忆 | 无权限结束;人工控制时取消 |
+| classify_intent | 结构化意图与必需字段 | 问答进入检索;换货进入查单;人工请求触发接管请求 |
+| resolve_order | 权限内订单候选 | 不唯一或缺信息进入 await_input |
+| await_input | 保存等待类型、interrupt_id 与 run_version | 有效输入事件恢复到 resolve_order |
+| retrieve_policy | 当前商家、发布版本的文档引用 | 无依据时请求人工;有依据进入 evaluate_policy |
+| evaluate_policy | 确定性业务校验结果 | 拒绝则答复;符合则 create_proposal |
+| create_proposal | 调用共享服务,保存方案引用 | 进入 await_confirmation |
+| await_confirmation | 客户指定版本确认事件 | 确认有效进入 await_approval |
+| await_approval | 指定版本审批事件 | 拒绝答复;批准进入 prepare_operation |
+| prepare_operation | 调用共享应用服务复核并创建唯一操作 | 变化请求修订;成功交由后台执行 |
+| respond | 从业务事实生成答复并附引用 | 保存带控制版本的消息,完成 run |
+
+interrupt 前保存 run_waits 与持久检查点。若 MySQL 等待记录已保存但检查点保存失败,run 标记恢复异常并暂停,恢复协调器检查两侧版本后继续;不能向客户端宣称可以恢复一个不存在的检查点。输入可能先于图进入等待到达,因此恢复协调器扫描未消费的有效事件,不依赖一次性通知。
+
+恢复使用原 thread_id(服务器按 tenant_id/run_id 构造)和原 release_id。可能重复执行的节点只能执行查询或调用幂等应用服务。恢复前核验事件所属租户、主体、等待类型、方案版本、消费状态和当前 run_version;同一事件绑定一个等待记录并以 CAS 推进状态。运行取消与恢复争用同一 run 行锁,已取消任务拒绝恢复。
+
+租约由 MySQL 发放,每次重新领取增加 lease_token。检查点写入使用每次执行独立 checkpoint namespace;失去租约的 worker 不能更新 MySQL 中已接受的 checkpoint_ref。接管、取消、业务写入和最终答复均校验 lease_token 与 control_version,过期 worker 产生的检查点不被恢复入口采用。
+
+图默认最多 20 个节点步骤,单次活跃执行截止时间 120 秒,单次模型调用截止时间 30 秒;等待用户与审批的时间不计入活跃执行时长。超限保存 FAILED 与错误原因,工单保持实际业务状态。等待业务失效或超期通过业务服务关闭,不能凭模型决定释放未知外部操作。
+
+## 7. Agent 工具边界
+
+| 工具 | 模型可提供的参数 | 服务端强制行为 |
+|---|---|---|
+| search_orders | 查询条件、分页游标 | 注入主体与店铺范围,限制返回条数 |
+| get_order | order_id | 校验归属,返回来源版本 |
+| retrieve_policy | question、product_type | 注入 tenant 与知识版本过滤,返回引用 |
+| evaluate_exchange | order_id、item_id、quantity、reason | 确定性规则计算,返回拒绝码或依据 |
+| create_exchange_proposal | 当前订单明细、数量、理由 | 应用服务校验,节点重放复用持久化命令标识 |
+| get_ticket | ticket_id | 校验主体,返回实际进度 |
+| request_human | reason | 登记接管请求,等待客服取得控制权 |
+
+确认、审批、执行授权、身份关联和记忆删除不开放为模型自行决定的工具。prepare_operation 节点由图控制调用应用服务,并验证已保存的业务证据。工具返回不包含其他客户字段、密钥或可执行指令。工具参数 JSON schema 在工程中与 API 请求模型复用,约束由服务执行。
+
+## 8. 记忆、检索与模型资源
+
+记忆写入按 customer_subject 串行处理,写前与提交后核验 epoch、revision、consent_version。更正或删除增加版本,使迟到提取任务失效。读取同时检查 MySQL 当前 epoch,持久 Redis 故障时不使用不确定版本的个性化内容。每条记忆保存来源、更新时间及授权依据。
+
+知识导入采用对象存储原文 → 解析 → 分段 → Embedding → Milvus → 检索验证 → 发布的任务链,任务以 document_version_id 和阶段去重。检索过滤 tenant_id、store 范围、发布版本及文档权限;缓存键包含这些维度。删除或撤销立即改变可读状态,后台异步清理向量。
+
+模型入口按供应商全局额度、租户额度、应用额度、客户额度执行准入;使用估算输入加最大输出预留 token,实际完成后按 model_attempt_id 结算。超时且用量未知的尝试保留待核对状态。每次真实重试创建独立 attempt,不能重复结算同一 attempt。
+
+交互队列按租户轮转并限制各租户运行数量,全局供应商信号量控制外呼,检索与文档任务使用独立并发池。API 连接池总量以所有副本进程之和核算,保留 MySQL 运维连接。SSE 使用有界发送缓冲,慢客户端断开后通过事件游标恢复,禁止无限累计内存。
+
+## 9. 发布与验证
+
+发布清单锁定图、状态 schema、Prompt、工具、规则、知识、模型路由及依赖版本。新 run 使用当前发布,等待 run 使用原发布;安全撤销与权限变化始终依据当前状态。数据库迁移采用兼容新增、数据迁移、切换读取、最后清理的顺序,运行中的旧版本退出前保留所需字段及执行制品。
+
+运行环境分别管理密钥、数据库和 OMS 地址;审计访问需要单独权限。生产发布需要完成备份恢复演练、告警通知、密钥轮换、数据保留清理以及已确定 RTO / RPO 的验证。业务日志使用 trace 关联,不记录令牌、完整敏感正文和密钥。
+
+| 设计领域 | 验收依据 | 必需证据 |
+|---|---|---|
+| 身份与共享服务 | AC-01~AC-08、AC-19、AC-32 | 授权结果、规则引用及业务记录 |
+| 确认与审批 | AC-09~AC-13、AC-23~AC-24 | 不可变版本、确认与审批审计 |
+| 事务与外部恢复 | AC-14~AC-17、AC-21~AC-22 | 并发事务结果、OMS 操作唯一性 |
+| 接管与流式输出 | AC-18、AC-20 | 控制提交时间、消息版本与事件续传 |
+| 记忆与消息可靠性 | AC-25~AC-28 | epoch、收据、投递及查询记录 |
+| 资源与质量 | AC-29~AC-35 | 按租户指标、真实用量与分类评测 |
+
+后续工程实现按身份与数据库 → 售后服务与 OMS → 可靠投递与人工 API → Agent 与知识记忆 → 全链路验收推进。数据库迁移、OpenAPI 文件与依赖锁定随应用实现生成并验证;本文不表示已经运行这些组件。

+ 32 - 0
docs/design/wireframes-verification.md

@@ -0,0 +1,32 @@
+# 产品线稿验证记录
+
+日期:2026-09-20。
+
+范围:`docs/design/wireframes` 内的产品线稿。使用本机 HTTP 静态服务、Playwright 和现有 Chromium 1228,通过真实浏览器操作检查页面。未调用业务服务或生成模型,未读取业务数据库,未使用截图。
+
+## 已通过的检查
+
+- 63 个页面入口可访问,按钮动作及页面跳转目标均有定义,包括登录页、通知页、学习业务页与系统结构说明页。
+- 商城首页四个底部导航、商品详情携带商品上下文进入 AI 客服。
+- 商家后台和平台后台的独立系统侧栏、经营首页及平台概览。
+- 评审目录默认隐藏,异常情景通过评审工具展开。
+- 产品入口为购物商城、商家后台、平台后台。商家后台的管理员、客服、主管共用系统框架,菜单按员工权限显示。
+- 客服直接访问审批页面时保留当前员工身份并返回授权页面;主管与管理员菜单按各自权限展示。
+- 1440px 桌面宽度和 390px 手机宽度下,63 个页面均无整体横向溢出。
+- 管理员与客服通知分别展示、查看详情减少未读、业务入口跳转、全部已读及未读列表空状态。
+- 商家及平台独立登录、未登录页面拦截、过期重新登录返回原页并保留草稿、退出登录状态隔离及登录失败反馈。
+- 消费者登录、创建方案、本人确认、主管审批及跨入口进度显示。
+- 结果未知时禁止取消与再次提交,申请人无法审批自己提交的申请。
+- 版本变化后重新检查方案并要求客户确认。
+- 客户取消、客服接管、人工回复、结束接管及断线保留输入。
+- 沟通习惯授权、保存、清空与撤销授权。
+- 知识与规则发布、原方案规则版本保留、Agent 发布清单确认。
+- 预算申请保留原预算,另一位费用员工批准后更新。
+- 额度总量检查、投递重试进入等待状态、监控不展示客户消息正文。
+- JavaScript 语法检查通过,浏览器运行错误为 0。
+
+## 验证边界
+
+这些检查只证明交互线稿的本地行为。正式登录、跨租户授权、业务事务、模型质量、真实 Webhook、文件解析和生产容量需要在对应开发阶段单独验证。
+
+源脚本:[check_wireframes.cjs](../../scripts/check_wireframes.cjs)。

+ 114 - 0
docs/design/wireframes/README.md

@@ -0,0 +1,114 @@
+# 智服中台产品线稿
+
+本目录用于确认购物商城 H5、商家后台和平台后台的需求、权限与页面交互。商家后台按当前员工的角色与授权展示管理功能,客服接待和主管审批属于商家后台内的业务模块。使用正常购物应用及中台系统的信息结构和布局,保持灰度低保真表现。
+
+打开 [index.html](index.html) 即可使用。所有脚本和样式均保存在当前目录,无外部资源依赖。也可以在项目根目录运行:
+
+```powershell
+.venv/Scripts/python.exe -m http.server 18761 --bind 127.0.0.1 --directory docs/design/wireframes
+```
+
+浏览器访问 `http://127.0.0.1:18761`。该服务只提供线稿目录。
+
+## 使用方法
+
+后台登录、权限与会话行为的后续开发依据见 [实现依据](implementation-baseline.md)。
+
+三个端的会话、通知及管理列表展示遵循 [会话与列表展示规范](conversation-list-presentation.md)。
+
+消费者 H02 登录页面的信息、输入和反馈遵循 [H5 登录页展示规范](h5-login-presentation.md)。
+
+- 顶部提供购物商城、商家后台、平台后台三个入口。
+- 商城使用底部导航及页面内入口;后台使用系统侧栏、顶部账户区、面包屑与内容页签。
+- 点击顶部“评审工具”展开页面目录、异常情景及重置按钮。在商家后台可以演示管理员、客服、主管三种员工角色,业务状态保持不变。正式产品根据登录员工的授权显示菜单和操作。
+- 页面下方说明当前操作规则。需要再次确认的操作使用确认窗口。
+- 刷新页面清空演示状态;页面地址保留当前位置。也可以点击“重置全部演示”。
+- 消费者及两个后台的登录页只用于演示,填写任意非空内容即可,请勿输入真实密码。商家、平台登录状态独立,刷新后清空。
+
+所有操作只修改当前浏览器内存。这里展示包括尚未开发能力在内的目标产品行为,不代表业务服务已经实现,也不代表接口、权限或模型已经验收。线稿不请求后端,不发送消息,不读取业务数据库。
+
+## 建议检查的流程
+
+### 客户申请、主管审批、客户查看结果
+
+1. 从商城首页、商品页或个人中心进入 AI 客服;查询本人业务时登录。
+2. 打开订单,查看申请条件和规则引用。
+3. 填写换货数量与故障描述,检查方案版本,再由客户确认。
+4. 进入商家后台,通过评审工具切换主管角色,打开待审批资料,检查客户确认记录并批准或拒绝。
+5. 切回消费者查看当前工单。通过主管情景选择“审批通过后的进度”或“履约已完成”,检查两种结果的区别。
+
+### 人工协助
+
+1. 消费者申请人工协助。
+2. 商家后台切换客服角色,从会话队列进入会话并接管。
+3. 客服回复、整理或修订方案;消费者查看相同消息和方案。
+4. 消费者本人确认,主管审批。
+5. 客服结束接管,下一条消息恢复助手服务。
+
+### 内容与费用管理
+
+1. 商家保存规则草稿,检查影响范围并发布;已经确认的方案保留原规则版本。
+2. 保存知识草稿,预览内容后发布,检查撤回操作。
+3. 检查 Agent 发布清单,确认规则与知识有效后演示发布。
+4. 申请调整每日预算。左侧切换另一位费用员工,批准或拒绝,检查预算生效时刻。
+5. 平台查看失败投递并重试,确认状态为等待投递;只有接收端确认情景才显示完成。
+
+## 页面目录
+
+| 入口 | 页面 | 需要确认的内容 |
+| --- | --- | --- |
+| 商家后台 | M00 员工登录 | 商家标识、员工账户、密码、权限加载与返回原页 |
+| 平台后台 | P00 员工登录 | 平台身份、密码、独立登录状态 |
+| 购物商城 H5 | H11 首页、H12 分类、H13 商品详情、H14 购物车、H15 个人中心 | 搜索、分类、商品与购买区域、底部导航及 AI 入口 |
+| 购物商城 H5 | H16 消息、H17 地址、H18 收藏、H19 设置 | 正常商城栏目结构,地址等页面只说明功能范围 |
+| 消费者 H5 | H01 咨询、H02 登录、H03 订单列表、H04 订单详情、H05 规则依据 | 身份、订单选择、依据及人工协助 |
+| 消费者 H5 | H06 申请编辑、H07 方案确认、H08 售后进度 | 版本、本人确认、取消、结果未知与修订 |
+| 消费者 H5 | H09 沟通习惯、H10 身份关联 | 授权、更正、清空、撤销与双身份验证 |
+| 商家后台 · 客服权限 | S01 客服接待、S02 会话处理、S03 方案编辑、S04 工单详情 | 接管、回复、业务上下文、客户确认和控制权释放 |
+| 商家后台 · 主管权限 | S05 待审批、S06 审批资料、S07 审批后进度 | 证据完整、自己审批限制、版本检查及决定结果 |
+| 商家后台 | M01 待办、M02 店铺员工、M03 渠道配置 | 本商家范围、授权、停用与渠道接入检查 |
+| 商家后台 | M04 规则、M05 知识、M06 发布清单、M07 费用 | 草稿与发布、撤回影响、版本和双人审批 |
+| 平台后台 | P01 商家、P02 额度、P03 投递、P04 监控、P05 审计 | 接入、资源分配、通知重试、运行元数据和操作记录 |
+| 平台后台 | P06 平台概览 | 商家接入、任务、告警、运行趋势和待办 |
+| 各管理系统 | 商品、交易订单、客户、营销、团队、统计、应用及系统设置等栏目 | 完整系统导航;学习范围之外只提供栏目结构说明 |
+
+共 63 个可访问页面,包含登录页、通知列表与详情、学习业务交互页和系统结构说明页。客服接待采用左侧会话列表、中间聊天处理、右侧客户订单与工单信息的三栏工作区;商家首页提供经营指标、趋势、待办和常用管理;平台首页提供接入与运行管理。
+
+商家管理员、客服和主管可从顶部消息入口或侧栏通知中心查看各自通知,筛选未读、查看详情并进入授权业务页面。评审工具切换员工后可以检查通知内容及未读状态的独立性。
+
+## 交互规则
+
+- 客服整理方案后交客户检查,客户确认后生成审批任务。
+- 方案业务字段变更产生新版本,原确认和审批失效。
+- 主管只处理客户已确认的当前版本,申请人不能审批自己提交的申请。
+- 外部受理与最终履约完成使用不同状态。受理结果未知时继续核实原申请。
+- 取消仅在草稿、等待审批、批准但尚未登记外部操作时开放。
+- 接管改变会话控制权;已提交业务持续跟踪,助手后续输出受限制。
+- 新规则服务新申请,已经确认的有效方案使用原规则。撤销使相关方案需要修订。
+- 金额预算、价格和费用核对需要另一位具有费用权限的员工审批。
+- 平台管理展示运行信息,客户正文和业务资料需要独立授权。
+
+## 评审边界
+
+本轮关注页面结构、关键操作和业务反馈。列表使用代表记录,未展开批量操作、复杂筛选及所有字段。文件导入、价格申请和账单核对展示流程与字段说明;真实解析、身份验证、网络异常恢复和支付供应商调用均不在本线稿执行。
+
+需要产品确认的设计选择:
+
+1. 消费者使用“首页 / 分类 / 购物车 / 我的”四个底部入口。AI 在首页、悬浮入口、商品详情、订单和个人中心可进入。
+2. 客服采用会话队列、消息区、订单及工单上下文的工作区。
+3. 商家后台统一承载经营、客服和审批;当前员工授权决定可见菜单及可执行操作。平台后台围绕商家、应用、资源与系统管理。
+4. 客户确认后自动创建审批任务;客服不再重复提交相同审批。
+5. 跨渠道关联明确展示两个已验证身份,具体渠道认证页面由正式接入能力确定。
+
+## 浏览器检查
+
+验证脚本为 [check_wireframes.cjs](../../../scripts/check_wireframes.cjs)。执行时需要 Node.js、Playwright 及 Chromium,依赖安装在已忽略的 `.work` 目录:
+
+```powershell
+npm install --prefix .work/wireframe-tools --no-audit --no-fund playwright
+node scripts/check_wireframes.cjs
+```
+
+默认访问 `http://127.0.0.1:18761`,可通过 `WIREFRAME_URL` 指定其他地址。已有 Chromium 可以通过 `PLAYWRIGHT_CHROMIUM_EXECUTABLE` 指定完整路径;未提供时使用 Playwright 管理的浏览器。
+
+检查使用实际浏览器 DOM 与点击、填写、确认操作,不使用截图或图像定位。结果见 [线稿验证记录](../wireframes-verification.md)。

+ 16 - 0
docs/design/wireframes/admin-login.js

@@ -0,0 +1,16 @@
+// 两个后台分别保存演示登录状态;页面地址和账户输入不扩大员工权限。
+function adminProduct(role) { return ['merchant', 'staff', 'supervisor'].includes(role) ? 'merchant' : role === 'platform' ? 'platform' : null; }
+function completeAdminLogin(product, data) {
+  if (!data.get('account').trim() || !data.get('password').trim() || product === 'merchant' && !data.get('tenant').trim()) { notify('请填写完整的登录信息。'); return; }
+  adminAuth[product] = true;
+  const role = product === 'merchant' ? merchantRole : 'platform';
+  const target = adminReturn[product]; adminReturn[product] = null;
+  notice = '已进入后台演示身份,功能按当前账户权限展示。';
+  navigate(role, target?.role === role && target.page !== 'login' ? target.page : roleHomes[role]);
+}
+function loginScreen(product) {
+  const merchant = product === 'merchant';
+  return `<div class="login-layout"><section class="login-intro"><h1>智服中台</h1><h2>${merchant ? '商家后台' : '平台后台'}</h2><p>${merchant ? '店铺经营 · 客户服务 · 售后管理' : '商家接入 · 资源管理 · 运行监控'}</p></section><section class="surface login-panel"><h2>${merchant ? '商家员工登录' : '平台员工登录'}</h2><p>${merchant ? '使用商家分配的员工账户登录。' : '仅限已授权的平台运营账户。'}</p>${form(`${product}-login`, `${merchant ? field('商家标识', 'tenant', 'merchant-a', 'text', 'required autocomplete="organization"') : ''}${field('员工账户', 'account', '', 'text', 'required autocomplete="username" placeholder="请输入员工账户"')}${field('密码', 'password', '', 'password', 'required autocomplete="current-password" placeholder="线稿演示,请勿输入真实密码"')}`, '登录')}<p class="muted">${merchant ? '账户与权限由商家管理员分配。' : '账户与权限由平台管理员分配。'}</p>${button('忘记密码 / 无法登录', 'admin-login-help')}<div class="hint">线稿使用任意非空账户与密码演示登录。${merchant ? '当前角色由评审工具中的员工角色决定。' : '商家员工账户不获得平台权限。'}</div></section></div>`;
+}
+register('merchant', 'login', 'M00', '商家后台登录', () => loginScreen('merchant'), ['商家标识、员工账户和密码必填;由服务端校验身份、账户状态和所属商家。', '登录成功后加载当前员工授权。无权访问的返回地址进入授权首页。', '登录失败保持页面并显示统一错误提示,密码清空;线稿情景用于确认反馈。']);
+register('platform', 'login', 'P00', '平台后台登录', () => loginScreen('platform'), ['平台账号通过独立的平台权限检查;商家登录状态不能用于平台后台。', '成功登录后进入授权的平台功能;失败不展示受保护内容。', '密码恢复由平台管理员处理,当前不设计自助注册。']);

+ 320 - 0
docs/design/wireframes/app.js

@@ -0,0 +1,320 @@
+const drafts = new Map();
+const scenarios = {
+  customer: [['start', '重新体验换货流程'], ['waiting', '等待审批'], ['unknown', '外部受理结果未知'], ['revision', '方案需要修订'], ['generating', '助手正在回复'], ['unavailable', '助手暂时不可用'], ['disconnect', '连接中断 / 恢复'], ['empty', '无订单 / 无工单']],
+  staff: [['human', '客户请求人工协助'], ['unknown', '已提交外部申请'], ['revision', '方案需要修订'], ['empty', '队列为空'], ['expired', '店铺权限已撤销']],
+  supervisor: [['waiting', '客户已确认,等待审批'], ['self', '申请人为当前主管'], ['stale', '方案版本发生变化'], ['processed', '审批通过后的进度'], ['completed', '履约已完成']],
+  merchant: [['merchant', '商家正常配置'], ['billing-reviewer', '切换费用申请 / 审批员工'], ['stale', '配置版本冲突'], ['expired', '当前权限已撤销']],
+  platform: [['platform', '平台正常管理'], ['delivery-done', '投递已确认'], ['stale', '额度版本冲突'], ['expired', '当前权限已撤销']],
+};
+function navigate(role, page) {
+  const target = screens[`${role}/${page}`];
+  if (!target) throw new Error(`Unknown wireframe page: ${role}/${page}`);
+  if (page !== 'login' && ['merchant', 'staff', 'supervisor'].includes(role) && role !== merchantRole) { notify('当前员工没有该功能权限。'); return; }
+  currentRole = role; currentPage = page;
+  const hash = routeUrl(role, page);
+  if (location.hash === hash) render(); else location.hash = hash;
+}
+function saveDrafts() {
+  const forms = document.querySelectorAll('#product form');
+  forms.forEach(element => {
+    const values = {};
+    for (const input of element.elements) {
+      if (!input.name || input.type === 'password') continue;
+      values[input.name] = input.type === 'checkbox' ? input.checked : input.value;
+    }
+    drafts.set(`${currentRole}/${currentPage}/${element.dataset.submit}`, values);
+  });
+}
+function restoreDrafts() {
+  document.querySelectorAll('#product form').forEach(element => {
+    const values = drafts.get(`${currentRole}/${currentPage}/${element.dataset.submit}`);
+    if (!values) return;
+    for (const input of element.elements) {
+      if (!(input.name in values)) continue;
+      if (input.type === 'checkbox') input.checked = values[input.name];
+      else input.value = values[input.name];
+    }
+  });
+}
+function render() {
+  const product = adminProduct(currentRole);
+  if (product && currentPage !== 'login' && !adminAuth[product]) {
+    adminReturn[product] = { role: currentRole, page: currentPage };
+    currentRole = product; currentPage = 'login';
+    history.replaceState(null, '', routeUrl(product, 'login'));
+  }
+  const screen = screens[`${currentRole}/${currentPage}`];
+  document.title = `${screen.code} ${screen.title} · 智服产品线稿`;
+  const productRole = ['staff', 'supervisor'].includes(currentRole) ? 'merchant' : currentRole;
+  document.getElementById('role-nav').innerHTML = ['customer', 'merchant', 'platform'].map(role => `<button type="button" data-role-home="${role}" ${role === productRole ? 'aria-current="page"' : ''}>${roleLabels[role]}</button>`).join('');
+  document.getElementById('merchant-role-controls').hidden = productRole !== 'merchant';
+  document.getElementById('merchant-role-nav').innerHTML = [['merchant', '商家管理员'], ['staff', '客服'], ['supervisor', '主管']].map(([role, label]) => `<button type="button" data-employee-role="${role}" ${merchantRole === role ? 'aria-current="page"' : ''}>${label}</button>`).join('');
+  document.getElementById('page-nav').innerHTML = Object.values(screens).filter(item => item.role === currentRole).map(item => `<button type="button" data-page="${item.page}" data-role="${item.role}" ${item.page === currentPage ? 'aria-current="page"' : ''}>${item.code} ${item.title}</button>`).join('');
+  const availableScenarios = product ? [...scenarios[currentRole], ['login-error', '登录失败'], ['session-expired', '登录会话过期']] : scenarios[currentRole];
+  document.getElementById('scenario-nav').innerHTML = availableScenarios.map(([id, label]) => `<button type="button" data-scenario="${id}">${label}</button>`).join('');
+  document.getElementById('page-code').textContent = `${screen.code} / ${roleLabels[currentRole]}`;
+  document.getElementById('page-title').textContent = screen.title;
+  document.getElementById('screen-scope').textContent = currentRole === 'customer' ? '手机页面 · 可点击线稿' : '桌面页面 · 可点击线稿';
+  document.getElementById('notice').textContent = notice;
+  document.getElementById('product').innerHTML = `${product && currentPage !== 'login' ? `<div class="admin-session"><span>已登录 · ${roleLabels[currentRole]}</span>${product === 'merchant' && !state.expired ? link(`消息通知(${unreadCount()})`, 'notifications') : ''}${button('退出登录', 'admin-logout')}</div>` : ''}${state.expired && currentPage !== 'login' ? desktop(`${hint('当前店铺权限已失效,内容已停止显示。未提交文字保留。')}${button('演示重新取得有效授权', 'restore-access')}`) : screen.render()}`;
+  document.getElementById('notes').innerHTML = `<ul>${screen.notes.map(item => `<li>${item}</li>`).join('')}</ul>`;
+  restoreDrafts();
+}
+function notify(message) { notice = message; render(); }
+function checkAccess() {
+  if (state.expired) { notify('当前权限已失效,请重新取得授权。'); return false; }
+  if (state.stale) { notify('内容已被其他操作更新,请重新读取后检查并提交。'); return false; }
+  return true;
+}
+function loginRequired() {
+  if (currentRole === 'customer' && !state.loggedIn) {
+    state.returnPage = currentPage; notice = '请登录当前商家账户后继续。'; navigate('customer', 'login'); return true;
+  }
+  return false;
+}
+function createProposal(data) {
+  if (loginRequired() || !checkAccess()) return;
+  if (currentRole === 'staff' && state.owner !== 'HUMAN') { notify('请先接管会话。'); return; }
+  if (!canCreate() && !canRevise()) { notify('当前状态无法修改方案,请查看已有工单。'); return; }
+  if (state.blocked || !state.ruleActive) { notify('适用条件已经变化,无法生成方案。请查看规则或申请人工协助。'); return; }
+  const reason = data.get('reason').trim();
+  if (!reason || Number(data.get('quantity')) !== 1) { notify('请填写故障描述,申请数量必须为 1。'); return; }
+  if (state.ticket !== 'NONE') state.version += 1;
+  state.reason = reason; state.quantity = 1; state.ticket = 'DRAFT'; state.customerConfirmed = false;
+  state.proposalRuleVersion = state.ruleVersion; state.proposalRuleDays = state.ruleDays;
+  drafts.delete('customer/proposal/confirm-proposal');
+  record(`方案 V${state.version} 已保存,等待客户本人确认。`);
+    notice = currentRole === 'staff' ? '已保存待确认方案。通过购物商城入口检查客户确认页面。' : '请检查方案,再确认提交。';
+  navigate(currentRole, currentRole === 'staff' ? 'ticket' : 'proposal');
+}
+
+// 每个表单都在本地演示相应结果;不发送网络请求,不产生业务侧写入。
+const submissions = {
+  'merchant-login'(data) { completeAdminLogin('merchant', data); },
+  'platform-login'(data) { completeAdminLogin('platform', data); },
+  login(data) { state.loggedIn = true; notice = '已进入客户 A01 的线稿身份。'; navigate('customer', state.returnPage); },
+  'customer-message'(data) {
+    if (loginRequired() || !checkAccess()) return;
+    if (!state.connected) { notify('连接中断,消息保留在输入框。'); return; }
+    const text = data.get('message').trim();
+    if (!text) { notify('请填写消息内容。'); return; }
+    state.messages.push({ sender: '客户', text });
+    if (state.owner === 'HUMAN') markNotificationNew('staff-message');
+    if (state.owner === 'AGENT' && !state.blocked) state.messages.push({ sender: '智能助手 · 设计示例', text: '请通过“选择订单”确认需要处理的商品,并填写故障描述。' });
+    drafts.delete('customer/chat/customer-message');
+    notify(state.owner === 'HUMAN' ? '消息已显示在客服会话中。' : '消息已加入本次咨询线稿。');
+  },
+  'staff-message'(data) {
+    if (!checkAccess() || state.owner !== 'HUMAN') { notify('只有当前接管客服可以回复。'); return; }
+    const text = data.get('message').trim();
+    if (!text) { notify('请填写回复内容。'); return; }
+    state.messages.push({ sender: '客服王芳', text }); drafts.delete('staff/conversation/staff-message'); notify('回复已同步到消费者咨询页面。');
+  },
+  proposal: createProposal,
+  'confirm-proposal'() {
+    if (loginRequired() || !checkAccess()) return;
+    if (state.ticket !== 'DRAFT') { notify('当前方案已处理,请查看最新进度。'); return; }
+    state.customerConfirmed = true; state.ticket = 'WAITING_REVIEW'; record(`客户 A01 确认方案 V${state.version},创建主管审批任务。`);
+    markNotificationNew('supervisor-review');
+    notice = '方案已提交审批。商家后台具有审批权限的员工可以处理。'; navigate('customer', 'ticket');
+  },
+  approval(data) {
+    if (!checkAccess()) return;
+    if (state.selfApproval || state.ticket !== 'WAITING_REVIEW' || !state.customerConfirmed) { notify('当前申请不满足审批条件。'); return; }
+    const reason = data.get('reason').trim();
+    if (!reason) { notify('请填写审批意见。'); return; }
+    const approved = data.get('decision') === '批准';
+    confirmAction('确认提交审批决定', `对方案 V${state.version} 作出“${approved ? '批准' : '拒绝'}”决定。意见:${reason}`, () => {
+      state.ticket = approved ? 'APPROVED' : 'REJECTED'; state.formValues.approvalReason = reason;
+      record(`主管李明${approved ? '批准' : '拒绝'}方案 V${state.version}:${reason}`);
+      notice = '决定已记录,可通过消费者入口查看进度。'; navigate('supervisor', 'history');
+    });
+  },
+  memory(data) {
+    if (loginRequired() || !checkAccess()) return;
+    if (!state.memoryConsent) { notify('请先授权保存沟通习惯。'); return; }
+    state.memoryStyle = data.get('style'); state.memoryLanguage = data.get('language'); state.memorySaved = true; record('客户更新沟通习惯。'); notify('沟通习惯已保存,可查看、更正或清空。');
+  },
+  invite(data) {
+    if (!checkAccess()) return;
+    const account = data.get('account').trim();
+    if (!account) { notify('请填写员工账户。'); return; }
+    if (state.invites.some(item => item.account === account)) { notify('该账户已登记,请检查已有邀请。'); return; }
+    state.invites.push({ account, permission: data.get('permission') }); record(`登记员工 ${account},授权配件店的${data.get('permission')}职责。`); notify('员工邀请已登记在下方列表中,等待身份接入。');
+  },
+  'channel-save'(data) {
+    if (!checkAccess()) return;
+    const url = new URL(data.get('callback'));
+    if (url.protocol !== 'https:') { notify('回调地址必须使用 HTTPS。'); return; }
+    state.formValues.channel = data.get('name'); record('保存渠道接入配置,等待接入检查。'); notify('配置已保存到线稿;签名、身份映射和回调接入仍需验证。');
+  },
+  'rule-save'(data) {
+    if (!checkAccess()) return;
+    state.ruleSaved = true; state.formValues.ruleDays = Number(data.get('days'));
+    state.formValues.rule = `${data.get('products')} / ${data.get('days')} 天 / ${data.get('citation')}`;
+    notify('规则草稿已保存,请检查影响范围后发布。');
+  },
+  'knowledge-save'(data) {
+    if (!checkAccess()) return;
+    if (state.knowledge !== 'DRAFT') state.knowledgeVersion += 1;
+    state.knowledgeTitle = data.get('title'); state.formValues.knowledge = data.get('text'); state.knowledge = 'DRAFT'; record('知识资料保存为草稿。'); notify('草稿已保存,发布后才可以被客户检索。');
+  },
+  release(data) {
+    if (!checkAccess()) return;
+    if (!state.ruleActive || state.knowledge !== 'PUBLISHED') { notify('请先发布有效规则和知识资料,再检查发布清单。'); return; }
+    confirmAction('确认发布配置', '新运行将使用发布版本 R2。原等待任务继续引用原发布版本。', () => { state.release = 'R2 已发布'; record(`发布 R2:${data.get('reason')}`); notify('线稿中的发布状态已更新为 R2。'); });
+  },
+  budget(data) {
+    if (!checkAccess()) return;
+    if (state.billingReviewer || state.budgetRequest === 'PENDING') { notify('请由申请员工提交新申请,已有待审批申请请先处理。'); return; }
+    state.pendingBudget = Number(data.get('amount')); state.budgetRequest = 'PENDING'; record(`员工甲申请每日预算 ¥${state.pendingBudget}:${data.get('reason')}`); notify('申请已保存,等待另一位费用员工审批;当前预算保持原值。');
+  },
+  'budget-decision'(data) {
+    if (!checkAccess()) return;
+    if (!state.billingReviewer || state.budgetRequest !== 'PENDING') { notify('需要另一位有审批权限的员工处理待审批申请。'); return; }
+    const approved = data.get('decision') === '批准';
+    confirmAction('确认费用审批决定', approved ? `批准后每日预算变为 ¥${state.pendingBudget}。` : '拒绝后当前预算保持不变。', () => {
+      state.budgetRequest = approved ? 'APPROVED' : 'REJECTED'; if (approved) state.budget = state.pendingBudget;
+      record(`员工乙${approved ? '批准' : '拒绝'}费用申请:${data.get('reason')}`); notify('费用审批决定已记录。');
+    });
+  },
+  'tenant-create'(data) {
+    if (!checkAccess()) return;
+    const name = data.get('name').trim();
+    if (!name) { notify('请填写商家名称。'); return; }
+    if (state.tenantRegistrations.includes(name)) { notify('该商家已登记。'); return; }
+    state.tenantRegistrations.push(name); record(`登记商家 ${name},等待接入。`); notify('商家已登记为待接入,尚未启用业务。');
+  },
+  capacity(data) {
+    if (!checkAccess()) return;
+    const interactive = Number(data.get('interactive')); const reserved = Number(data.get('reserved'));
+    if (reserved > interactive || reserved + 2 > 4) { notify('预留份额不能超过本商家并发上限;另一个商家已预留 2,交互可分配总量为 4。'); return; }
+    state.formValues.interactive = interactive; state.formValues.reserved = reserved;
+    record(`调整${data.get('tenant')}交互上限 ${interactive}、预留 ${reserved}:${data.get('reason')}`); notify('额度已保存。已有任务继续执行,预留随执行额度释放生效。');
+  },
+};
+
+const actionHandlers = {
+  'notification-filter'(target) { notificationFilter = target.dataset.filter; render(); },
+  'notification-open'(target) { if (!checkAccess()) return; const item = employeeNotifications().find(entry => entry.id === target.dataset.id); if (!item) { notify('当前员工无权查看该通知。'); return; } selectedNotification = item.id; notificationRead.add(item.id); navigate(currentRole, 'notification-detail'); },
+  'notifications-read-all'() { if (!checkAccess()) return; confirmAction('全部标为已读', '将当前员工的所有通知标为已读,业务任务仍需分别处理。', () => { employeeNotifications().forEach(item => notificationRead.add(item.id)); notify('当前员工通知已全部标为已读。'); }); },
+  'admin-login-help'() { notify(currentRole === 'platform' ? '请联系平台账户管理员核实身份并重置密码。' : '请联系本商家管理员核实账户、商家标识和权限,必要时重置密码。'); },
+  'admin-logout'() { const product = adminProduct(currentRole); confirmAction('退出当前后台', '当前后台登录状态将结束,未提交表单清空。', () => { adminAuth[product] = false; adminReturn[product] = null; for (const key of drafts.keys()) if (adminProduct(key.split('/')[0]) === product) drafts.delete(key); notice = '已退出登录。'; navigate(product, 'login'); }); },
+  'review-toggle'() { const panel = document.querySelector('.review-sidebar'); panel.hidden = !panel.hidden; document.getElementById('review-toggle').setAttribute('aria-expanded', String(!panel.hidden)); },
+  'commerce-scope'() { notify('当前项目提供商品咨询、已购订单查询和换货服务。购物车与支付交易保留产品入口,暂未提供交易功能。'); },
+  'catalog-search'() { notify('商品搜索位置用于确认商城结构。可选择分类查看商品,并通过商品页咨询 AI。'); },
+  category(target) { state.category = target.dataset.category; render(); },
+  'order-filter'(target) { state.orderFilter = target.dataset.filter; notice = ''; navigate('customer', 'orders'); },
+  reset() { confirmAction('重置全部线稿状态', '本次演示的方案、消息及配置记录将清空。', () => { state = initialState(); drafts.clear(); notice = '演示已重置。'; navigate('customer', 'home'); }); },
+  'request-human'() { if (loginRequired()) return; state.humanRequested = true; markNotificationNew('staff-handoff'); record('客户申请人工协助。'); notify('已登记人工协助请求。客服接管后会显示服务人员。'); },
+  takeover() { if (!checkAccess()) return; state.owner = 'HUMAN'; state.humanRequested = false; record('客服王芳接管会话,助手停止后续输出。'); notify('已接管,现在可以回复并整理方案。'); },
+  release() { confirmAction('结束人工接管', '下一条客户消息交由助手处理,已有售后申请继续跟踪。', () => { state.owner = 'AGENT'; record('客服王芳结束接管。'); notify('接管已结束。'); }); },
+  'cancel-ticket'() {
+    if (loginRequired() || !checkAccess()) return;
+    if (!canCancel()) { notify('当前外部操作已登记或工单已结束,无法取消。'); return; }
+    confirmAction('确认取消换货申请', '取消后停止当前方案的确认与审批。需要再次申请时重新填写。', () => { state.ticket = 'CANCELLED'; record('客户取消换货申请。'); notice = '申请已取消。'; navigate('customer', 'ticket'); });
+  },
+  'refresh-proposal'() { state.stale = false; state.version += 1; state.customerConfirmed = false; state.ticket = 'DRAFT'; drafts.delete('customer/proposal/confirm-proposal'); notify('已读取新方案,等待客户重新确认;旧版本审批失效。'); },
+  reconnect() { state.connected = true; notify('连接已恢复,继续显示已有消息。输入内容保留。'); },
+  'stop-answer'() { state.replyActive = false; notify('本次助手输出已停止,售后工单状态保持不变。'); },
+  logout() { state.loggedIn = false; notice = '已退出当前客户身份。'; navigate('customer', 'login'); },
+  'memory-consent'() { if (loginRequired()) return; confirmAction('授权保存沟通习惯', '仅用于本商家的回复方式、语言和联系方式。可以随时撤销授权。', () => { state.memoryConsent = true; record('客户授权保存沟通习惯。'); notify('已授权,可以设置沟通习惯。'); }); },
+  'memory-clear'() { confirmAction('清空沟通习惯', '已保存内容清空,授权状态保持不变,历史咨询记录保留。', () => { state.memorySaved = false; record('客户清空沟通习惯。'); notify('已清空已保存内容。'); }); },
+  'memory-revoke'() { confirmAction('撤销沟通习惯授权', '停止保存及使用沟通习惯,历史咨询记录保留。', () => { state.memoryConsent = false; state.memorySaved = false; record('客户撤销沟通习惯授权。'); notify('授权已撤销。'); }); },
+  'identity-verify'() { if (loginRequired()) return; state.identityVerified = true; notify('已展示“目标渠道验证通过”情景,请检查两个身份并确认。'); },
+  'identity-link'() { if (!state.identityVerified) return; confirmAction('确认关联两个身份', '仅共享本商家本人业务进度,各渠道聊天记录保持独立。', () => { state.linked = true; record('客户确认关联已验证的两个渠道身份。'); notify('已关联,可以跨渠道查看同一工单。'); }); },
+  'store-toggle'() { toggle('storeEnabled', '店铺'); },
+  'staff-toggle'() { toggle('staffEnabled', '员工账户'); },
+  'channel-toggle'() { toggle('channelEnabled', 'Webhook 接入'); },
+  'tenant-toggle'() { toggle('tenantEnabled', '商家'); },
+  'rule-revoke'() { confirmAction('撤销当前规则', '使用该规则且尚未执行的方案需要重新修订、确认与审批。', () => { state.ruleActive = false; if (['DRAFT', 'WAITING_REVIEW', 'APPROVED'].includes(state.ticket)) { state.ticket = 'NEEDS_REVISION'; state.customerConfirmed = false; } record('撤销当前换货规则。'); notify('规则已撤销,相关方案显示需要修订。'); }); },
+  'rule-publish'() { if (!checkAccess() || !state.ruleSaved) return; confirmAction('发布新规则版本', '新申请使用新规则,已经确认的有效方案继续使用原规则版本。', () => { state.ruleVersion += 1; state.ruleDays = state.formValues.ruleDays; state.ruleSaved = false; state.ruleActive = true; record(`发布换货规则 V${state.ruleVersion}。`); notify('新规则版本已发布。'); }); },
+  'knowledge-preview'() { notify(`检索内容预览:${state.formValues.knowledge || '键盘清洁前请断开电源。换货条件以当前有效售后规则为准。'}`); },
+  'knowledge-publish'() { if (!checkAccess()) return; confirmAction('发布知识资料', '发布后当前版本可以被本店客户检索和引用。', () => { state.knowledge = 'PUBLISHED'; record(`发布知识 ${state.knowledgeTitle} V${state.knowledgeVersion}。`); notify('当前知识版本已发布。'); }); },
+  'knowledge-revoke'() { confirmAction('撤回知识资料', '停止向客户提供当前版本正文,相关答复需要重新检查资料。', () => { state.knowledge = 'REVOKED'; record('撤回知识资料。'); notify('资料已撤回,客户检索停止引用该版本。'); }); },
+  'delivery-retry'() { if (!checkAccess()) return; confirmAction('重试结果投递', '重试沿用原业务事件,提交后进入等待投递状态。', () => { state.delivery = 'QUEUED'; record('结果投递 DL-001 重新排队。'); notify('投递已排队,尚未收到接收端确认。'); }); },
+  'restore-access'() { state.expired = false; state.stale = false; notify('已进入重新取得有效授权的演示状态。'); },
+  'price-fields'() { notify('价格申请字段:供应商地址、模型名称、地域、生效日期、三类 token 单价、价格来源、申请原因。提交后等待另一位费用员工审批。'); },
+  'reconcile-fields'() { notify('费用核对字段:账本编号、完整核实金额、证据编号、核对原因。提交后等待另一位费用员工审批,批准后保留修改记录。'); },
+};
+function toggle(key, label) {
+  if (!checkAccess()) return;
+  const verb = state[key] ? '停用' : '启用';
+  confirmAction(`${verb}${label}`, `${verb}影响后续访问和调用。已经登记的外部业务继续跟踪。`, () => { state[key] = !state[key]; record(`${verb}${label}。`); notify(`${label}已${verb}。`); });
+}
+function applyScenario(id) {
+  saveDrafts(); notice = '已切换评审情景;页面内容为设计示例。';
+  if (id === 'login-error' || id === 'session-expired') {
+    const product = adminProduct(currentRole);
+    if (currentPage !== 'login') adminReturn[product] = { role: currentRole, page: currentPage };
+    adminAuth[product] = false;
+    notice = id === 'login-error' ? '登录未成功,请检查账户信息或联系管理员。' : '登录已过期,请重新登录后继续。未提交内容保留。';
+    navigate(product, 'login'); return;
+  }
+  state.expired = false; state.stale = false; state.selfApproval = false; state.empty = false;
+  if (id === 'start') { state = initialState(); drafts.clear(); navigate('customer', 'home'); return; }
+  if (id === 'waiting' || id === 'self' || id === 'stale' && currentRole === 'supervisor') { state.loggedIn = true; state.ticket = 'WAITING_REVIEW'; state.customerConfirmed = true; state.selfApproval = id === 'self'; state.stale = id === 'stale'; navigate(currentRole, currentRole === 'supervisor' ? 'review' : 'ticket'); return; }
+  if (id === 'unknown' || id === 'revision' || id === 'completed' || id === 'processed') {
+    state.loggedIn = true; state.customerConfirmed = id !== 'revision';
+    state.ticket = { unknown: 'RESULT_UNKNOWN', revision: 'NEEDS_REVISION', completed: 'COMPLETED', processed: 'PROCESSING' }[id];
+    navigate(currentRole, currentRole === 'supervisor' ? 'history' : 'ticket'); return;
+  }
+  if (id === 'human') { state.humanRequested = true; state.owner = 'AGENT'; navigate('staff', 'conversation'); return; }
+  if (id === 'disconnect') { state.connected = false; navigate('customer', 'chat'); return; }
+  if (id === 'generating') { state.loggedIn = true; state.replyActive = true; state.blocked = false; navigate('customer', 'chat'); return; }
+  if (id === 'unavailable') { state.loggedIn = true; state.replyActive = false; state.blocked = true; navigate('customer', 'chat'); return; }
+  if (id === 'empty') { state.empty = true; state.ticket = 'NONE'; navigate(currentRole, currentRole === 'staff' ? 'queue' : 'orders'); return; }
+  if (id === 'expired') state.expired = true;
+  if (id === 'stale') { state.stale = true; notice = '演示其他操作者已更新配置。保存时将提示版本冲突。使用正常配置情景重新读取。'; }
+  if (id === 'billing-reviewer') { state.billingReviewer = !state.billingReviewer; navigate('merchant', 'billing'); return; }
+  if (id === 'delivery-done') { state.delivery = 'SENT'; navigate('platform', 'deliveries'); return; }
+  render();
+}
+document.addEventListener('click', event => {
+  const target = event.target.closest('button'); if (!target || target.disabled) return;
+  if (target.dataset.employeeRole) { saveDrafts(); merchantRole = target.dataset.employeeRole; notice = ''; navigate(merchantRole, roleHomes[merchantRole]); }
+  else if (target.dataset.roleHome) { saveDrafts(); notice = ''; const role = target.dataset.roleHome === 'merchant' ? merchantRole : target.dataset.roleHome; navigate(role, roleHomes[role]); }
+  else if (target.dataset.page) {
+    saveDrafts(); notice = '';
+    if (target.dataset.category) state.category = target.dataset.category;
+    // 商品与订单入口携带咨询上下文,AI 会话仍保持同一客户身份。
+    if (target.dataset.role === 'customer' && target.dataset.page === 'chat') {
+      state.aiContext = currentPage === 'product' ? `当前咨询商品:${state.category || '键盘'}` : ['orders', 'order', 'ticket'].includes(currentPage) ? '当前咨询订单:A-20260918-001 · 键盘' : '';
+    }
+    navigate(target.dataset.role, target.dataset.page);
+  }
+  else if (target.dataset.scenario) applyScenario(target.dataset.scenario);
+  else if (target.dataset.action) { saveDrafts(); const handler = actionHandlers[target.dataset.action]; if (!handler) throw new Error(`Unknown action: ${target.dataset.action}`); handler(target); }
+});
+document.addEventListener('submit', event => {
+  const action = event.target.dataset.submit; if (!action) return;
+  event.preventDefault(); saveDrafts();
+  const handler = submissions[action]; if (!handler) throw new Error(`Unknown form: ${action}`);
+  handler(new FormData(event.target));
+});
+document.getElementById('confirm-dialog').addEventListener('close', event => {
+  const callback = pendingConfirmation; pendingConfirmation = null;
+  if (event.target.returnValue === 'confirm' && callback) callback();
+});
+// 商家页面共用一个入口;页面路径不改变当前员工的权限。
+function routeUrl(role, page) {
+  if (role === 'staff') return `#merchant/service/${page}`;
+  if (role === 'supervisor') return `#merchant/approval/${page}`;
+  return `#${role}/${page}`;
+}
+function readRoute() {
+  let key = location.hash.slice(1);
+  if (key.startsWith('merchant/service/')) key = 'staff/' + key.slice('merchant/service/'.length);
+  else if (key.startsWith('merchant/approval/')) key = 'supervisor/' + key.slice('merchant/approval/'.length);
+  const screen = screens[key];
+  if (screen && screen.page !== 'login' && ['merchant', 'staff', 'supervisor'].includes(screen.role) && screen.role !== merchantRole) {
+    currentRole = merchantRole; currentPage = roleHomes[merchantRole];
+    notice = '当前员工没有所请求页面的权限,已进入可访问的商家功能。';
+    history.replaceState(null, '', routeUrl(currentRole, currentPage));
+  }
+  else if (screen) { currentRole = screen.role; currentPage = screen.page; history.replaceState(null, '', routeUrl(currentRole, currentPage)); }
+  else { currentRole = 'customer'; currentPage = 'home'; history.replaceState(null, '', '#customer/home'); }
+  render();
+}
+window.addEventListener('hashchange', readRoute);
+readRoute();

+ 44 - 0
docs/design/wireframes/conversation-list-presentation.md

@@ -0,0 +1,44 @@
+# 会话与列表展示规范
+
+本次调整依据用户于 2026-09-28 提出的三端会话与列表 UI 优化要求。页面入口、业务操作、确认步骤、权限和接口沿用已有设计;本规范维护信息展示、长内容阅读及滚动规则。
+
+## 信息展示
+
+| 位置 | 直接展示 | 按需查看 | 不向当前用户展示 |
+| --- | --- | --- | --- |
+| H5 咨询 | 客户、AI 助手、人工客服身份,正文,消息时间,发送和处理状态,人工接待状态,相关订单与工单入口 | 长消息全文、订单详情、方案内容与规则依据 | 内部 run、task、event 编号,原始事件名称,工具输入输出、Prompt、模型参数、内部错误信息 |
+| 商家会话队列 | 客户标识、接待状态、会话来源、建立时间及已有查询条件 | 完整会话标识及授权业务上下文 | 未获授权客户正文及资料 |
+| 商家会话 | 消息身份、正文和时间,接待控制状态,订单及售后操作 | 长正文、完整业务编号、规则依据、工单处理记录 | 内部任务载荷、模型推理过程、员工权限之外的数据 |
+| 商家通知 | 已读状态、类型、标题、摘要、发生时间和详情入口 | 通知全文与有权限的业务入口 | 客户消息正文,其他员工的通知 |
+| 平台投递 | 商家、接收应用、业务事件、投递状态及操作 | 所选投递的完整编号、事件编号与版本、发送次数、失败原因、下次尝试、接收结果 | 客户聊天正文、订单正文、模型内部内容 |
+| 平台审计 | 时间、操作者、范围、对象、操作及结果 | 变更前后的完整元数据、较长操作说明 | 超出平台管理权限的业务资料 |
+
+状态名称使用面向用户的中文。没有服务端记录的送达、已读和未读信息不得推测或补充。客户确认与审批条件始终完整展示。
+
+## 消息形式
+
+- 客户、AI 助手和人工客服消息使用具有身份文字的消息气泡;颜色作为辅助识别。
+- 人工接待、恢复助手、业务进度等系统事件使用独立状态提示。传输和模型调度事件仅更新处理状态。
+- 订单、工单和方案继续使用业务卡片及原有入口。正文中的文字按文本安全展示,不能执行 HTML。
+- 发送中、发送失败、等待回复和连接异常具有独立反馈。恢复操作遵循已有幂等与连接规则。
+- 用户查看历史消息时保持阅读位置;新消息提示允许返回最新内容。加载更早记录后保持当前消息位置。
+
+## 高度与滚动
+
+| 区域 | 高度限制 | 超出后的行为 |
+| --- | --- | --- |
+| H5 咨询工作区 | 根据当前视口可用高度分配;输入区域保持可访问 | 消息区域纵向滚动;视口高度不超过 600px 时减少外围和标题间距,保留消息阅读空间 |
+| H5 咨询输入框 | 正常为 44–96px;视口高度不超过 600px 时最高 44px | 较长草稿在输入框内滚动,发送与人工协助操作保持可见 |
+| H5、商家单条长消息 | 默认正文预览最高 240px | 展开全文后由消息区域滚动,正文自身不产生第二个滚动区域 |
+| 商家会话工作区 | 宽屏为 560–760px,并按视口高度减去 220px 计算 | 队列、消息和上下文分别纵向滚动;1150px 以下改为两栏,760px 以下纵向排列,聊天区域高 680px,客户资料最高 480px |
+| 后台数据表格 | 最高为 560px 与视口高度 65% 中较小值 | 表体纵向滚动,表头保持可见;列宽不足时表格内部横向滚动 |
+| 列表长文字 | 使用短摘要或有限高度预览 | 通过展开全文或详情入口读取完整内容,键盘和触控均可使用 |
+| 平台投递详情 | 正文最高为 560px 与视口高度 60% 中较小值 | 正文区域纵向滚动;窗口关闭操作保持可访问 |
+| H5 商品、订单和消息列表 | 沿用页面纵向滚动 | 保留现有分页与加载更多,不添加卡片内部滚动 |
+| H5 售后申请选择列表 | 最高为 360px 与视口高度 40% 中较小值 | 选择列表独立滚动,当前申请详情及加载更多操作位于列表外 |
+
+完整编号保留在业务详情中,展示缩略不改变接口提交值。列表滚动不等同于加载全部数据;既有分页继续使用服务端游标,平台投递和审计明确标注最多显示最近 100 条。
+
+## 验证要求
+
+使用真实服务和浏览器 DOM 检查消息身份、长内容展开、阅读位置、输入区域、详情选择、滚动高度与横向宽度。覆盖加载、空内容、失败、禁用、无权限及会话过期等相关状态。检查不使用截图;生成模型调用次数单独记录。

+ 41 - 0
docs/design/wireframes/customer.js

@@ -0,0 +1,41 @@
+register('customer', 'chat', 'H01', '咨询与人工协助', () => {
+  const messages = state.messages.map(item => `<div class="message ${item.sender === '客户' ? 'customer' : ''}"><small>${escapeHtml(item.sender)}</small>${escapeHtml(item.text)}</div>`).join('');
+  return mobile('咨询', `${!state.loggedIn ? hint(`登录后可以查询本人订单和发起售后。${link('进入登录', 'login')}`) : tag('客户 A01 · 已登录')}
+    ${!state.connected ? hint(`连接已中断,输入内容保留。${button('重新连接', 'reconnect')}`) : ''}
+    ${state.blocked ? hint(`助手暂时不可用,你可以查询已有工单或申请人工协助。${link('查看售后', 'ticket')}`) : ''}
+    ${state.owner === 'HUMAN' ? hint('客服王芳正在为你服务。') : state.humanRequested ? hint('已申请人工协助,等待客服接管。你可以继续补充问题。') : tag('智能助手服务中')}
+    ${state.aiContext ? box('正在咨询', `<p>${escapeHtml(state.aiContext)}</p>`) : ''}
+    <div class="message"><small>AI 购物助手</small>你好,可以向我咨询商品使用、订单进度和售后规则。需要换货时,我会协助你确认订单和申请信息。</div>${messages}
+    ${actions(`${link('选择订单', 'orders')}${button('申请人工协助', 'request-human', state.humanRequested || state.owner === 'HUMAN' ? 'disabled' : '')}`)}
+    ${state.ticket !== 'NONE' ? box('当前售后', `${tag(statusLabels[state.ticket])}${actions(link('查看当前方案与进度', 'ticket'))}`) : ''}
+    ${form('customer-message', area('补充问题', 'message', '', 'required maxlength="2000" placeholder="例如:收到的键盘空格键无法使用"'), '发送', !state.connected ? 'disabled' : '')}
+    ${state.replyActive ? `${hint('助手正在查询,请稍候。')}${actions(button('停止本次回复', 'stop-answer'))}` : ''}`);
+}, ['入口来自商家店铺,客户身份和商家范围由登录结果确定。', '申请人工只登记服务请求;客服取得控制权后切换服务提示。', '断线恢复继续读取已有消息,不重复发送已受理内容。停止回复不会取消售后工单。']);
+
+register('customer', 'login', 'H02', '登录与身份确认', () => mobile('登录', `${hint('当前进入 A 商家配件店。账户仅用于本商家服务。')}${form('login', `${field('账户', 'account', 'A01', 'text', 'required autocomplete="username"')}${field('密码', 'password', '', 'password', 'required autocomplete="current-password" placeholder="线稿可填写任意内容,请勿输入真实密码"')}${consent('accept', '确认进入当前商家的客户服务')}`, '登录并返回咨询')}`), ['正式产品由身份服务验证账户;线稿只演示进入已登录状态。', '登录失效后保留当前页面入口,重新登录后返回原页面并重新检查权限。']);
+
+register('customer', 'orders', 'H03', '我的订单', () => mobile('我的订单', `<div class="tabs order-filters">${['全部', '待付款', '待发货', '待收货', '已完成'].map(label => `<button data-action="order-filter" data-filter="${label}" ${state.orderFilter === label ? 'aria-current="page"' : ''}>${label}</button>`).join('')}</div>${state.empty || !['全部', '已完成'].includes(state.orderFilter) ? '<div class="empty">暂无该状态的订单。</div>' : `<section class="order-card"><div class="section-heading"><strong>A 商家 · 配件店</strong><span>已签收</span></div><div class="order-product"><div class="goods-image">键盘</div><div><strong>办公键盘</strong><p>标准款 · 数量 1</p><p>订单 A-20260918-001</p></div></div><p class="muted">签收后第 2 天</p>${actions(`${link('查看订单', 'order')}${link('申请售后', 'apply')}${link('联系 AI 客服', 'chat')}`)}</section>`}`), ['订单采用状态标签和订单卡片结构。', '订单详情、申请售后、联系客服分别提供直接入口。']);
+
+register('customer', 'order', 'H04', '订单详情与申请条件', () => mobile('订单详情', `${box('订单信息', orderSummary())}${box('适用规则', `<p>《键盘换货规则》V${state.ruleVersion}</p><p>签收后 ${state.ruleDays} × 24 小时以内,包含时间边界;功能故障需填写描述。</p>${link('阅读规则原文', 'policy')}`)}${!state.ruleActive ? hint('当前订单不符合申请条件:适用规则已失效。可以申请人工协助核实。') : ''}${actions(`${link('填写换货申请', 'apply')}${button('申请人工协助', 'request-human')}`)}`), ['订单详情明确显示商品、可申请数量和规则依据。', '超过期限、未签收、数量不足或规则失效时,展示具体原因;提交时再次检查最新状态。']);
+
+register('customer', 'policy', 'H05', '规则依据', () => mobile('规则详情', `<h2>键盘换货规则 · V${state.ruleVersion}</h2>${tag(state.ruleActive ? '当前有效' : '已撤销')}<p>第一条:签收后 ${state.ruleDays} × 24 小时以内,键盘存在功能故障时可以申请换货。</p><p>第二条:申请数量需在可售后数量范围内,必须描述故障。</p><p>第三条:方案由客户确认,主管批准后提交履约系统。</p>${hint('规则内容为需求文档中的验收商家示例。')}${actions(link('返回订单', 'order'))}`), ['引用包含文档标题、版本和条款。', '规则撤销后,原方案需要重新核实适用条件。']);
+
+register('customer', 'apply', 'H06', '填写或修订换货申请', () => mobile('换货申请', `${box('已选择订单', orderSummary())}${state.ticket !== 'NONE' ? hint(`当前方案 V${state.version}。修订后需要重新确认,原确认与审批失效。`) : ''}${form('proposal', `${field('申请数量', 'quantity', state.quantity, 'number', 'min="1" max="1" required')}${area('故障描述', 'reason', state.reason, 'required maxlength="2000"')}`, canRevise() ? '保存新版本并检查' : '生成方案并检查', !canCreate() && !canRevise() ? 'disabled' : '')}`), ['数量为正整数且不超过可申请数量;故障描述必填。', '保存后进入确认页,不直接提交审批或创建外部申请。', '等待审批时修订会撤销当前审批资格,新版本重新确认。']);
+
+register('customer', 'proposal', 'H07', '检查并确认方案', () => mobile('方案确认', state.ticket === 'NONE' ? `${hint('请先选择订单并填写申请。')}${link('选择订单', 'orders')}` : `${box('请核对以下信息', proposalSummary())}${state.stale ? hint('此方案已有新版本,请刷新后重新检查。') : ''}${form('confirm-proposal', consent('confirmed', `我确认商品、数量、故障描述及方案 V${state.version},同意提交主管审批。`), '确认并提交审批', state.ticket !== 'DRAFT' || state.stale ? 'disabled' : '')}${actions(`${link('修改方案', 'apply')}${button('取消申请', 'cancel-ticket', canCancel() ? '' : 'disabled')}${state.stale ? button('读取最新方案', 'refresh-proposal') : ''}`)}`), ['只有客户可以确认;客服和主管查看确认记录。', '按钮绑定用户看到的方案版本,版本变化时必须重新检查。', '重复点击应返回同一确认结果和审批任务。']);
+
+register('customer', 'ticket', 'H08', '售后进度与取消', () => {
+  if (state.ticket === 'NONE') return mobile('我的售后', `<div class="empty">当前没有售后申请。${actions(link('查看订单', 'orders'))}</div>`);
+  const messages = {
+    DRAFT: '请检查方案并确认,确认后交由主管审批。', WAITING_REVIEW: '主管正在审核,处理结果会显示在这里。',
+    APPROVED: '审批通过,系统正在复核订单和规则。尚未提交外部申请时可以取消。',
+    PROCESSING: '履约系统已受理换货,等待完成。', RESULT_UNKNOWN: '正在查询同一笔申请的受理结果。请继续等待,可申请人工协助。',
+    COMPLETED: '履约系统已确认换货完成。', REJECTED: `审批未通过:${escapeHtml(state.formValues.approvalReason || '当前故障材料不足,请联系人工了解原因。')}`,
+    NEEDS_REVISION: '适用条件已变化,请修订方案并重新确认。', CANCELLED: '本次申请已取消。', FAILED: '履约系统明确拒绝:当前库存不足。',
+  };
+  return mobile('售后 SH-001', `${tag(statusLabels[state.ticket])}${hint(messages[state.ticket])}${box('方案信息', proposalSummary())}<ol class="timeline"><li>方案 V${state.version} 已保存</li><li>${state.customerConfirmed ? '客户已确认当前版本' : '等待客户确认当前版本'}</li><li>${['APPROVED', 'PROCESSING', 'RESULT_UNKNOWN', 'COMPLETED'].includes(state.ticket) ? '主管已批准' : state.ticket === 'REJECTED' ? '主管已拒绝' : '主管审批'}</li><li>${state.ticket === 'COMPLETED' ? '履约完成' : '履约结果待确认'}</li></ol>${actions(`${state.ticket === 'DRAFT' ? link('检查并确认', 'proposal') : ''}${canRevise() ? link('修订方案', 'apply') : ''}${button('取消申请', 'cancel-ticket', canCancel() ? '' : 'disabled')}${button('申请人工协助', 'request-human')}`)}${!canCancel() ? '<p class="muted">当前状态不提供取消操作。已登记的外部申请继续跟踪。</p>' : ''}`);
+}, ['受理成功与履约完成分别显示。', '结果未知时保持原申请,禁止重复建单;展示持续核实与人工协助入口。', '取消需要再次确认;提交时根据最新状态判断,已经登记外部操作则保留跟踪。']);
+
+register('customer', 'memory', 'H09', '沟通习惯与账户', () => mobile('我的', `${box('账户', `<p>A01 · A 商家</p>${actions(`${link('渠道身份关联', 'identity')}${button('退出登录', 'logout')}`)}`)}${tag(state.memoryConsent ? '已授权保存沟通习惯' : '尚未授权')}<p>授权后仅保存回复方式、语言及联系方式。可以随时更正、清空或撤销授权。</p>${!state.memoryConsent ? button('授权保存', 'memory-consent') : form('memory', `${select('回复方式', 'style', ['简洁回复', '详细回复'], state.memoryStyle)}${select('回复语言', 'language', ['中文', 'English'], state.memoryLanguage)}`, '保存沟通习惯')}${state.memorySaved ? hint(`已保存:${escapeHtml(state.memoryStyle)} / ${escapeHtml(state.memoryLanguage)} / 文字沟通`) : hint('当前没有已保存的沟通习惯。')}${actions(`${button('清空已保存内容', 'memory-clear', state.memorySaved ? '' : 'disabled')}${button('撤销授权', 'memory-revoke', state.memoryConsent ? '' : 'disabled')}`)}<p class="muted">清空沟通习惯保留历史咨询记录。</p>`), ['授权默认关闭;清空内容与撤销授权是独立操作。', '所有变更由本人确认,删除后旧任务不能恢复已删除内容。']);
+
+register('customer', 'identity', 'H10', '关联渠道身份', () => mobile('身份关联', `${hint('关联后,可以在本商家的两个渠道查询本人同一工单。各渠道聊天记录独立。')}${tag(state.linked ? 'Webhook 渠道已关联' : '尚未关联')}<ol><li>确认当前 H5 登录身份 A01</li><li>在目标渠道完成一次身份验证</li><li>检查两个身份并确认关联</li></ol>${state.identityVerified ? box('已验证的身份', '<p>H5:A01</p><p>目标渠道:已通过渠道验证的账户</p>') : '<div class="placeholder">目标渠道验证入口<br>正式接入方式由渠道授权能力确定</div>'}${actions(`${button('演示目标渠道验证完成', 'identity-verify')}${button('确认关联', 'identity-link', !state.identityVerified || state.linked ? 'disabled' : '')}`)}`), ['这里表达双身份验证与确认步骤,具体渠道验证方式保留为接入条件。', '姓名、昵称及聊天里提供的手机号不作为关联依据。']);

+ 14 - 0
docs/design/wireframes/h5-login-presentation.md

@@ -0,0 +1,14 @@
+# H5 登录页展示规范
+
+本规范依据用户于 2026-09-28 提出的 H5 登录页优化要求,维护 H02 的信息层级、输入体验和反馈形式。账户、密码、当前商家服务确认、返回与消息入口,以及真实认证和原页面返回规则继续适用。
+
+- 顶部标题为“账户登录”。正文以“欢迎回来”和登录用途说明引导,白色卡片集中展示真实店铺名称、输入字段、确认选项及提交按钮。店铺名称来自现有商品目录接口,读取失败提供“重新读取店铺”。
+- 页面采用已确认的浅灰蓝背景、蓝色主操作、16px 卡片圆角和 18px 标题。电脑端最大宽度 414px;手机端使用可用宽度。登录页不展示装饰性的时间、电量和网络文字。
+- 账户和密码具有可见标签、占位提示及浏览器自动填充属性。账户最长 128 个字符,密码最长 256 个字符,与身份接口一致。账户关闭自动大写和拼写检查。
+- 密码默认隐藏,可通过具有明确名称的按钮显示或隐藏。按钮可操作区域为 44×44px;字段至少高 48px。离开登录页或一次提交结束时清空密码并恢复隐藏状态。
+- 当前商家服务确认默认关闭;字段和确认由 Vant 表单校验。确认区域至少高 44px,支持空格切换,并向辅助技术提供明确名称与禁用状态。未满足条件时显示对应提示并停止提交。
+- 主按钮为“登录并继续”,占据表单可用宽度,至少高 48px。提交期间显示“正在登录”,字段只读、确认和按钮禁用,阻止重复请求。
+- 认证失败在表单内使用有文字与图标的错误提示,保留账户和当前商家确认。客户重新输入密码后再次提交;网络和限流反馈沿用接口提示,不自动连续重试。
+- 正文使用单个纵向滚动区域,短屏和横屏可滚动到每个字段及提交按钮。登录页末尾保留安全区空间。
+
+验证使用真实身份、店铺和订单接口,检查字段校验、密码显隐、认证失败、原页面返回、不同尺寸下的操作区域及浏览器错误。通过 DOM 和交互检查完成验证。

+ 99 - 0
docs/design/wireframes/implementation-baseline.md

@@ -0,0 +1,99 @@
+# 后台登录与权限实现依据
+
+本文件与当前交互线稿共同作为后续功能实现依据。业务开发应保持页面入口、字段、权限边界、状态反馈与返回行为一致;学习范围之外的栏目保持范围说明。涉及接口的实现需要同步更新 OpenAPI、接口指南和真实组件验证。
+
+会话与列表的信息展示、消息形式及滚动行为遵循 [会话与列表展示规范](conversation-list-presentation.md)。
+
+## 页面与账户
+
+- M00 商家后台登录:商家标识、员工账户、密码,全部必填。
+- P00 平台后台登录:平台员工账户、密码,全部必填。
+- 两个后台分别验证身份和管理登录状态。商家账户不能凭商家登录状态进入平台后台。
+- 商家后台共用系统框架,客服、主管、管理员的菜单和操作由服务端实际授权决定;页面路径不能改变身份或权限。
+- 角色选择仅存在于线稿评审工具,正式登录页不允许用户自行选择权限角色。
+- 账户由对应管理员分配。无法登录或忘记密码时,页面提示联系对应管理员核实与重置;当前范围不提供公开自助注册。
+
+## 登录流程
+
+1. 未登录访问后台页面,保存同一后台内的目标页面地址,进入所属后台登录页。
+2. 校验必填字段;提交期间显示处理中并阻止重复提交。
+3. 服务端校验账户、密码、启用状态、所属商家及后台访问权限。
+4. 失败时停留登录页,保留账户和商家标识,清空密码,显示统一失败提示;不得泄露账户是否存在。
+5. 网络失败显示可重试提示;限流按服务端等待时间提示,不自动连续重试。
+6. 成功后加载账户权限。如果原目标页面仍有权限则返回;否则进入当前账户有权限的首页。
+7. 返回地址只允许当前后台内的已登记页面,不接受任意外部地址。
+
+## 会话与退出
+
+- 正式前端在当前标签页的 `sessionStorage` 中分别保存消费者、商家和平台的会话凭据。刷新页面后重新向对应身份接口验证 Bearer token,并读取最新身份与权限;验证成功后保留原有授权页面。
+- 恢复身份期间展示加载提示,验证完成前不挂载业务页面。网络失败保留凭据并提供“重新验证登录”;会话过期或被服务端撤销时清除当前入口凭据并进入登录流程。
+- 本地仅保存令牌、到期时间和展示用账户名称。账户身份、角色、店铺授权和菜单权限均来自身份接口,业务数据及未提交表单继续只保存在内存。
+- 登录页不展示受保护的菜单和业务内容。
+- 会话过期时隐藏受保护内容、停止业务提交,进入所属后台登录页。
+- 过期前未提交的表单在当前浏览器内存中保留。重新登录后重新读取版本与权限,用户检查后再次提交,禁止自动重放写操作。
+- 切换账户后不得向新账户恢复前一账户的客户数据或表单。保留内容只恢复给重新验证的同一身份。
+- 点击退出登录后再次确认;服务端撤销当前后台会话,清理当前后台敏感页面与未提交表单,返回该后台登录页。
+- 退出商家后台不自动退出平台后台,反向行为相同。
+- 密码不得写入日志、URL、浏览器持久存储或草稿缓存。
+
+## 验收要点
+
+- 商家、平台分别验证成功登录、失败登录、禁用账户和无后台权限账户。
+- 未登录直接访问业务地址时,页面与 API 均拒绝访问受保护数据。
+- 客服不能审批,主管不能审批自己提交的申请;商家管理员的配置身份不自动包含售后审批权限。
+- 过期重新登录返回原授权页面;权限撤销后返回有权限的页面,原写入不能继续提交。
+- 退出后旧会话无法继续访问;另一后台的有效会话仍按其权限工作。
+
+## 商家员工消息通知
+
+- 商家后台提供顶部“消息通知(未读数量)”入口及侧栏通知中心,适用于当前已登录员工。
+- 管理员通知覆盖知识待发布、模型预算提醒及渠道接入异常;客服通知覆盖人工协助请求、已分配会话中的客户新消息和关联工单进度。主管按审批权限接收待办。
+- 通知接收范围按商家、员工、授权店铺及具体业务权限计算。管理员身份不自动获得客户聊天正文或费用审批权限。
+- 人工协助请求发送给对应店铺有接待权限的员工;有人接管后关联原会话,其他员工进入时读取当前接待状态。已接管会话的新消息只通知当前接管员工。
+- 列表展示未读状态、类型、标题、摘要、时间及详情入口,支持全部与未读筛选、无通知状态、全部标为已读。
+- 查看通知详情后标记当前接收人的通知为已读;重复请求幂等。全部已读需要确认,只影响当前员工已收到的通知,新到通知仍为未读。
+- 已读与业务处理状态分别管理。读取通知不会自动接管、审批、发布或完成工单。
+- 详情中的操作入口重新校验权限并读取最新业务状态。目标已删除、权限撤销或任务已处理时展示对应提示。
+- 客户消息通知摘要只提示有新消息,敏感正文通过授权会话页面读取。
+- 后续实现需按业务事件和接收人去重,保存通知投递与已读状态,确保重新登录后仍能查询。连接中断后重新查询未读数量和增量通知。
+- 线稿演示使用代表通知。客户申请人工、人工接管期间收到客户消息、客户确认方案时,相应通知重新显示未读。真实事件投递、持久化、重复消息和跨员工隔离需要实际组件验收。
+
+## 线稿验证范围
+
+线稿仅演示浏览器交互,任意非空账户密码会进入演示身份,刷新后演示登录状态清空。失败和过期通过评审情景展示。以上身份认证、账户隔离、登录限流、服务端会话撤销与权限检查需要后续真实实现及测试,不能以线稿检查代替。
+
+## Agent 发布与会话
+
+- 商家发布 Agent 配置后,所有会话后续新建的 `AgentRun` 都读取当前店铺的最新已发布版本。客户可以继续使用原账号和原聊天记录。
+- 每个 `AgentRun` 保存发布时的运行图、提示词、工具与模型配置快照。运行期间、等待补充资料、等待客户确认或主管审批及恢复执行时继续使用同一快照。
+- 商家 AI 配置页面及发布确认窗口明确说明:所有会话中新建的助手任务使用已发布配置,进行中和等待中的任务继续使用原配置。
+- 发布操作沿用当前店铺权限、候选内容检查、并发版本及幂等规则;发布不会修改正在进行的任务、已有消息或工单。
+
+## 换货服务评价
+
+本节对应用户授权的课程前端改造,补充 H08 售后进度和 S04 工单详情的服务评价区域;主管售后记录、审批资料和商家售后管理复用相同的只读内容。页面入口、导航、售后状态和已确认视觉参数继续沿用现有规范。
+
+- H08 在工单内容下方展示“服务评价”。工单尚未完成时提示“换货完成后可以提交服务评价”;本人 `COMPLETED` 工单尚未评价时显示评分、处理结果和评价内容。
+- 评分为 1 至 5 的整数,处理结果必选“问题已解决”或“问题未解决”,文字选填且去除首尾空白后最多 500 个字符。
+- 点击“提交服务评价”打开确认窗口,列出评分、处理结果、文字和每个工单仅评价一次的说明。返回检查保留表单,确认后开始提交;提交期间禁用输入与重复提交。
+- 成功后显示已评价、评分、处理结果、文字及时间,不提供再次评价或修改入口。文字为空时显示说明;较长文字使用现有全文查看组件。
+- 读取期间展示加载提示。读取失败展示错误与“读取最新评价”,读取成功前不允许提交;无工单时沿用现有空内容页面。
+- 网络中断、超时或服务端异常保留原提交内容与幂等标识,禁用输入并显示“重试原评价”。人工再次确认后发送同一请求,不自动重试。
+- 版本冲突、工单状态变化或已有评价时重新读取工单及评价。仍可评价时要求客户检查并重新确认,已有评价时展示原结果。
+- 会话过期进入所属登录入口,同一身份重新登录后读取当前版本与权限;退出或更换客户身份时清理草稿。无权限或工单不可访问时停止提交并显示失败提示,接口仍须验证商家、店铺和工单本人范围。
+- S04 及其他员工工单页面展示相同评价结果与“读取最新评价”,员工没有代客户提交入口。当前店铺具备 `tickets:read` 权限的有效员工可以读取。
+- 接口使用 `GET /openapi/v1/tickets/{ticket_id}/feedback` 查询单个工单,使用同路径 `POST` 提交。POST 携带 Bearer 会话、`Idempotency-Key`、`expected_version`、`score`、`resolved`、`comment`;成功返回评价对象。完整字段和错误规则见 `frontend/data-sources.md`。
+- 评分不高于 2 分或问题未解决时,服务端通知当前店铺具备工单读取权限的有效员工。现有通知详情通过 `target_id` 读取最新工单,按当前员工权限进入客服工单、主管售后记录或商家售后管理。通知已读与业务跟进分别处理。
+- 消费者表单使用 Vant,员工读取区域使用 Element Plus 标签及按钮;卡片、字号、间距和颜色复用原有 H5 与后台规范。后端完成课程开发后,通过真实 API 验证提交、重复请求、身份隔离和通知跳转。
+
+## 换货问题的客服回访
+
+本节对应用户授权的后端与 Agent 能力课程,补充 H08、S04 及复用工单组件的员工页面。客户使用已有 AI 咨询提出回访需求,工单显示登记结果。
+
+- 已有 `followup` 时,在服务评价组件内展示“客服回访”,依次显示“已登记”、回访原因、期望联系时段和登记时间。客户与员工均只读,较长原因和联系时段使用现有全文查看组件。
+- 工单尚无回访时隐藏该区域。客户在现有咨询输入中说明工单、问题和期望联系时段,例如“工单 xxx 换货后仍有问题,请客服在工作日晚上联系我”。
+- Agent 缺少资料时继续使用现有补充输入:展示助手的问题,客户通过“补充问题”和“发送”回答,同一运行继续处理。
+- 组件每次“读取最新评价”同时读取最新工单与评价,工单的 `followup` 字段提供回访记录。加载提示说明正在读取评价与回访,读取失败使用原错误提示;两个查询成功前禁止评价提交。
+- 工单刷新保留尚未确认结果的评价请求正文与幂等标识。回访登记不代替客户评价、方案确认或主管审批,工单状态和版本遵循现有售后规则。
+- 回访通知仍通过原通知详情进入当前员工有权读取的工单。店铺权限和工单本人范围由服务端检查,通知已读与回访登记分别处理。
+- H5 使用 Vant 标签,员工页面使用 Element Plus 标签;间距、颜色和文字样式沿用现有服务评价区域。

+ 42 - 0
docs/design/wireframes/index.html

@@ -0,0 +1,42 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>智服中台 · 产品线稿</title>
+  <link rel="stylesheet" href="wireframes.css">
+  <script defer src="shared.js"></script>
+  <script defer src="customer.js"></script>
+  <script defer src="workspace.js"></script>
+  <script defer src="management.js"></script>
+  <script defer src="product-layout.js"></script>
+  <script defer src="admin-login.js"></script>
+  <script defer src="notifications.js"></script>
+  <script defer src="app.js"></script>
+</head>
+<body>
+  <header class="review-header">
+    <div><strong>智服 · 交互线稿</strong><span>示例内容 · 未连接业务服务</span><button type="button" id="review-toggle" data-action="review-toggle">评审工具</button></div>
+    <nav aria-label="评审入口" id="role-nav"></nav>
+  </header>
+  <div class="review-layout">
+    <aside class="review-sidebar" hidden>
+      <section id="merchant-role-controls"><h2>商家员工角色</h2><div id="merchant-role-nav"></div><p class="muted">演示不同登录员工的权限;正式产品由账户授权决定。</p></section>
+      <h2>页面目录</h2><nav aria-label="页面目录" id="page-nav"></nav>
+      <details open><summary>情景演示</summary><div id="scenario-nav"></div></details>
+      <button type="button" data-action="reset">重置全部演示</button>
+      <p class="muted">顶部切换产品入口。员工角色与情景切换只用于评审。</p>
+    </aside>
+    <main id="main" tabindex="-1">
+      <div class="page-heading"><div><p id="page-code"></p><h1 id="page-title"></h1></div><span id="screen-scope"></span></div>
+      <div id="notice" role="status" aria-live="polite"></div>
+      <section id="product" aria-label="产品页面"></section>
+      <details class="annotation"><summary>本页交互说明</summary><div id="notes"></div></details>
+    </main>
+  </div>
+  <dialog id="confirm-dialog" aria-labelledby="dialog-title">
+    <form method="dialog"><h2 id="dialog-title"></h2><p id="dialog-body"></p><div class="actions"><button value="cancel">返回检查</button><button id="dialog-confirm" value="confirm" class="primary">确认操作</button></div></form>
+  </dialog>
+  <noscript>请启用 JavaScript 以浏览交互线稿。</noscript>
+</body>
+</html>

Datei-Diff unterdrückt, da er zu groß ist
+ 23 - 0
docs/design/wireframes/management.js


+ 30 - 0
docs/design/wireframes/notifications.js

@@ -0,0 +1,30 @@
+// 通知归属于当前员工;只保存演示内容。正式实现需在服务端检查接收人与业务权限。
+const notificationItems = [
+  { id: 'admin-knowledge', recipient: 'merchant', kind: '待办', title: '知识资料等待发布', summary: '《键盘售后说明》草稿已更新,请检查正文和发布范围。', page: 'knowledge', action: '查看知识资料', time: '今天 09:40' },
+  { id: 'admin-budget', recipient: 'merchant', kind: '系统', title: '模型预算配置提醒', summary: '请检查当前预算与费用申请。费用审批需要独立授权。', page: 'billing', action: '查看模型费用', time: '今天 09:30' },
+  { id: 'admin-channel', recipient: 'merchant', kind: '异常', title: '渠道接入检查待完成', summary: '售后渠道应用的结果回调需要检查配置与接入记录。', page: 'channels', action: '检查渠道配置', time: '今天 09:20' },
+  { id: 'staff-handoff', recipient: 'staff', kind: '待办', title: '客户 A01 请求人工协助', summary: '配件店 H5 会话有待接待请求,请查看最新会话状态。', page: 'conversation', action: '查看会话', time: '今天 09:40' },
+  { id: 'staff-message', recipient: 'staff', kind: '客户消息', title: '客户 A01 有新消息', summary: '当前授权店铺的客户补充了咨询信息。', page: 'conversation', action: '查看客户消息', time: '今天 09:35' },
+  { id: 'staff-ticket', recipient: 'staff', kind: '业务进度', title: '售后工单状态更新', summary: '工单 SH-001 的处理状态已变化,请查看最新确认与履约记录。', page: 'ticket', action: '查看工单', time: '今天 09:25' },
+  { id: 'supervisor-review', recipient: 'supervisor', kind: '待办', title: '售后审批待处理', summary: '请核对客户确认记录和当前方案版本后处理审批。', page: 'approvals', action: '查看审批待办', time: '今天 09:40' },
+];
+const notificationRead = new Set();
+let notificationFilter = '全部';
+let selectedNotification = null;
+function employeeNotifications() { return notificationItems.filter(item => item.recipient === merchantRole); }
+function unreadCount() { return employeeNotifications().filter(item => !notificationRead.has(item.id)).length; }
+function markNotificationNew(id) { notificationRead.delete(id); }
+function notificationList() {
+  const items = employeeNotifications().filter(item => notificationFilter !== '未读' || !notificationRead.has(item.id));
+  return desktop(`<div class="toolbar">${['全部', '未读'].map(label => `<button data-action="notification-filter" data-filter="${label}" ${notificationFilter === label ? 'aria-current="page"' : ''}>${label}</button>`).join('')}<span>未读 ${unreadCount()} 条</span>${button('全部标为已读', 'notifications-read-all', unreadCount() ? '' : 'disabled')}</div>${items.length ? table(['状态', '类型', '通知', '时间', '操作'], items.map(item => [notificationRead.has(item.id) ? '已读' : '未读', item.kind, `<strong>${item.title}</strong><p>${item.summary}</p>`, item.time, `<button data-action="notification-open" data-id="${item.id}">查看详情</button>`])) : '<div class="empty">暂无未读通知。</div>'}<p class="muted">以上为通知设计示例。已读表示已查看,业务处理状态以对应页面为准。</p>`);
+}
+function notificationDetail() {
+  const item = employeeNotifications().find(entry => entry.id === selectedNotification);
+  if (!item) return desktop(`${hint('通知不存在或当前员工无权查看。')}${link('返回通知中心', 'notifications')}`);
+  return desktop(`${tag(item.kind)}${tag('已读')}<h2>${item.title}</h2><p class="muted">${item.time} · A 商家 / 配件店</p><p>${item.summary}</p>${hint('进入业务页面时重新检查当前权限和状态;通知不代表任务已经完成。')}${actions(`${link(item.action, item.page)}${link('返回通知中心', 'notifications')}`)}`);
+}
+for (const role of ['merchant', 'staff', 'supervisor']) {
+  menuGroups[role].push(['消息与通知', [['notifications', '通知中心']]]);
+  register(role, 'notifications', 'N01', '通知中心', notificationList, ['仅展示当前员工接收且有权限查看的通知,切换角色后未读数量分别计算。', '列表区分已读、未读和通知类型;查看详情标记已读,全部已读需要确认。', '通知中的业务入口重新检查权限,已读不自动接管、审批、发布或完成工单。']);
+  register(role, 'notification-detail', 'N02', '通知详情', notificationDetail, ['客户消息摘要默认不显示敏感正文,进入授权会话后查看。', '已失效或无权访问的通知提供提示,不能通过通知扩大权限。']);
+}

+ 42 - 0
docs/design/wireframes/product-layout.js

@@ -0,0 +1,42 @@
+/* 产品导航体现正常商城和管理系统结构;学习范围之外的栏目只展示功能范围说明。 */
+const roleHomes = { customer: 'home', staff: 'queue', supervisor: 'approvals', merchant: 'overview', platform: 'dashboard' };
+const menuGroups = {
+  staff: [['客户服务', [['queue', '客服接待'], ['ticket', '售后工单'], ['customers', '客户管理']]], ['服务资源', [['reference', '知识中心'], ['quick-replies', '快捷回复'], ['statistics', '服务统计']]]],
+  supervisor: [['服务管理', [['approvals', '审批待办'], ['history', '售后工单'], ['team', '客服团队'], ['statistics', '服务统计']]]],
+  merchant: [['经营管理', [['overview', '经营概览'], ['products', '商品管理'], ['orders', '订单管理'], ['after-sales', '售后管理'], ['customers', '客户管理'], ['marketing', '营销中心']]], ['智能服务', [['service', '客服管理'], ['knowledge', '知识中心'], ['rules', '售后规则'], ['release', 'AI 配置'], ['billing', '模型费用']]], ['店铺设置', [['access', '店铺与员工'], ['channels', '渠道接入']]]],
+  platform: [['平台运营', [['dashboard', '平台概览'], ['tenants', '商家管理'], ['applications', '应用接入']]], ['资源与运行', [['capacity', '配额管理'], ['models', '模型服务'], ['deliveries', '消息投递'], ['health', '运行监控']]], ['系统管理', [['audit', '操作审计'], ['permissions', '权限管理'], ['settings', '系统设置']]]],
+};
+function menuItem(page, label) {
+  const selected = currentPage === page || currentRole === 'staff' && ['conversation', 'draft'].includes(currentPage) && page === 'queue' || currentRole === 'supervisor' && currentPage === 'review' && page === 'approvals';
+  return `<button type="button" data-page="${page}" data-role="${currentRole}" ${selected ? 'aria-current="page"' : ''}>${label}</button>`;
+}
+function adminShell(content) {
+  const title = screens[`${currentRole}/${currentPage}`]?.title || '工作台';
+  const person = { staff: '王芳 · 客服', supervisor: '李明 · 主管', merchant: '商家管理员', platform: '平台运营' }[currentRole];
+  return `<div class="admin-shell"><aside class="admin-sidebar"><div class="admin-brand">智服中台<small>${roleLabels[currentRole]}</small></div><nav aria-label="系统导航">${menuGroups[currentRole].map(([group, items]) => `<div class="nav-group"><p>${group}</p>${items.map(([page, label]) => menuItem(page, label)).join('')}</div>`).join('')}</nav></aside><div class="admin-main"><header class="admin-topbar"><strong>${currentRole === 'platform' ? '平台运营中心' : 'A 商家 / 配件店'}</strong><div>${currentRole === 'staff' ? '<span class="tag">在线接待</span>' : ''}<span>${person}</span></div></header><div class="admin-breadcrumb">${roleLabels[currentRole]} / ${title}</div><div class="admin-tabs"><span>${title}</span></div><div class="admin-content"><div class="admin-title"><h2>${title}</h2><span>当前范围:${currentRole === 'platform' ? '已授权的平台运行信息' : '配件店'}</span></div><div class="surface">${content}</div></div></div></div>`;
+}
+const productCard = (name, type = '键盘') => `<button class="goods-card" type="button" data-page="product" data-role="customer" data-category="${type}"><div class="goods-image">${type}<small>商品图片</small></div><div><strong>${name}</strong><p>日常办公 · 数码配件</p><span>查看商品</span></div></button>`;
+const shortcut = (label, page) => `<button type="button" class="shortcut" data-page="${page}" data-role="customer"><span class="shortcut-icon" aria-hidden="true"></span>${label}</button>`;
+register('customer', 'home', 'H11', '商城首页', () => mobile('首页', `<button class="shop-search" type="button" data-page="categories" data-role="customer">搜索商品、品牌、配件</button><div class="shop-banner"><small>A 商家 · 配件店</small><h2>数码配件专区</h2><p>键盘 / 鼠标 / 桌面配件</p>${link('进入商品分类', 'categories')}</div><div class="shortcut-grid">${shortcut('键盘', 'categories')}${shortcut('鼠标', 'categories')}${shortcut('配件', 'categories')}${shortcut('全部订单', 'orders')}</div><button class="ai-entry" type="button" data-page="chat" data-role="customer"><strong>AI 购物助手</strong><span>商品咨询、查订单、办售后</span><span>开始咨询 ›</span></button><div class="section-heading"><h2>店铺商品</h2>${link('查看全部', 'categories')}</div><div class="goods-grid">${productCard('办公键盘')}${productCard('无线鼠标', '鼠标')}</div>`), ['首页保留搜索、分类、活动区域和商品列表的购物信息结构。', 'AI 提供独立服务入口,商品与订单也可以携带上下文进入咨询。', '商品浏览为场景页面,交易、支付与营销不在本项目学习业务范围。']);
+register('customer', 'categories', 'H12', '商品分类', () => mobile('分类', `<button class="shop-search" type="button" data-action="catalog-search">搜索商品</button><div class="catalog"><nav aria-label="商品分类"><button data-action="category" data-category="键盘">键盘</button><button data-action="category" data-category="鼠标">鼠标</button><button data-action="category" data-category="桌面配件">桌面配件</button></nav><section><h2>${escapeHtml(state.category || '键盘')}</h2>${productCard(state.category === '鼠标' ? '无线鼠标' : '办公键盘', state.category || '键盘')}</section></div>`), ['分类导航与商品区域组成普通商城分类页面。', '商品咨询通过商品详情进入 AI,并显示当前咨询商品。']);
+register('customer', 'product', 'H13', '商品详情', () => mobile('商品详情', `<div class="goods-image product-hero">${escapeHtml(state.category || '键盘')}<small>商品主图位置</small></div><h2>${state.category === '鼠标' ? '无线鼠标' : '办公键盘'}</h2><p>商品规格 · 产品介绍 · 使用说明</p><div class="product-section">选择规格 <span>标准款 ›</span></div><div class="product-section">配送服务 <span>店铺配送说明 ›</span></div><div class="product-section">店铺 <span>A 商家 · 配件店</span></div>${box('商品介绍', '<p>商品介绍正文与规格参数位置。</p>')}<div class="purchase-bar">${link('客服咨询', 'chat')}${button('加入购物车', 'commerce-scope')}${button('立即购买', 'commerce-scope', 'class="primary"')}</div>`), ['商品页保留图片、标题、规格、配送与购买操作位置。', '交易操作说明本项目范围,客服咨询与已购订单售后可继续演示。']);
+register('customer', 'cart', 'H14', '购物车', () => mobile('购物车', `<div class="section-heading"><span>A 商家 · 配件店</span><span>管理</span></div><div class="empty"><h2>购物车暂时没有商品</h2><p>可以浏览店铺商品,或咨询购物助手。</p>${actions(`${link('去逛逛', 'categories')}${link('咨询 AI', 'chat')}`)}</div><div class="cart-total"><span>已选 0 件 · 合计 ¥0.00</span><button disabled>结算</button></div>`), ['保留正常商城购物车的位置和空状态。', '购物车计算与结算属于交易业务,当前学习范围提供已购订单和售后。']);
+register('customer', 'profile', 'H15', '个人中心', () => mobile('我的', `<div class="profile-card"><div class="avatar-placeholder">头像</div><div><h2>${state.loggedIn ? 'A01' : '登录 / 注册'}</h2><p>${state.loggedIn ? '欢迎来到智选商城' : '登录后查看订单和售后进度'}</p>${!state.loggedIn ? link('立即登录', 'login') : ''}</div></div><div class="section-heading"><h2>我的订单</h2>${link('全部订单 ›', 'orders')}</div><div class="order-shortcuts">${['待付款', '待发货', '待收货', '已完成'].map(label => `<button data-action="order-filter" data-filter="${label}"><span class="shortcut-icon"></span>${label}</button>`).join('')}${shortcut('退款 / 售后', 'ticket')}</div><button class="ai-entry" data-page="chat" data-role="customer"><strong>AI 客服</strong><span>查订单、了解规则、申请换货</span><span>进入会话 ›</span></button><div class="account-list">${link('消息中心 ›', 'messages')}${link('收货地址 ›', 'address')}${link('我的收藏 ›', 'favorites')}${link('沟通习惯 ›', 'memory')}${link('账户与渠道身份 ›', 'identity')}${link('设置 ›', 'settings')}</div>`), ['个人中心集中订单状态、售后、账户和客户服务入口。', '沟通习惯属于 AI 服务设置,放在个人中心的功能列表内。']);
+register('customer', 'messages', 'H16', '消息中心', () => mobile('消息中心', `<div class="account-list">${link('AI 客服 · 继续咨询 ›', 'chat')}${link(`售后通知 · ${statusLabels[state.ticket]} ›`, 'ticket')}</div><div class="empty">店铺通知<br><span class="muted">暂无其他消息</span></div>`), ['消息中心区分客服会话、售后通知与店铺通知。']);
+for (const [page, code, title] of [['address', 'H17', '收货地址'], ['favorites', 'H18', '我的收藏'], ['settings', 'H19', '账户设置']]) {
+  register('customer', page, code, title, () => mobile(title, `${box(title, '<p>该栏目保留商城标准功能位置。本项目提供订单查询、AI 咨询和换货服务。</p>')}${actions(`${link('返回个人中心', 'profile')}${link('进入 AI 客服', 'chat')}`)}`), ['此页面用于确认产品导航位置,相关业务不在当前学习范围。']);
+}
+
+register('platform', 'dashboard', 'P06', '平台概览', () => desktop(`<div class="metric-grid">${['接入商家', '运行应用', '等待任务', '异常告警'].map(label => `<section class="metric"><span>${label}</span><strong>—</strong><small>等待运行数据</small></section>`).join('')}</div><div class="grid">${box('平台运行趋势', '<div class="chart-placeholder">请求量 / 任务成功率 / 资源使用趋势</div>')}${box('待处理事项', `<div class="account-list">${link('商家接入申请 ›', 'tenants')}${link('异常投递 ›', 'deliveries')}${link('服务告警 ›', 'health')}</div>`)}</div>${box('常用管理', actions(`${link('商家管理', 'tenants')}${link('配额管理', 'capacity')}${link('操作审计', 'audit')}`))}`), ['平台首页围绕接入、资源、运行和治理,业务正文权限独立。']);
+
+const auxiliaryPages = {
+  staff: [['customers', '客户管理', '授权店铺客户、会话与售后记录'], ['reference', '知识中心', '服务规则、资料版本与引用'], ['quick-replies', '快捷回复', '客服常用回复管理'], ['statistics', '服务统计', '接待量、响应时间与处理情况']],
+  supervisor: [['team', '客服团队', '团队接待与工作分配'], ['statistics', '服务统计', '审批处理与服务质量']],
+  merchant: [['products', '商品管理', '商品列表、分类与库存'], ['orders', '订单管理', '订单查询、交易与履约状态'], ['after-sales', '售后管理', '换货工单、审批与履约'], ['customers', '客户管理', '本商家客户与服务记录'], ['marketing', '营销中心', '店铺活动与优惠'], ['service', '客服管理', '客服人员、会话与智能服务']],
+  platform: [['applications', '应用接入', '应用、渠道与授权范围'], ['models', '模型服务', '模型配置、供应商与运行状态'], ['permissions', '权限管理', '平台人员与管理权限'], ['settings', '系统设置', '通知渠道与系统运行配置']],
+};
+for (const [role, pages] of Object.entries(auxiliaryPages)) {
+  for (const [page, title, description] of pages) {
+    register(role, page, `${role.toUpperCase()}-${page}`, title, () => desktop(`${box(title, `<p>${description}</p>`)}${role === 'merchant' && page === 'after-sales' ? table(['工单', '状态', '处理入口'], [['SH-001', statusLabels[state.ticket], link('处理售后工单', 'ticket', 'staff')]]) : role === 'merchant' && page === 'orders' ? table(['订单', '商品', '状态'], [['A-20260918-001', '键盘 × 1', '已签收']]) : `<div class="empty">当前栏目提供系统结构预览。<p>本项目学习业务集中于 AI 咨询、订单查询、换货、知识和平台治理。</p></div>`}${role === 'merchant' && page === 'service' ? `<p>员工需要会话接管与回复权限才可以接待客户。</p>${actions(link('接待客户', 'queue', 'staff'))}` : ''}`), ['栏目采用正常管理系统的信息结构,菜单与操作受当前员工权限限制。']);
+  }
+}

+ 61 - 0
docs/design/wireframes/shared.js

@@ -0,0 +1,61 @@
+/* 评审数据只存在页面内存中;角色切换用于检查同一业务的交互,不提供实际授权。 */
+const initialState = () => ({
+  loggedIn: false, ticket: 'NONE', version: 1, reason: '键盘空格键无法使用', quantity: 1,
+  owner: 'AGENT', humanRequested: false, connected: true, blocked: false, empty: false, replyActive: false,
+  stale: false, expired: false, selfApproval: false, customerConfirmed: false,
+  memoryConsent: false, memorySaved: false, memoryStyle: '简洁回复', memoryLanguage: '中文',
+  linked: false, identityVerified: false, ruleVersion: 1, ruleActive: true, ruleSaved: false,
+  proposalRuleVersion: 1, ruleDays: 7, proposalRuleDays: 7,
+  knowledge: 'DRAFT', knowledgeTitle: '键盘售后说明', knowledgeVersion: 1,
+  staffEnabled: true, storeEnabled: true, channelEnabled: true, tenantEnabled: true,
+  delivery: 'FAILED', budgetRequest: 'NONE', budget: 100, pendingBudget: 100,
+  messages: [], audit: [], lastAction: '', release: '草稿', formValues: {},
+  invites: [], tenantRegistrations: [], billingReviewer: false, returnPage: 'home', orderFilter: '全部', aiContext: '',
+});
+let state = initialState();
+let currentRole = 'customer';
+let merchantRole = 'merchant';
+const adminAuth = { merchant: false, platform: false };
+const adminReturn = { merchant: null, platform: null };
+let currentPage = 'home';
+let notice = '';
+let pendingConfirmation = null;
+const screens = {};
+const roleLabels = { customer: '购物商城 H5', staff: '商家后台', supervisor: '商家后台', merchant: '商家后台', platform: '平台后台' };
+const statusLabels = { NONE: '尚未申请', DRAFT: '待客户确认', WAITING_REVIEW: '等待主管审批', APPROVED: '审批通过,准备提交', PROCESSING: '换货处理中', RESULT_UNKNOWN: '正在核实受理结果', COMPLETED: '换货完成', REJECTED: '审批未通过', NEEDS_REVISION: '需要修订方案', CANCELLED: '申请已取消', FAILED: '换货申请失败' };
+const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
+const button = (label, action, extra = '') => `<button type="button" data-action="${action}" ${extra}>${label}</button>`;
+const link = (label, page, role = currentRole) => {
+  const restricted = ['merchant', 'staff', 'supervisor'].includes(role) && role !== merchantRole;
+  return `<button type="button" data-page="${page}" data-role="${role}" ${restricted ? 'disabled title="当前员工未获该功能权限"' : ''}>${label}</button>`;
+};
+const tag = text => `<span class="tag">${escapeHtml(text)}</span>`;
+const hint = text => `<div class="hint">${text}</div>`;
+const box = (title, content) => `<section class="box"><h3>${title}</h3>${content}</section>`;
+const table = (headers, rows) => `<div class="table-wrap"><table><thead><tr>${headers.map(h => `<th scope="col">${h}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(cell => `<td>${cell}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`;
+const field = (label, name, value = '', type = 'text', extra = '') => `<label>${label}<input name="${name}" type="${type}" value="${escapeHtml(value)}" ${extra}></label>`;
+const area = (label, name, value = '', extra = '') => `<label>${label}<textarea name="${name}" ${extra}>${escapeHtml(value)}</textarea></label>`;
+const select = (label, name, options, value) => `<label>${label}<select name="${name}">${options.map(option => `<option${option === value ? ' selected' : ''}>${escapeHtml(option)}</option>`).join('')}</select></label>`;
+const actions = content => `<div class="actions">${content}</div>`;
+const form = (action, content, label = '保存', extra = '') => `<form data-submit="${action}">${content}<button class="primary" type="submit" ${extra}>${label}</button></form>`;
+const consent = (name, label) => `<label class="check"><input type="checkbox" name="${name}" required><span>${label}</span></label>`;
+const canCancel = () => ['DRAFT', 'WAITING_REVIEW', 'APPROVED'].includes(state.ticket);
+const canRevise = () => ['DRAFT', 'WAITING_REVIEW', 'NEEDS_REVISION'].includes(state.ticket);
+const canCreate = () => ['NONE', 'CANCELLED', 'REJECTED', 'FAILED'].includes(state.ticket);
+function register(role, page, code, title, render, notes) { screens[`${role}/${page}`] = { role, page, code, title, render, notes }; }
+function mobile(title, content) {
+  const root = ['home', 'categories', 'cart', 'profile'].includes(currentPage);
+  const tabs = [['home', '首页'], ['categories', '分类'], ['cart', '购物车'], ['profile', '我的']];
+  return `<div class="surface mobile"><div class="device-status"><span>9:41</span><span>网络 电量</span></div><header class="mobile-header">${root ? '<strong>智选商城</strong>' : link('返回', currentPage === 'chat' ? 'home' : 'profile', 'customer')}<strong>${title}</strong>${link(currentPage === 'chat' ? '我的订单' : '消息', currentPage === 'chat' ? 'orders' : 'messages', 'customer')}</header><div class="content">${content}</div>${root ? `<footer class="shop-tabs">${tabs.map(([page, label]) => `<button type="button" data-page="${page}" data-role="customer" ${page === currentPage ? 'aria-current="page"' : ''}>${label}</button>`).join('')}</footer>` : ''}${currentPage !== 'chat' && currentPage !== 'login' ? '<button type="button" class="ai-float" data-page="chat" data-role="customer"><strong>AI</strong><span>购物助手</span></button>' : ''}</div>`;
+}
+function desktop(content) { return adminShell(content); }
+function orderSummary() { return `<p>订单 A-20260918-001</p><p>键盘 × 1 · 签收后第 2 天</p><p>可申请数量:1</p>`; }
+function proposalSummary() { return `${orderSummary()}<p>申请数量:${state.quantity}</p><p>故障描述:${escapeHtml(state.reason)}</p><p>方案版本:V${state.version}</p><p>依据:《键盘换货规则》V${state.proposalRuleVersion},签收后 ${state.proposalRuleDays} 天内功能故障可申请,需客户确认及主管审批。</p>`; }
+function ticketSummary() { return `${tag(statusLabels[state.ticket])}<p>工单 SH-001 · 当前方案 V${state.version}</p>${proposalSummary()}`; }
+function record(text) { state.audit.unshift(text); state.lastAction = text; }
+function confirmAction(title, body, callback) {
+  pendingConfirmation = callback;
+  document.getElementById('dialog-title').textContent = title;
+  document.getElementById('dialog-body').textContent = body;
+  document.getElementById('confirm-dialog').showModal();
+}

+ 40 - 0
docs/design/wireframes/style-preview/README.md

@@ -0,0 +1,40 @@
+# 已确认视觉风格规范
+
+状态:用户已于 2026-09-20 确认后台与消费者 H5 两套视觉风格。后续前端开发必须严格遵循本规范、对应样式及已确认交互线稿,项目根目录 `AGENTS.md` 中的前端设计与实现规则具有强制性。
+
+预览入口:`style-preview/index.html`。通过顶部切换消费者 H5、商家后台和平台后台;评审工具提供员工角色与页面目录。全部页面复用已确认线稿的交互逻辑,示例数据与登录仅用于原型演示。
+
+## 后台
+
+参考 https://shuhe-ai.com 首页的页面内容与 computed styles,采用海军蓝导航、蓝色主操作、白色内容和浅灰蓝工作区域。商家和平台使用同一套组件;管理员、客服和主管根据已有权限显示功能。
+
+| 项目 | 规范 |
+| --- | --- |
+| 导航 | #071126,文字 #D8E1FF |
+| 主操作 | #2F6BFF,白色文字;悬停 #2057DF |
+| 工作区域 / 卡片 | #F8FAFC / #FFFFFF |
+| 文字 / 辅助文字 | #111827 / #667085 |
+| 边框 | #E6EBF3 |
+| 字体 | Inter、Noto Sans SC、Microsoft YaHei、Arial、sans-serif |
+| 字号 | 正文 14px,表格 13px,页面标题 22px |
+| 圆角 | 控件 8px,卡片 12px |
+
+登录页使用参考网站的海军蓝渐变背景与白色表单。系统侧栏保留分组导航,当前栏目使用蓝色。表格采用浅色表头与行分隔线,表单、对话框、通知和客服会话共用基础样式。
+
+## 消费者 H5
+
+采用白色卡片、浅蓝活动区域、浅灰蓝页面背景。主操作为 #245FE5;AI 入口使用浅蓝卡片与蓝色文字,悬浮入口采用蓝色。保留首页、分类、购物车、我的,以及商品和订单中的 AI 咨询入口。
+
+正文 14px,页面标题 18px,卡片圆角 16px;按钮触控区域至少 44px。电脑端以最大宽度 414px 的手机容器展示,手机端使用整页宽度。商品图片保留位置标识,数据不替换为未经验证的经营指标。
+
+H02 的登录卡片、输入框、密码显隐、错误提示和短屏滚动遵循 [H5 登录页展示规范](../h5-login-presentation.md)。
+
+## 交互状态
+
+会话与列表的正文预览、详情及高度限制见 [会话与列表展示规范](../conversation-list-presentation.md),继续使用本规范的配色、字体和组件状态。
+
+按钮、输入框提供 hover、focus-visible、disabled 样式。状态继续显示文字标签,通知继续显示已读与未读文字。两套风格的页面与权限流程由已有原型脚本统一控制。
+
+## 验证范围
+
+使用 `WIREFRAME_URL=http://127.0.0.1:18761/style-preview/` 运行 `scripts/check_wireframes.cjs`,检查登录、角色、通知和售后交互,以及 1440px 与 390px 下的全部页面宽度。此项验证仅覆盖浏览器原型,不代表业务接口验收。

+ 51 - 0
docs/design/wireframes/style-preview/index.html

@@ -0,0 +1,51 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <base href="../">
+  <title>智服 · 已确认视觉风格</title>
+  <link rel="stylesheet" href="wireframes.css">
+  <link rel="stylesheet" href="style-preview/style.css">
+  <!-- 复用已确认的页面与交互,视觉提案独立于线稿入口。 -->
+  <script defer src="shared.js"></script>
+  <script defer src="customer.js"></script>
+  <script defer src="workspace.js"></script>
+  <script defer src="management.js"></script>
+  <script defer src="product-layout.js"></script>
+  <script defer src="admin-login.js"></script>
+  <script defer src="notifications.js"></script>
+  <script defer src="app.js"></script>
+</head>
+<body>
+  <header class="review-header">
+    <div><strong>智服 · 视觉规范</strong><span>已确认 · 示例内容</span><button type="button" id="review-toggle" data-action="review-toggle">评审工具</button></div>
+    <nav aria-label="评审入口" id="role-nav"></nav>
+  </header>
+  <details class="style-guide">
+    <summary>设计说明 · 后台 / 消费者 H5</summary>
+    <div class="style-guide-grid">
+      <section><span class="style-label">01 / 管理系统</span><h2>海军蓝导航 · 蓝色操作 · 浅色内容</h2><p>商家与平台共用视觉规范。深色侧栏区分系统导航,白色内容区承载表格、表单、消息与客服会话。</p><div class="swatches"><span style="--swatch:#071126">导航 #071126</span><span style="--swatch:#2f6bff">品牌 #2F6BFF</span><span style="--swatch:#f8fafc">背景 #F8FAFC</span></div><p>正文 14px / 页面标题 22px / 控件圆角 8px / 内容卡片 12px</p></section>
+      <section><span class="style-label">02 / 消费者 H5</span><h2>明亮商城 · 清晰卡片 · AI 服务入口</h2><p>白色与浅蓝色组成购物界面,保留首页、分类、购物车、我的。AI 使用蓝色标识,商品与订单提供场景入口。</p><div class="swatches"><span style="--swatch:#ffffff">卡片 #FFFFFF</span><span style="--swatch:#edf4ff">浅蓝 #EDF4FF</span><span style="--swatch:#245fe5">操作 #245FE5</span></div><p>正文 14px / 页面标题 18px / 卡片圆角 16px / 触控区域至少 44px</p></section>
+    </div>
+    <p>通过顶部切换产品,使用评审工具查看不同员工角色。后台登录使用任意非空演示账户与密码。商品图片继续保留位置标识。</p>
+  </details>
+  <div class="review-layout">
+    <aside class="review-sidebar" hidden>
+      <section id="merchant-role-controls"><h2>商家员工角色</h2><div id="merchant-role-nav"></div><p class="muted">演示不同登录员工的权限;正式产品由账户授权决定。</p></section>
+      <h2>页面目录</h2><nav aria-label="页面目录" id="page-nav"></nav>
+      <details open><summary>情景演示</summary><div id="scenario-nav"></div></details>
+      <button type="button" data-action="reset">重置全部演示</button>
+      <p class="muted">员工角色与情景切换仅用于评审。</p>
+    </aside>
+    <main id="main" tabindex="-1">
+      <div class="page-heading"><div><p id="page-code"></p><h1 id="page-title"></h1></div><span id="screen-scope"></span></div>
+      <div id="notice" role="status" aria-live="polite"></div>
+      <section id="product" aria-label="产品页面"></section>
+      <details class="annotation"><summary>本页交互说明</summary><div id="notes"></div></details>
+    </main>
+  </div>
+  <dialog id="confirm-dialog" aria-labelledby="dialog-title"><form method="dialog"><h2 id="dialog-title"></h2><p id="dialog-body"></p><div class="actions"><button value="cancel">返回检查</button><button id="dialog-confirm" value="confirm" class="primary">确认操作</button></div></form></dialog>
+  <noscript>请启用 JavaScript 以浏览设计提案。</noscript>
+</body>
+</html>

+ 60 - 0
docs/design/wireframes/style-preview/style.css

@@ -0,0 +1,60 @@
+/* 参考站点的品牌色用于两个后台;H5 共用蓝色品牌,并采用明亮的购物界面。 */
+:root { font-family: Inter,"Noto Sans SC","Microsoft YaHei",Arial,sans-serif; color:#111827; background:#f8fafc; --primary:#2f6bff; --line:#e6ebf3; --muted:#667085; }
+button,.button,input,select,textarea { border-color:#d5ddeb; border-radius:8px; }
+button,.button { transition:background .15s,border-color .15s; }
+button:hover,.button:hover { background:#edf4ff; border-color:#a9c2ff; }
+button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible { outline:3px solid #78a2ff; outline-offset:3px; }
+.primary,button[aria-current="page"] { background:var(--primary); border-color:var(--primary); color:white; }
+.primary { background:linear-gradient(135deg,#3678ff,#2f6bff); box-shadow:0 3px 8px #2f6bff18; }
+.primary:hover { background:#2057df; color:white; }
+button:disabled { background:#f2f4f7; border-color:#e4e7ec; color:#98a2b3; box-shadow:none; }
+a { color:#245fe5; }.muted,.message small { color:var(--muted); }
+input,select,textarea { padding:10px 12px; }input::placeholder,textarea::placeholder { color:#8893a5; }
+input:focus,select:focus,textarea:focus { border-color:var(--primary); }
+h1,h2,h3,strong { font-weight:650; } h2 { letter-spacing:-.3px; }
+.review-header { background:#fff; border-color:var(--line); padding:10px 20px; }.review-header strong { color:#243959; }.review-header span { color:var(--muted); }
+.style-guide { padding:10px 20px; background:#f0f5ff; border-bottom:1px solid #dce6f8; font-size:12px; color:#43516b; }
+.style-guide summary { font-weight:600; color:#245fe5; }.style-guide-grid { display:grid; grid-template-columns:1fr 1fr; gap:32px; padding:24px 0 12px; }.style-guide h2 { margin-top:8px; color:#172b4d; }.style-label { font-size:11px; letter-spacing:1px; }
+.swatches { display:flex; flex-wrap:wrap; gap:16px; }.swatches span { display:flex; align-items:center; gap:7px; }.swatches span::before { content:""; display:block; width:22px; height:22px; border-radius:6px; background:var(--swatch); border:1px solid #d5ddeb; }
+.review-sidebar { top:65px; border-color:var(--line); border-radius:12px; box-shadow:0 16px 48px #07112626; }.review-sidebar details { border-color:var(--line); }
+.annotation { border-color:#d5ddeb; color:var(--muted); padding:16px; font-size:12px; }
+.box,.surface { border-color:var(--line); border-radius:12px; }.box { padding:18px; }
+.hint { border:1px solid #dce7fb; background:#f3f7ff; border-radius:8px; color:#435777; }
+.tag { background:#edf4ff; color:#2452aa; border:1px solid #d8e5ff; border-radius:5px; padding:2px 8px; }
+.placeholder,.empty { border-color:#d5ddeb; background:#f8fafc; border-radius:12px; color:var(--muted); }
+th,td { border:0; border-bottom:1px solid var(--line); padding:13px 12px; }th { background:#f8fafc; color:#667085; font-weight:600; }tbody tr:hover { background:#f9fbff; }
+table { margin:14px 0; }.table-wrap { border:1px solid var(--line); border-radius:10px; }
+.message { border-color:var(--line); border-radius:12px 12px 12px 3px; background:#fff; }.message.customer { background:#edf4ff; border-color:#dbe8ff; border-radius:12px 12px 3px 12px; }
+.steps span { border-color:#b4cbff; color:#245fe5; }
+dialog { border:1px solid var(--line); border-radius:16px; box-shadow:0 24px 80px #07112633; }dialog::backdrop { background:#07112666; }
+#notice:not(:empty) { border:1px solid #cbdcff; background:#edf4ff; color:#204eaa; margin:0; }
+/* 登录、导航和数据页面使用同一组后台组件。角色权限继续由原有交互决定。 */
+.login-layout { background:radial-gradient(ellipse at 10% 10%,#204b9544,transparent 65%),linear-gradient(120deg,#020712,#071126 54%,#0b1835); min-height:calc(100vh - 100px); gap:100px; }
+.login-intro { color:#d8e1ff; max-width:380px; }.login-intro h1 { color:white; font-size:40px; letter-spacing:2px; }.login-intro h2 { color:white; font-size:24px; }.login-intro p { color:#afbcdd; font-size:15px; }
+.login-panel { padding:32px; width:420px; border:1px solid #e6ebf3; box-shadow:0 24px 70px #02071255; }.login-panel h2 { font-size:23px; }.login-panel > p { color:var(--muted); }.login-panel form { margin-top:24px; }.login-panel form > button { min-height:44px; }.login-panel .hint { margin-top:20px; font-size:12px; }
+.admin-session { border-color:var(--line); padding:8px 24px; color:#667085; }.admin-session button { font-size:12px; }
+.admin-shell { background:#f8fafc; grid-template-columns:216px minmax(0,1fr); }
+.admin-sidebar { background:#071126; color:#d8e1ff; }.admin-brand { border-color:#ffffff12; padding:24px; color:white; font-size:21px; letter-spacing:1px; }.admin-brand small { color:#8fa5cc; letter-spacing:0; margin-top:4px; }
+.nav-group { margin:22px 12px; }.nav-group p { color:#91a1bd; padding:0 12px 8px; }.nav-group button { border-radius:7px; padding:10px 12px; color:#c7d3eb; margin-bottom:4px; }.nav-group button:hover { background:#182a49; }.nav-group button[aria-current="page"] { background:#2f6bff; border:0; padding-left:12px; box-shadow:0 4px 12px #0002; color:white; }
+.admin-topbar { height:64px; border-color:var(--line); }.admin-breadcrumb { color:#748096; }.admin-tabs { border-color:var(--line); }.admin-tabs span { background:#edf4ff; color:#245fe5; border-color:#dce7ff; border-radius:6px; }
+.admin-title h2 { font-size:22px; }.admin-title > span { color:#748096; }.admin-content > .surface { border-color:var(--line); box-shadow:0 4px 18px #15294d04; padding:24px; }
+.metric { border-color:var(--line); background:linear-gradient(150deg,#fff,#f8faff); border-radius:10px; padding:20px; }.metric > span { color:#667085; font-size:13px; }.metric strong { font-size:30px; color:#172b4d; font-variant-numeric:tabular-nums; }.metric small { color:#748096; }
+.chart-placeholder { border-color:var(--line); border-radius:8px; color:#748096; background:repeating-linear-gradient(0deg,#fff,#fff 33px,#edf1f7 34px); }
+.workspace > section { border-color:var(--line); }.workspace > section:first-child,.workspace > section:last-child { background:#f8fafc; }.workspace > section:nth-child(2) > form { border-color:var(--line); }
+.session-row { border-color:var(--line); border-radius:6px; }.session-row.active { background:#edf4ff; border-left:3px solid var(--primary); }.session-row small { color:#667085; }
+/* H5 使用明亮背景和较大的触控范围,AI 与购物操作共享品牌色。 */
+.mobile { border:1px solid #dce5f1; border-radius:24px; overflow:hidden; box-shadow:0 18px 60px #1b376214; margin:28px auto; --primary:#245fe5; }
+.device-status { background:white; color:#344054; padding:12px 20px 5px; }.mobile .mobile-header { border-color:#edf1f7; padding:8px 16px; background:white; }.mobile .mobile-header strong { font-size:18px; }.mobile .mobile-header button { min-height:44px; color:#43516b; }
+.mobile .content { background:#f6f8fc; padding:16px 16px 80px; }.mobile button,.mobile .button { min-height:44px; }.mobile .box { border-radius:16px; padding:16px; }
+.mobile .shop-tabs { border-color:#edf1f7; padding:10px 8px; box-shadow:0 -4px 18px #17346105; }.shop-tabs button { color:#667085; border-radius:12px; }.shop-tabs button[aria-current="page"] { background:#edf4ff; color:#245fe5; font-weight:650; }
+.shop-search { border:1px solid #e3eaf5; border-radius:24px; background:white; color:#748096; padding:11px 16px; }
+.shop-banner { background:linear-gradient(120deg,#e4edff,#eff7ff); border:1px solid #d9e7ff; border-radius:18px; padding:24px; color:#284778; }.shop-banner h2 { color:#152f5d; font-size:25px; }.shop-banner p { color:#526b92; }.shop-banner .button,.shop-banner button { background:white; border-color:#d1dfff; color:#245fe5; border-radius:22px; }
+.shortcut-icon { width:32px; height:32px; border:1px solid #d4e3ff; background:#e8f0ff; border-radius:10px; position:relative; }.shortcut-icon::after { content:""; position:absolute; inset:8px; border:2px solid #6089dc; border-radius:3px; }.shortcut:nth-child(2) .shortcut-icon::after { border-radius:9px; }.shortcut:nth-child(3) .shortcut-icon::after { transform:rotate(45deg); }
+.ai-entry { background:linear-gradient(120deg,#edf4ff,#fff); border:1px solid #c9dcff; border-radius:16px; padding:18px; box-shadow:0 4px 14px #2f6bff06; }.ai-entry strong { color:#245fe5; font-size:17px; }.ai-entry span { color:#4e6790; }.ai-entry:hover { background:#e8f1ff; }
+.ai-float { background:#245fe5; color:white; border:3px solid white; border-radius:22px; box-shadow:0 6px 18px #245fe533; padding:8px 3px; }.ai-float:hover { background:#174dc9; color:white; }
+.section-heading h2 { font-size:18px; }.section-heading button { color:#667085; }
+.goods-card { border:1px solid #e8edf5; border-radius:16px; overflow:hidden; background:white; }.goods-card > div:last-child { padding:14px 12px; }.goods-card strong { font-size:14px; }.goods-card span { color:#245fe5; }.goods-image { background:linear-gradient(135deg,#eef2f8,#e5ecf7); border:0; color:#657794; height:140px; gap:4px; }.goods-image small { color:#748096; }.product-hero { height:240px; border-radius:16px; }
+.catalog nav button { background:#eef3fb; color:#456089; border-color:#dde6f5; }.profile-card { padding:22px 8px; }.avatar-placeholder { background:#e4edff; border-color:#d1dfff; color:#5577b5; }.order-shortcuts { border-radius:16px; padding:14px 4px; }.account-list { border-radius:12px; overflow:hidden; }.account-list button { border-color:#edf1f7; }.order-card { border-color:#e6ebf3; border-radius:16px; }.product-section { border-color:#e6ebf3; }.cart-total,.purchase-bar { background:white; border-radius:12px; padding:12px; }
+@media(max-width:1150px) { .admin-shell { grid-template-columns:180px minmax(0,1fr); }.admin-content > .surface { padding:16px; }.login-layout { gap:40px; } }
+@media(max-width:760px) { .style-guide-grid { grid-template-columns:1fr; gap:16px; }.style-guide { padding:10px 12px; }.login-layout { gap:24px; padding:28px 16px; }.login-intro h1 { font-size:28px; }.login-panel { width:100%; padding:24px; }.admin-shell { grid-template-columns:110px minmax(0,1fr); }.admin-brand { padding:16px 8px; font-size:15px; }.nav-group { margin:16px 5px; }.nav-group p { padding:0 4px 8px; }.nav-group button,.nav-group button[aria-current="page"] { padding:10px 6px; }.admin-content > .surface { padding:8px; }.admin-title h2 { font-size:18px; }.admin-topbar { height:auto; }.mobile { margin:0 auto; border:0; border-radius:0; box-shadow:none; }.review-header { padding:8px; }.review-header > div { display:flex; align-items:center; gap:8px; }.annotation { margin-top:16px; }.metric { padding:14px; } }
+@media(prefers-reduced-motion:reduce) { button,.button { transition:none; } }

+ 123 - 0
docs/design/wireframes/wireframes.css

@@ -0,0 +1,123 @@
+* { box-sizing: border-box; }
+.login-layout { min-height: 75vh; display: flex; justify-content: center; align-items: center; gap: 70px; padding: 35px 20px; }
+.login-intro { max-width: 330px; }.login-intro h1 { font-size: 30px; margin-bottom: 25px; }
+.login-panel { width: 390px; min-height: auto !important; }.login-panel form > button { width: 100%; }
+.admin-session { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding: 6px 24px; background: #fff; border-bottom: 1px solid #ccc; font-size: 12px; }
+@media(max-width:760px) { .login-layout { flex-direction: column; gap: 20px; padding: 20px 12px; }.login-panel { width: 100%; }.login-intro h1 { margin-bottom: 8px; }.admin-session { flex-wrap: wrap; padding: 6px; } }
+:root { color-scheme: light; font-family: "Microsoft YaHei", sans-serif; color: #222; background: #ededed; font-size: 14px; line-height: 1.6; }
+body { margin: 0; }
+button, input, select, textarea { font: inherit; color: inherit; }
+button, .button { background: #fff; border: 1px solid #777; padding: 7px 12px; cursor: pointer; text-decoration: none; display: inline-block; text-align: center; }
+button:hover, .button:hover { background: #e9e9e9; }
+button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid #222; outline-offset: 2px; }
+button:disabled { cursor: not-allowed; color: #777; border-color: #bbb; background: #eee; }
+.primary, button[aria-current="page"] { color: white; background: #333; border-color: #333; }
+.primary:hover { background: #555; }
+a { color: #222; text-underline-offset: 3px; }
+h1 { font-size: 23px; margin: 0; } h2 { font-size: 17px; margin: 0 0 12px; } h3 { font-size: 15px; margin: 0 0 8px; }
+p { margin: 7px 0 13px; } ul, ol { padding-left: 23px; }
+.review-header { background: #fafafa; border-bottom: 2px solid #555; padding: 14px 24px; }
+.review-header strong { font-size: 19px; }.review-header span { margin-left: 20px; }
+.review-header p, .muted { color: #626262; font-size: 12px; }
+#role-nav { display: flex; gap: 8px; flex-wrap: wrap; }
+.review-layout { display: block; width: 100%; }
+.review-sidebar { padding: 22px 16px; border-right: 1px solid #bbb; }
+.review-sidebar nav { display: grid; gap: 4px; margin-bottom: 22px; }
+.review-sidebar nav button { text-align: left; border-color: transparent; }
+.review-sidebar details { border-top: 1px solid #aaa; padding: 14px 0; }
+#scenario-nav { display: grid; gap: 6px; margin-top: 12px; }
+#scenario-nav button { text-align: left; font-size: 12px; }
+main { padding: 0 0 20px; min-width: 0; }
+.page-heading { display: none; }
+#page-code { margin: 0; color: #666; font-size: 12px; }
+#screen-scope { font-size: 12px; text-align: right; }
+#notice:not(:empty) { padding: 12px; border: 2px solid #555; background: white; margin-bottom: 16px; }
+.surface { background: #fff; border: 1px solid #888; padding: 20px; min-height: 470px; }
+.mobile { max-width: 414px; margin: 22px auto; padding: 0; min-height: 780px; display: flex; flex-direction: column; position: relative; }
+.mobile header { border-bottom: 1px solid #999; padding: 14px 16px; }
+.mobile header strong { display: block; font-size: 17px; }
+.mobile .content { padding: 16px; flex: 1; }
+.mobile footer { border-top: 1px solid #999; padding: 12px; display: flex; gap: 8px; }
+.mobile footer button { flex: 1; padding: 6px; }
+.box { border: 1px solid #aaa; padding: 14px; margin-bottom: 14px; background: #fff; }
+.box p:last-child { margin-bottom: 0; }
+.placeholder { border: 1px dashed #888; background: #f5f5f5; padding: 22px; text-align: center; margin-bottom: 14px; }
+.hint { padding: 10px 12px; border: 1px dashed #888; background: #f6f6f6; font-size: 13px; margin-bottom: 14px; }
+.tag { display: inline-block; border: 1px solid #888; padding: 1px 7px; margin: 0 5px 4px 0; font-size: 12px; }
+.actions, .tabs, .toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; align-items: center; }
+.actions:last-child { margin-bottom: 0; }
+.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
+.workspace { display: grid; grid-template-columns: 235px minmax(260px, 1fr) 270px; gap: 0; min-height: 620px; }
+.workspace section { min-width: 0; }
+.workspace .box { padding: 12px; }
+label { display: block; margin-bottom: 13px; } label input, label select, label textarea { display: block; width: 100%; margin-top: 5px; }
+input, select, textarea { padding: 8px; background: #fff; border: 1px solid #999; min-width: 0; }
+textarea { resize: vertical; min-height: 85px; }
+input[type="checkbox"], input[type="radio"] { display: inline-block; width: auto; margin-right: 7px; }
+.check { display: flex; align-items: flex-start; gap: 5px; }
+.check input { margin-top: 6px; }
+.table-wrap { overflow-x: auto; }
+table { border-collapse: collapse; width: 100%; margin: 14px 0; font-size: 13px; }
+th, td { text-align: left; border: 1px solid #aaa; padding: 10px; vertical-align: top; }
+th { background: #eee; font-weight: normal; white-space: nowrap; }
+td button { padding: 4px 9px; }
+.message { padding: 12px; border: 1px solid #aaa; margin: 12px 25px 12px 0; }
+.message.customer { margin: 12px 0 12px 25px; background: #eee; }
+.message small { display: block; color: #666; margin-bottom: 4px; }
+.timeline li { padding-bottom: 12px; }
+.annotation { border-top: 1px dashed #aaa; margin: 25px auto 0; padding-top: 14px; max-width: 1060px; color: #555; }
+.annotation ul { margin-bottom: 0; }
+summary { cursor: pointer; }
+.empty { padding: 42px 18px; text-align: center; border: 1px dashed #aaa; }
+dialog { max-width: 460px; width: calc(100% - 32px); border: 2px solid #555; padding: 24px; }
+dialog::backdrop { background: #0005; }
+.steps { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 18px; }
+.steps span { border-bottom: 2px solid #aaa; padding: 5px 8px; }
+@media (max-width: 1150px) { .workspace { grid-template-columns: 1fr 1fr; }.workspace > section:first-child { grid-column: 1 / -1; }.workspace > section:first-child .box { display: inline-block; margin-right: 10px; } }
+@media (max-width: 760px) { .review-layout { display: block; }.review-sidebar { border-right: 0; border-bottom: 1px solid #aaa; padding: 12px; }.review-sidebar nav { display: flex; flex-wrap: wrap; margin: 0; }.review-sidebar h2, .review-sidebar > .muted { display: none; }.review-sidebar details { margin: 8px 0; padding: 8px 0; }#scenario-nav { grid-template-columns: repeat(2, minmax(0, 1fr)); }main { padding: 16px 10px; }.review-header { padding: 12px; }.grid, .workspace { display: block; }.mobile { width: 100%; }.page-heading { align-items: flex-start; }.surface { padding: 14px; }.mobile { padding: 0; } }
+@media print { .review-header, .review-sidebar, #notice { display: none; }.review-layout { display: block; }main { padding: 0; }body { background: white; }.surface { min-height: auto; }.annotation { break-inside: avoid; } }
+
+/* 顶部只保留轻量的原型切换;页面内部使用产品自己的导航。 */
+.review-header { padding: 8px 16px; border-bottom: 1px solid #aaa; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
+.review-header strong { font-size: 13px; }.review-header span { font-size: 11px; margin: 0 10px; }
+.review-header button { padding: 4px 9px; font-size: 12px; }
+.review-sidebar { position: fixed; z-index: 20; width: 260px; max-height: calc(100vh - 65px); overflow: auto; right: 12px; top: 62px; background: white; border: 1px solid #888; }
+[hidden] { display: none !important; }
+.admin-shell { display: grid; grid-template-columns: 188px minmax(0, 1fr); min-height: calc(100vh - 66px); background: #f1f2f3; }
+.admin-sidebar { background: #26292c; color: #e3e3e3; }
+.admin-brand { padding: 20px 18px; font-size: 20px; border-bottom: 1px solid #555; }.admin-brand small { display: block; font-size: 11px; color: #bbb; }
+.nav-group { margin: 16px 0; }.nav-group p { font-size: 11px; margin: 0; padding: 0 18px 6px; color: #aaa; }
+.nav-group button { display: block; border: 0; background: transparent; color: #ddd; width: 100%; text-align: left; padding: 10px 20px; }
+.nav-group button[aria-current="page"] { background: #4d5257; color: #fff; border-left: 3px solid #eee; padding-left: 17px; }
+.nav-group button:hover { background: #41464b; }
+.admin-main { min-width: 0; }.admin-topbar { height: 58px; background: white; border-bottom: 1px solid #ddd; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; }
+.admin-topbar > div { display: flex; gap: 22px; align-items: center; }.admin-topbar span { font-size: 12px; }
+.admin-breadcrumb { font-size: 12px; padding: 12px 24px; color: #666; }
+.admin-tabs { background: #fff; border-block: 1px solid #ddd; padding: 7px 24px; }.admin-tabs span { padding: 6px 14px; border: 1px solid #bbb; display: inline-block; font-size: 12px; }
+.admin-content { padding: 20px 24px; }.admin-title { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 16px; }.admin-title h2 { margin: 0; font-size: 19px; }.admin-title > span { font-size: 12px; color: #666; }
+.admin-content > .surface { border: 1px solid #d0d0d0; padding: 20px; min-height: 560px; }
+.workspace > section { padding: 15px; border-right: 1px solid #ccc; }.workspace > section:last-child { border-right: 0; background: #f8f8f8; }
+.workspace > section:first-child { background: #f5f5f5; }.workspace > section:nth-child(2) { display: flex; flex-direction: column; }.workspace > section:nth-child(2) > form { margin-top: auto; border-top: 1px solid #ddd; padding-top: 14px; }
+.session-row { border: 0; border-bottom: 1px solid #ccc; padding: 14px 8px; background: #fff; text-align: left; width: 100%; }.session-row strong, .session-row small { display: block; }.session-row small { color: #666; }.session-row.active { background: #ddd; }
+.chat-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 490px; }
+.metric-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 16px; margin-bottom: 20px; }.metric { background: #f6f6f6; border: 1px solid #ccc; padding: 16px; }.metric strong { display: block; font-size: 27px; margin: 8px 0; }.metric small { color: #777; }
+.chart-placeholder { min-height: 170px; display: grid; place-items: center; background: repeating-linear-gradient(0deg,#fff,#fff 33px,#ddd 34px); border: 1px solid #bbb; color: #666; }
+.device-status { display: flex; justify-content: space-between; padding: 5px 16px; font-size: 10px; background: #fafafa; }
+.mobile .mobile-header { padding: 12px; display: flex; align-items: center; justify-content: space-between; gap: 8px; }.mobile-header button { border: 0; padding: 3px; font-size: 12px; }.mobile .mobile-header strong { font-size: 15px; }
+.mobile .content { background: #f5f5f5; padding: 14px; padding-bottom: 75px; }.mobile .shop-tabs { background: #fff; position: sticky; bottom: 0; padding: 8px; }.shop-tabs button { border: 0; font-size: 12px; }.shop-tabs button[aria-current="page"] { background: #ddd; color: #111; }
+.shop-search { display: block; width: 100%; background: #fff; color: #777; border: 1px solid #bbb; border-radius: 20px; text-align: left; margin-bottom: 14px; }
+.shop-banner { padding: 24px 18px; background: #dedede; margin-bottom: 15px; min-height: 150px; }.shop-banner h2 { font-size: 22px; margin: 6px 0; }
+.shortcut-grid { display: grid; grid-template-columns: repeat(4,1fr); margin-bottom: 16px; }.shortcut { border: 0; background: transparent; font-size: 12px; padding: 8px 2px; }.shortcut-icon { display: block; border: 1px solid #999; background: #e5e5e5; width: 25px; height: 25px; margin: 0 auto 6px; border-radius: 6px; }
+.ai-entry { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px; width: 100%; padding: 15px; border: 1px solid #888; background: #fff; text-align: left; margin-bottom: 18px; }.ai-entry strong { width: 100%; }.ai-entry span { font-size: 12px; }
+.ai-float { position: absolute; right: 12px; bottom: 90px; border-radius: 24px; padding: 8px; width: 64px; background: #333; color: white; z-index: 2; }.ai-float strong, .ai-float span { display: block; }.ai-float span { font-size: 10px; }.ai-float:hover { color: #222; }
+.section-heading { display: flex; justify-content: space-between; align-items: center; margin: 15px 0; }.section-heading h2 { margin: 0; }.section-heading button { border: 0; background: transparent; font-size: 11px; }
+.goods-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }.goods-card { display: block; padding: 0; border: 1px solid #ccc; text-align: left; width: 100%; }.goods-card > div:last-child { padding: 10px; }.goods-card p, .goods-card span { font-size: 11px; color: #666; }
+.goods-image { height: 125px; background: #e5e5e5; color: #777; display: flex; align-items: center; justify-content: center; flex-direction: column; border-bottom: 1px solid #ccc; }.goods-image small { font-size: 10px; }.product-hero { height: 240px; margin-bottom: 18px; }
+.catalog { display: grid; grid-template-columns: 82px minmax(0,1fr); gap: 12px; }.catalog nav button { display: block; width: 100%; padding: 14px 0; border: 0; border-bottom: 1px solid #ccc; background: #e7e7e7; }
+.profile-card { display: flex; gap: 16px; align-items: center; padding: 18px 0; }.profile-card p { font-size: 12px; }.avatar-placeholder { border: 1px solid #999; width: 58px; height: 58px; border-radius: 50%; display: grid; place-items: center; background: #ddd; color: #666; }
+.order-shortcuts { display: grid; grid-template-columns: repeat(5,1fr); background: white; margin-bottom: 18px; padding: 10px 2px; }.order-shortcuts button { padding: 5px 0; font-size: 10px; border: 0; }
+.account-list button { display: block; width: 100%; text-align: left; padding: 13px; border: 0; border-bottom: 1px solid #ddd; }
+.cart-total,.purchase-bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 14px 0; }.cart-total span { font-size: 12px; }.product-section { padding: 15px 0; border-bottom: 1px solid #ccc; }.product-section span { float: right; font-size: 12px; }
+.order-card { padding: 15px; background: #fff; border: 1px solid #ccc; margin: 12px 0; }.order-product { display: flex; gap: 15px; padding: 15px 0; }.order-product .goods-image { width: 75px; height: 75px; flex-shrink: 0; }.order-product p { font-size: 12px; margin: 2px 0; }
+@media (max-width: 1150px) { .admin-shell { grid-template-columns: 160px minmax(0,1fr); }.admin-content { padding: 14px; }.workspace { grid-template-columns: 200px minmax(0,1fr); }.workspace > section:first-child { grid-column: auto; }.workspace > section:last-child { grid-column: 1 / -1; }.metric-grid { grid-template-columns: 1fr 1fr; } }
+@media (max-width: 760px) { main { padding: 0; }.review-header { padding: 7px; }.review-header > div > span { display: none; }.admin-shell { grid-template-columns: 110px minmax(0,1fr); }.admin-brand { font-size: 15px; padding: 12px 8px; }.admin-brand small { font-size: 9px; }.nav-group button { padding: 9px 8px; font-size: 11px; }.nav-group p { padding-left: 8px; }.nav-group button[aria-current="page"] { padding-left: 5px; }.admin-topbar { padding: 8px; height: auto; display: block; font-size: 12px; }.admin-breadcrumb, .admin-tabs { padding: 8px; }.admin-content { padding: 8px; }.admin-content > .surface { padding: 8px; }.admin-title { display: block; }.admin-title h2 { font-size: 15px; }.admin-title > span { font-size: 10px; }.workspace { display: block; }.workspace > section { padding: 8px; }.metric-grid { grid-template-columns: 1fr; gap: 8px; }.mobile { margin: 8px auto; min-height: calc(100vh - 100px); }.review-sidebar { width: min(290px,calc(100vw - 24px)); }.review-sidebar nav { display: grid; }.review-sidebar h2 { display: block; }.review-sidebar #scenario-nav { grid-template-columns: 1fr; } }

+ 13 - 0
docs/design/wireframes/workspace.js

@@ -0,0 +1,13 @@
+register('staff', 'queue', 'S01', '接待工作台', () => desktop(`<div class="workspace"><section><h3>客户会话</h3><div class="tabs"><span class="tag">待接待</span><span class="tag">我的会话</span></div>${state.empty ? '<p>暂无待处理会话</p>' : `<button class="session-row" data-page="conversation" data-role="staff"><strong>A01 H5</strong><p>键盘空格键无法使用</p><small>${state.humanRequested ? '请求人工协助' : state.owner === 'HUMAN' ? '王芳接待中' : 'AI 接待中'}</small></button>`}</section><section><div class="chat-empty"><h2>选择会话开始接待</h2><p>查看咨询记录,接管后回复客户。</p>${!state.empty ? link('进入 A01 会话', 'conversation') : ''}</div></section><section><h3>客户信息</h3><p class="muted">选择会话后显示客户资料。</p><hr><h3>关联订单</h3><p class="muted">当前客户授权范围内的订单。</p><hr><h3>售后工单</h3><p class="muted">确认、审批和履约进度。</p></section></div>`), ['会话列表、聊天工作区、客户业务上下文在同一接待页面。', '未选择会话时显示工作区空状态;选择后加载会话与业务资料。']);
+
+register('staff', 'conversation', 'S02', '会话处理与业务上下文', () => desktop(`<div class="workspace"><section>${box('当前会话', `<p>A01 / H5</p>${tag(state.humanRequested ? '等待人工' : '换货咨询')}${link('返回会话队列', 'queue')}`)}${box('服务控制', `<p>${state.owner === 'HUMAN' ? '当前接管:王芳' : '智能助手服务中'}</p>${actions(state.owner === 'AGENT' ? button('接管会话', 'takeover', 'class="primary"') : button('结束接管', 'release'))}`)}</section><section><h2>咨询记录</h2><div class="message customer"><small>客户 A01</small>键盘空格键无法使用,希望换货。</div>${state.messages.map(item => `<div class="message"><small>${escapeHtml(item.sender)}</small>${escapeHtml(item.text)}</div>`).join('')}${state.owner === 'AGENT' ? hint('接管后可以发送人工回复。接管将停止助手继续回复。') : hint('你正在接管会话。')}${form('staff-message', area('人工回复', 'message', '', 'required maxlength="2000"'), '发送回复', state.owner !== 'HUMAN' ? 'disabled' : '')}</section><section>${box('当前客户订单', `${orderSummary()}${actions(link('整理换货方案', 'draft'))}`)}${box('售后进度', `${tag(statusLabels[state.ticket])}<p>客户确认:${state.customerConfirmed ? `已确认 V${state.version}` : '待客户本人确认'}</p>${actions(link('查看工单', 'ticket'))}`)}${box('规则依据', `<p>键盘换货规则 V${state.ruleVersion}</p><p>7 天内功能故障,客户确认及主管审批。</p>`)}${['PROCESSING', 'RESULT_UNKNOWN'].includes(state.ticket) ? hint('已有外部申请正在跟踪,接管后继续查询同一笔申请。') : ''}</section></div>`), ['会话、订单、方案和规则在同一处理页面可见。', '接管后禁止新的 Agent 写操作和自动回复;已提交外部操作继续跟踪。', '结束接管后,下一条客户消息交由助手处理。客服不能代替客户确认方案。']);
+
+register('staff', 'draft', 'S03', '代客户整理或修订方案', () => desktop(`<div class="grid">${box('订单与规则', `${orderSummary()}<p>键盘换货规则 V${state.ruleVersion}:7 天内功能故障可申请。</p><p>客户本人确认后进入主管审批。</p>`)}${box('方案编辑', `${state.owner !== 'HUMAN' ? hint('请先接管会话。') : ''}${form('proposal', `${field('申请数量', 'quantity', state.quantity, 'number', 'required min="1" max="1"')}${area('故障描述', 'reason', state.reason, 'required maxlength="2000"')}`, '保存并交客户检查', state.owner !== 'HUMAN' || (!canCreate() && !canRevise()) ? 'disabled' : '')}`)}</div>`), ['保存为待客户确认方案。', '修订内容生成新版本,旧确认与审批失效。', '只有当前接管客服可以通过会话整理方案。']);
+
+register('staff', 'ticket', 'S04', '工单详情与执行记录', () => desktop(state.ticket === 'NONE' ? `<div class="empty">当前客户没有工单。${actions(link('进入会话处理', 'conversation'))}</div>` : `<div class="grid">${box('当前工单', ticketSummary())}${box('确认与审批', `<p>客户确认:${state.customerConfirmed ? `A01 已确认 V${state.version}` : '等待客户确认'}</p><p>主管处理:${tag(statusLabels[state.ticket])}</p><p>外部申请:${['PROCESSING', 'RESULT_UNKNOWN', 'COMPLETED'].includes(state.ticket) ? 'EX-001 · 持续跟踪同一笔申请' : '尚未登记'}</p>${canRevise() ? link('修订并重新请客户确认', 'draft') : ''}`)}</div>${state.ticket === 'RESULT_UNKNOWN' ? hint('受理结果未知,后台继续核实原申请;工单保留数量占用。') : ''}${box('处理记录', state.audit.length ? `<ol>${state.audit.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ol>` : '<p>已生成方案,等待后续处理。</p>')}`), ['业务进度、客户确认、主管决定与外部操作分开展示。', '客服只能修订允许修订的方案;客户取消仍由客户操作。']);
+
+register('supervisor', 'approvals', 'S05', '待审批列表', () => desktop(`${hint('主管仅审批已获授权店铺的申请,审批人与提交人必须不同。')}${state.ticket === 'WAITING_REVIEW' && !state.empty ? table(['工单 / 客户', '方案', '确认记录', '操作'], [['SH-001 / A01', `V${state.version} · 键盘 × ${state.quantity}`, `客户已确认 V${state.version}`, link('查看并审批', 'review')]]) : `<div class="empty">当前没有待审批方案。${actions(link('查看工单记录', 'history'))}</div>`}`), ['进入详情后查看订单、规则和客户确认记录。', '角色为主管仍需检查店铺权限,已处理或已修订申请退出待办。']);
+
+register('supervisor', 'review', 'S06', '审批资料与决定', () => desktop(state.ticket === 'NONE' ? '<div class="empty">当前没有方案,请从消费者入口创建并确认方案。</div>' : `<div class="grid">${box('申请资料', proposalSummary())}${box('确认与权限', `<p>客户确认:${state.customerConfirmed ? `A01 已确认 V${state.version}` : '尚未确认'}</p><p>当前审批人:主管李明</p><p>提交人:${state.selfApproval ? '主管李明' : '客户 A01 / 客服王芳整理'}</p><p>当前工单:${statusLabels[state.ticket]}</p>${state.selfApproval ? hint('申请人不能审批自己提交的申请,请交由其他主管处理。') : ''}${state.stale ? hint('方案版本已变化,请重新获取资料后审批。') : ''}`)}</div>${form('approval', `${select('审批决定', 'decision', ['批准', '拒绝'], '批准')}${area('审批意见', 'reason', '', 'required placeholder="请填写决定依据;拒绝原因会提供给客户"')}${consent('checked', `已核对订单、规则和客户对 V${state.version} 的确认记录`)}`, '提交审批决定', state.ticket !== 'WAITING_REVIEW' || !state.customerConfirmed || state.selfApproval || state.stale ? 'disabled' : '')}${state.stale ? actions(button('重新读取资料', 'refresh-proposal')) : ''}`), ['审批操作绑定当前方案版本及工单版本。', '客户未确认、自己提交、版本已更新、无权限或已处理时不能批准。', '批准后仍需执行前复核;履约完成由外部业务结果确定。']);
+
+register('supervisor', 'history', 'S07', '审批后业务进度', () => desktop(state.ticket === 'NONE' ? '<div class="empty">当前没有工单记录。</div>' : `${box('工单 SH-001', ticketSummary())}${hint('审批完成后持续跟踪业务结果;需要修订时重新由客户确认并创建审批任务。')}`), ['用于从审批列表回查处理结果,不提供直接修改外部履约状态的产品按钮。']);

+ 3 - 1
frontend/README.md

@@ -21,7 +21,7 @@ $env:DEEPSEEK_API_KEY = [Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY'
 
 ## 身份与数据
 
-消费者、商家使用真实账户认证,平台使用独立平台身份接口。令牌只保存在内存,刷新后需要重新登录;两个后台退出相互独立。服务端每次检查账户状态及权限。会话过期后未提交表单只恢复给重新验证的同一账户,写操作需要用户重新检查并提交。
+消费者、商家使用真实账户认证,平台使用独立平台身份接口。三个入口在当前标签页的 `sessionStorage` 中分别保存令牌、到期时间和展示用账户名称;刷新后通过各自的 `auth/me` 接口重新验证身份和权限,再显示原有授权页面。恢复期间不显示业务内容,网络失败时可以点击“重新验证登录”。会话到期、被撤销或主动退出时清除对应入口的凭据,其他入口保持独立。密码、角色、权限和业务数据不写入存储。会话过期后,当前页面内存中的未提交表单只恢复给重新验证的同一账户,写操作需要用户重新检查并提交。
 
 数据范围见 [前端数据来源](data-sources.md)。服务端保存商品、规则草稿、发布配置、员工邀请、渠道配置、通知、身份验证和平台管理记录。订单、工单、审批、知识和费用继续使用相同业务服务。
 
@@ -33,6 +33,8 @@ $env:DEEPSEEK_API_KEY = [Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY'
 
 `npm run test:functional` 执行消费者、平台、全部页面布局、会话、组件及商家业务检查,同时验证入口数据隔离、平台草稿恢复和真实登录限流。也可分别执行 `test:customer`、`test:merchant`、`test:business`、`test:sessions`、`test:platform`、`test:layout` 和 `test:components`。`test:responsive` 验证H5各类视口。
 
+`npm run test:session-refresh` 使用真实身份接口验证三个入口刷新后恢复原页面、独立保存会话、主动退出、服务端撤销、网络连接中断后的手动恢复,以及浏览器缓存中的身份和权限字段不生效。只创建并撤销测试登录会话,不修改业务数据,不调用生成模型;总时限180秒,API请求最多500次,全部资源请求最多3000次,结果写入 `.work/frontend-verification/session-refresh.json`。
+
 通过 `FRONTEND_URL` 指定地址,通过 `PLAYWRIGHT_CHROMIUM_EXECUTABLE` 指定 Chromium。验证结果见 [验收记录](../docs/verification/frontend-backend.md)。生成模型调用与本地向量验证分别记录。
 
 `node tests/merchant-release.mjs` 通过真实知识发布和Agent配置发布验证成功流程,要求API进程读取有效的模型配置与凭据。该专项保留已发布的开发店铺服务资料和配置版本,不执行生成模型请求。

+ 59 - 2
frontend/data-sources.md

@@ -10,12 +10,14 @@
 | 订单与商品资料 | 真实订单投影及商品目录,保留来源版本和同步时间 |
 | 换货方案、客户确认、修订、取消与主管审批 | 真实售后接口,提交当前版本和幂等标识 |
 | 工单及售后历史 | `/openapi/v1/tickets`,按店铺、本人或当前会话客户查询持久工单 |
+| 换货服务评价 | 工单详情中的 `/openapi/v1/tickets/{ticket_id}/feedback`,本人评价已完成工单,授权员工读取结果;后端按课程讲义完成后提供此接口 |
+| 客服回访记录 | `/openapi/v1/tickets/{ticket_id}` 的 `followup` 字段,客户通过已有 AI 咨询登记,客户与授权员工在原工单只读查看;后端与 Agent 按课程讲义补充此能力 |
 | 会话、消息、客服接管与释放 | H5 使用真实 SSE 接口,按持久事件序号续传,失败后保留输入并提供重新连接 |
 | 助手等待补充资料 | 查询原运行的 waiting,通过 `/agent/runs/{id}/inputs` 提交当前 interrupt_id、run_version 及本人补充内容 |
 | 人工协助 | 会话 `/human-assistance`,持久保存请求并通知授权员工 |
 | 规则读取与草稿 | 当前规则和草稿从服务端读取,草稿版本及发布版本分别检查 |
 | 知识草稿、发布与撤回 | 真实知识接口,发布使用本地向量服务与 Milvus |
-| Agent 配置发布 | `/admin/v1/stores/{store_id}/agent-releases`,保存配置及发布版本 |
+| Agent 配置发布 | `/admin/v1/stores/{store_id}/agent-releases`,保存配置及发布版本;所有会话每次新建助手任务时使用当前发布版本,进行中和等待中的任务保留原配置 |
 | 店铺、员工与渠道配置 | 商家管理接口,按专用权限和并发版本处理 |
 | 员工通知 | `/admin/v1/notifications`,按真实业务事件产生,接收人、权限和已读状态由后端管理 |
 | 渠道身份关联 | 本人发起短期验证请求,受信任渠道完成身份验证,本人确认后保存关联 |
@@ -24,12 +26,67 @@
 | 平台商家、资源配额、投递、运行概览与审计 | `/openapi/v1/platform`,平台只读取运行和管理元数据 |
 | 交易支付、营销、地址、收藏、团队统计等范围外栏目 | 按已确认设计展示业务范围说明 |
 
-真实登录令牌保存在浏览器内存,刷新后重新登录。客户、商家与平台分别管理会话、业务上下文和草稿;退出或更换身份时清理对应入口的数据。旧会话的迟到响应不会清除新登录会话。写操作保持幂等标识;网络结果未知时保留相同请求标识,版本冲突后重新读取状态。公共请求超时20秒,不自动重试;HTTP 429 按服务端 Retry-After 提示等待秒数。
+三个入口的登录令牌、到期时间和展示用账户名称分别保存在当前标签页的 `sessionStorage`。刷新时,消费者与商家调用 `GET /openapi/v1/auth/me`,平台调用 `GET /openapi/v1/platform/auth/me`,携带对应 Bearer token 重新读取身份和权限。验证完成后显示原有授权页面;本地记录不提供权限依据,密码和业务数据不写入存储。
+
+恢复身份期间展示加载提示,业务页面等待验证结果。网络失败、请求超时或服务端暂时不可用时保留凭据,点击“重新验证登录”重新查询,不自动连续重试。登录接口返回的 `expires_in` 用于记录原到期时间,身份查询不会延长会话有效期。到期、401 或主动退出清除对应入口凭据,其他入口的会话保持独立。客户、商家与平台的业务上下文和草稿只保存在内存;退出或更换身份时清理对应入口的数据。旧会话的迟到响应不会清除新登录会话。
+
+写操作保持幂等标识;网络结果未知时保留相同请求标识,版本冲突后重新读取状态。公共请求超时 20 秒,不自动重试;HTTP 429 按服务端 Retry-After 提示等待秒数。
 
 商品图片作为项目静态资源保存,来源和初始化信息见 `scripts/prepare_storefront_data.py` 及商品数据说明。开发账户和初始化数据属于独立开发商家,凭据只保存在被忽略的 `.work` 目录。
 
+## 换货服务评价
+
+消费者在现有售后进度页评价本人 `COMPLETED` 工单。客服、主管和商家管理员在原有工单详情读取评价。评分不高于 2 分或问题未解决时,授权员工通过已有通知详情进入该工单。前端已经接入以下接口;当前主项目的评价后端代码由课程开发补充,接口尚未提供时显示读取失败和人工重试入口。
+
+`GET /openapi/v1/tickets/{ticket_id}/feedback` 使用当前入口的 Bearer 会话。未评价时返回 `200` 和 `{"feedback":null}`;已经评价时返回 `{"feedback":{"feedback_id":"UUID","ticket_id":"UUID","score":2,"resolved":false,"comment":"换货后问题仍然存在","created_at":"2026-09-29T08:00:00Z"}}`。工单详情同时提供相同 `feedback` 字段。员工读取需具备当前店铺的 `tickets:read` 权限,客户只读取本人记录。此接口查询单个工单,无分页。
+
+`POST /openapi/v1/tickets/{ticket_id}/feedback` 仅接受工单本人客户,要求 Bearer 会话、当前店铺的 `tickets:read` 权限与 `Idempotency-Key`。请求示例:
+
+```json
+{
+  "expected_version": 5,
+  "score": 2,
+  "resolved": false,
+  "comment": "换货后问题仍然存在"
+}
+```
+
+- `expected_version` 使用最新工单的 `version`。
+- `score` 为 1 至 5 的整数,`resolved` 为布尔值。
+- `comment` 可为空,去除首尾空白后最多 500 个字符。
+- 首次成功和同键同参重试均返回 `201` 和同一 `feedback` 对象。一个工单只保存一次评价,客户确认窗口提交后展示已保存结果;工单状态与版本保持不变。
+- 网络中断、请求超时或服务端异常时保留原请求正文与 `Idempotency-Key`,暂时禁用输入。客户选择“重试原评价”并确认后,继续发送相同内容。公共请求超时 20 秒,无自动重试。
+- `409 VERSION_CONFLICT`、`INVALID_STATE`、`FEEDBACK_EXISTS`、`IDEMPOTENCY_CONFLICT` 后重新读取工单和评价。读取成功且仍可评价时,客户重新检查并确认,使用新的提交标识。
+- `401` 按公共登录逻辑处理,同一身份重新登录后读取最新工单再确认;`403 FORBIDDEN` 和 `404 RESOURCE_NOT_FOUND` 停止提交并保留读取入口;`422 INVALID_INPUT` 允许修改输入;`429` 按公共请求提示等待时间。明确的请求拒绝清除本次提交标识,失败不会显示提交成功。
+- 草稿只保存在当前浏览器内存中,按工单区分;退出或更换客户身份时清理。员工页面仅能读取,无法代客户评价。
+
+## 客服回访记录
+
+`TicketFeedback` 在工单存在 `followup` 时显示“客服回访”区域,包含“已登记”、回访原因、期望联系时段、登记时间。客户与员工均只读,长文字使用现有 `ExpandableText`。`followup` 为 `null` 时不显示回访区域。
+
+读取复用 `GET /openapi/v1/tickets/{ticket_id}` 和现有 `ticket(id)` 方法,使用当前入口的 Bearer 会话。客户仅能读取本人工单,员工需要当前店铺的 `tickets:read` 权限。HTTP 200 的工单对象增加以下字段:
+
+```json
+{
+  "followup": {
+    "followup_id": "cb24ab6d-c44f-4c04-939c-614265f0c8f9",
+    "ticket_id": "c1882132-c2c7-4ca4-b7b6-9779e5a110d7",
+    "reason": "换货后仍然无法正常使用",
+    "contact_window": "工作日晚上",
+    "status": "REQUESTED",
+    "created_at": "2026-09-29T09:00:00Z"
+  }
+}
+```
+
+每次点击“读取最新评价”,组件依次读取工单详情和评价,更新父页面工单中的版本、评价与回访信息。两个请求成功前保持评价提交禁用。刷新不改变尚未确认结果的评价正文与 `Idempotency-Key`;服务端已经保存评价时清除待重试内容。回访读取没有额外分页,沿用公共请求 20 秒超时和人工重试;401 进入登录,403 或 404 停止提交并显示错误。
+
+客户通过现有 AI 咨询发送“工单 xxx 换货后仍有问题,请客服在工作日晚上联系我”。消息仍调用现有会话消息接口。Agent 缺少工单、问题或联系时段时,通过现有 `clarification` 等待及聊天输入补充资料。服务端 `create_followup_request` 工具保存回访请求后,员工通知沿用 `page="ticket"` 和 `target_id=ticket_id` 进入原工单。前端从工单响应展示记录,不新增回访 HTTP 方法或输入表单。
+
 ## H5 消息连接与补充输入
 
+原会话的每次新建 `AgentRun` 都读取店铺当前已发布配置;发布后客户在原聊天中发起新请求即可使用新能力。补充资料、客户确认和主管审批继续恢复原运行,使用该运行已保存的发布快照。
+
 进入咨询页面时,通过 `GET /openapi/v1/conversations/{id}/messages?after={sequence}&limit=100` 按页恢复消息,并查询会话及当前活动运行。之后使用 `GET /openapi/v1/conversations/{id}/events`,携带内存中的 `Authorization: Bearer` 和 `Last-Event-ID`。SSE 使用 `eventsource-parser` 处理分段和心跳,JSON 事件中的 `sequence`、`type`、`control_version` 分别对应事件序号、类型和会话控制版本。
 
 - 服务端约50秒结束一条连接,页面自动携带最后成功处理的序号继续连接。15秒心跳保持连接,客户端65秒总期限结束失效连接。

+ 1 - 0
frontend/package.json

@@ -16,6 +16,7 @@
     "test:merchant": "node tests/merchant-functional.mjs",
     "test:business": "node tests/merchant-business.mjs",
     "test:sessions": "node tests/session-functional.mjs",
+    "test:session-refresh": "node tests/session-refresh.mjs",
     "test:layout": "node tests/layout-functional.mjs"
   },
   "dependencies": {

+ 18 - 4
frontend/src/App.js

@@ -19,6 +19,7 @@ export default {
     const role = computed(() => product.value === 'merchant' ? ctx.session.merchant?.role || 'merchant' : product.value);
     const page = computed(() => route.path.split('/').filter(Boolean).at(-1) || 'home');
     const account = computed(() => ctx.session[product.value]);
+    const restoring = computed(() => ctx.restoration[product.value]);
     const productTitle = computed(() => ({ customer: '购物商城 H5', merchant: '商家后台', platform: '平台后台' })[product.value]);
     function allowed(name) {
       return allowedPage(product.value, account.value, name);
@@ -35,9 +36,16 @@ export default {
       const wrongSection = product.value === 'merchant' && account.value && (section === 'service' && route.path.split('/').length > 3 && role.value !== 'staff' || section === 'approval' && role.value !== 'supervisor');
       return !['customer', 'merchant', 'platform'].includes(product.value) || wrongSection || page.value !== 'login' && !current.value;
     });
-    watch(() => route.path, () => {
+    watch([() => route.path, () => restoring.value?.status], () => {
       login.password = ''; login.error = '';
       if (route.path === '/') { ctx.navigate('customer', 'home'); return; }
+      // 登录恢复结束后才执行守卫,避免刷新时把有效会话重定向至登录页。
+      if (restoring.value?.status !== 'ready' && restoring.value) {
+        document.title = '正在验证登录 · 智服';
+        if (restoring.value.status === 'pending') void ctx.restoreSession(product.value);
+        return;
+      }
+      if (account.value && page.value === 'login') { home(product.value); return; }
       if (['merchant', 'platform'].includes(product.value) && page.value !== 'login' && !account.value) ctx.requireLogin(product.value, route.path);
       // 子路径仅描述业务栏目,员工身份始终来自已验证账户。
       if (product.value === 'merchant' && account.value && page.value !== 'login') {
@@ -68,14 +76,20 @@ export default {
       if (action === 'confirm') await accept();
       return true;
     }
-    function home(target) { const r = ctx.session[target]?.role || target; ctx.navigate(r, { customer: 'home', merchant: 'overview', staff: 'queue', supervisor: 'approvals', platform: 'dashboard' }[r]); }
-    return { ctx, product, role, page, account, productTitle, menus, title, blocked, login, submitLogin, accept, logout, home, navigationIcon, beforeCustomerClose };
+    async function home(target) { await ctx.restoreSession(target); const r = ctx.session[target]?.role || target; ctx.navigate(r, { customer: 'home', merchant: 'overview', staff: 'queue', supervisor: 'approvals', platform: 'dashboard' }[r]); }
+    return { ctx, product, role, page, account, restoring, productTitle, menus, title, blocked, login, submitLogin, accept, logout, home, navigationIcon, beforeCustomerClose };
   },
   template: `
     <header class="review-header"><div><strong>智服</strong><span>商城与管理中心</span></div><nav aria-label="产品入口"><el-button @click="home('customer')"><Smartphone :size="16" aria-hidden="true"/>购物商城 H5</el-button><el-button @click="home('merchant')"><Store :size="16" aria-hidden="true"/>商家后台</el-button><el-button @click="home('platform')"><ShieldCheck :size="16" aria-hidden="true"/>平台后台</el-button></nav></header>
     <el-alert v-if="ctx.notice.text" class="app-notice" :title="ctx.notice.text" type="info" show-icon @close="ctx.notice.text=''" role="status" />
     <main id="product">
-      <section v-if="blocked" class="empty"><h1>页面不可访问</h1><el-empty description="页面不存在或当前账户未获得此功能权限。"><el-button type="primary" @click="home(product)">返回首页</el-button></el-empty></section>
+      <section v-if="restoring && restoring.status!=='ready'" :class="product==='customer'?'mobile':'empty'" :aria-busy="restoring.status!=='error'">
+        <div :class="product==='customer'?'content':'surface box'">
+          <template v-if="restoring.status==='error'"><h2>登录验证暂时无法完成</h2><p role="alert">{{restoring.error}}</p><van-button v-if="product==='customer'" type="primary" @click="ctx.restoreSession(product)">重新验证登录</van-button><el-button v-else type="primary" @click="ctx.restoreSession(product)">重新验证登录</el-button></template>
+          <div v-else role="status"><van-loading v-if="product==='customer'" size="24">正在验证登录,请稍候。</van-loading><template v-else><p>正在验证登录,请稍候。</p><el-skeleton animated :rows="2" /></template></div>
+        </div>
+      </section>
+      <section v-else-if="blocked" class="empty"><h1>页面不可访问</h1><el-empty description="页面不存在或当前账户未获得此功能权限。"><el-button type="primary" @click="home(product)">返回首页</el-button></el-empty></section>
       <CustomerPage v-else-if="product==='customer'" :key="ctx.session.customer?.id || 'guest'" :context="ctx" :page="page" />
       <div v-else-if="page==='login'" class="login-layout">
         <section class="login-intro"><h1>智服中台</h1><h2>{{productTitle}}</h2><p>{{product==='merchant'?'店铺经营 · 客户服务 · 售后管理':'商家接入 · 资源管理 · 运行监控'}}</p></section>

+ 2 - 2
frontend/src/api.js

@@ -37,7 +37,7 @@ export function createApi(getSession, currentProduct, expire) {
         } finally { reader.releaseLock(); }
       } catch (failure) {
         if (signal.aborted) return;
-        if (failure.status === 401 && account?.token && getSession(product)?.token === account.token) expire(product);
+        if (failure.status === 401 && account?.token && getSession(product)?.token === account.token) expire(product, account.token);
         if (failure instanceof ApiError) throw failure;
         throw new ApiError(failure.name === 'TimeoutError' ? '消息连接超时,请重新连接。' : '消息连接已中断,请检查网络后重新连接。', 0, 'EVENT_CONNECTION_FAILED');
       } finally { connection.abort(); }
@@ -67,7 +67,7 @@ export function createApi(getSession, currentProduct, expire) {
       if (keyed && response.status < 500) pendingKeys.delete(signature);
       if (!response.ok) {
         // 迟到的旧会话响应不能清除用户刚刚重新登录取得的新会话。
-        if (response.status === 401 && !options.token && account?.token && getSession(product)?.token === account.token && !path.endsWith('/auth/sessions')) expire(product);
+        if (response.status === 401 && !options.token && account?.token && getSession(product)?.token === account.token && !path.endsWith('/auth/sessions')) expire(product, account.token);
         let message = result?.error?.message || result?.message || (response.status === 422 ? '请求字段不符合接口要求。' : response.status >= 500 ? '业务服务暂时不可用,请稍后重试。' : '请求失败,请重试。');
         // 限流等待时间由服务端确定,页面提示用户手动重试。
         const retryAfter = Number(response.headers.get('Retry-After'));

+ 148 - 0
frontend/src/components/TicketFeedback.js

@@ -0,0 +1,148 @@
+import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue';
+import ExpandableText from './ExpandableText.js';
+
+export default {
+  components: { ExpandableText },
+  props: {
+    context: { type: Object, required: true },
+    service: { type: Object, required: true },
+    ticket: { type: Object, required: true },
+    product: { type: String, default: 'customer' },
+    disabled: { type: Boolean, default: false },
+  },
+  emits: ['updated'],
+  setup(props, { emit }) {
+    const customer = props.product === 'customer';
+    const actorId = props.context.session[props.product]?.id;
+    const ticketId = props.ticket.ticket_id;
+    const feedback = ref(props.ticket.feedback ?? null);
+    const followup = computed(() => props.ticket.followup ?? null);
+    const loading = ref(false), writing = ref(false), ready = ref(false), error = ref('');
+    let disposed = false, loadNumber = 0;
+    // 客户草稿按工单保存;公共登录逻辑在退出或更换身份时清理对应内存。
+    const drafts = customer ? (props.context.data.customer.feedbackDrafts ||= {}) : {};
+    const draft = reactive(drafts[ticketId] ||= { score: '', resolution: '', comment: '', pending: null, needsRefresh: false });
+    const busy = computed(() => props.disabled || loading.value || writing.value);
+    const locked = computed(() => busy.value || Boolean(draft.pending));
+    const canSubmit = computed(() => customer && ready.value && props.ticket.status === 'COMPLETED' && !feedback.value && !draft.needsRefresh);
+    const resolutionOptions = [{ text: '请选择处理结果', value: '' }, { text: '问题已解决', value: 'resolved' }, { text: '问题未解决', value: 'unresolved' }];
+    const scoreValid = value => Number.isInteger(Number(value)) && Number(value) >= 1 && Number(value) <= 5;
+    const current = () => !disposed && props.context.session[props.product]?.id === actorId && props.ticket.ticket_id === ticketId;
+    const formatTime = value => new Date(value).toLocaleString('zh-CN', { hour12: false });
+
+    function accept(value) {
+      feedback.value = value;
+      if (value) draft.pending = null;
+    }
+
+    async function load() {
+      if (loading.value || writing.value || !current()) return;
+      const requestNumber = ++loadNumber;
+      loading.value = true;
+      ready.value = false;
+      error.value = '';
+      try {
+        // 每次读取同步取得回访和工单版本;两个查询完成前保持评价提交禁用。
+        // 未确认结果的评价仍保留原正文和幂等标识,刷新版本不改变原请求。
+        const latest = await props.service.ticket(ticketId);
+        if (!current() || requestNumber !== loadNumber) return;
+        const result = await props.service.feedback(ticketId);
+        if (!current() || requestNumber !== loadNumber) return;
+        accept(result.feedback);
+        emit('updated', { ...latest, feedback: result.feedback });
+        draft.needsRefresh = false;
+        ready.value = true;
+      } catch (failure) {
+        if (current()) error.value = failure.message;
+      } finally {
+        if (current()) loading.value = false;
+      }
+    }
+
+    async function send(command) {
+      if (!current() || busy.value || !canSubmit.value) return;
+      writing.value = true;
+      error.value = '';
+      // 正文和 Idempotency-Key 一起保存,连接失败后的人工重试使用同一份内容。
+      draft.pending = command;
+      try {
+        const result = await props.service.submitFeedback(ticketId, command.body, command.key);
+        if (!current()) return;
+        accept(result.feedback);
+        emit('updated', { ...props.ticket, feedback: result.feedback });
+        props.context.notify('服务评价已提交。');
+      } catch (failure) {
+        // 明确的请求拒绝允许重新检查内容;网络中断和服务端异常保留原请求。
+        if (failure.status >= 400 && failure.status < 500) draft.pending = null;
+        if ([401, 409].includes(failure.status)) draft.needsRefresh = true;
+        if (!current()) return;
+        error.value = failure.message;
+        if ([401, 403, 404, 409].includes(failure.status)) ready.value = false;
+      } finally {
+        if (current()) writing.value = false;
+      }
+      if (current() && draft.needsRefresh) {
+        await load();
+        if (current() && ready.value && !feedback.value) error.value = '工单状态已更新,请检查当前内容后重新确认评价。';
+      }
+    }
+
+    function submit() {
+      if (busy.value || !canSubmit.value) return;
+      error.value = '';
+      if (!draft.pending && (!scoreValid(draft.score) || !['resolved', 'unresolved'].includes(draft.resolution) || draft.comment.trim().length > 500)) {
+        error.value = '请选择 1 至 5 的整数评分和处理结果,评价内容最多 500 个字符。';
+        return;
+      }
+      const command = draft.pending || {
+        key: crypto.randomUUID(),
+        body: { expected_version: props.ticket.version, score: Number(draft.score), resolved: draft.resolution === 'resolved', comment: draft.comment.trim() },
+      };
+      const body = command.body;
+      props.context.confirm('确认提交服务评价', `服务评分:${body.score} 分。${body.resolved ? '问题已解决' : '问题未解决'}。评价内容:${body.comment || '未填写'}。每个已完成工单可以评价一次,提交后保留本次结果。`, () => send(command));
+    }
+
+    // 已有详情刷新会带回评价和回访;回访直接读取父级工单,更换工单通过 key 创建独立实例。
+    watch(() => props.ticket.feedback, value => { if (value !== undefined) accept(value); });
+    onBeforeUnmount(() => { disposed = true; loadNumber++; });
+    void load();
+    return { customer, feedback, followup, draft, loading, ready, error, busy, locked, canSubmit, resolutionOptions, scoreValid, formatTime, load, submit };
+  },
+  template: `
+    <section class="box ticket-feedback" aria-label="服务评价" :aria-busy="busy">
+      <h3>服务评价</h3>
+      <p v-if="loading" role="status">正在读取服务评价与回访信息…</p>
+      <p v-if="error" role="alert" class="error">{{error}}</p>
+      <template v-if="feedback">
+        <van-tag v-if="customer" type="success">已提交评价</van-tag><el-tag v-else type="success">客户已评价</el-tag>
+        <p>服务评分:{{feedback.score}} / 5</p>
+        <p>处理结果:{{feedback.resolved ? '问题已解决' : '问题未解决'}}</p>
+        <ExpandableText v-if="feedback.comment" :text="feedback.comment" :product="customer ? 'customer' : 'admin'" label="评价内容" />
+        <p v-else>客户未填写文字评价。</p>
+        <p>评价时间:{{formatTime(feedback.created_at)}}</p>
+      </template>
+      <van-form v-else-if="canSubmit" @submit="submit">
+        <p>请评价本次换货服务,每个已完成工单可以提交一次。</p>
+        <van-field v-model="draft.score" name="feedback_score" label="服务评分" type="digit" required :disabled="locked" placeholder="请输入 1 至 5 的整数" :rules="[{validator:scoreValid,message:'评分必须为 1 至 5 的整数'}]" />
+        <van-field name="feedback_resolved" label="处理结果" required :rules="[{validator:()=>['resolved','unresolved'].includes(draft.resolution),message:'请选择处理结果'}]">
+          <template #input><van-dropdown-menu direction="up"><van-dropdown-item v-model="draft.resolution" :options="resolutionOptions" :disabled="locked" /></van-dropdown-menu></template>
+        </van-field>
+        <van-field v-model="draft.comment" name="feedback_comment" label="评价内容" type="textarea" rows="3" maxlength="500" show-word-limit :disabled="locked" placeholder="可以补充本次服务体验" />
+        <p v-if="draft.pending" class="hint">上次提交结果尚未确认,重试将继续提交原有评分和内容。</p>
+        <van-button native-type="submit" type="primary" :disabled="busy">{{draft.pending ? '重试原评价' : '提交服务评价'}}</van-button>
+      </van-form>
+      <p v-else-if="ready">{{customer ? '换货完成后可以提交服务评价。' : '客户尚未提交服务评价。'}}</p>
+      <div v-if="followup" role="region" aria-label="客服回访">
+        <h3>客服回访</h3>
+        <van-tag v-if="customer" type="primary">已登记</van-tag><el-tag v-else type="primary">已登记</el-tag>
+        <p>回访原因:</p>
+        <ExpandableText :text="followup.reason" :product="customer ? 'customer' : 'admin'" label="回访原因" />
+        <p>期望联系时段:</p>
+        <ExpandableText :text="followup.contact_window" :product="customer ? 'customer' : 'admin'" label="期望联系时段" />
+        <p>登记时间:{{formatTime(followup.created_at)}}</p>
+      </div>
+      <van-button v-if="customer" @click="load" :disabled="busy">读取最新评价</van-button>
+      <el-button v-else @click="load" :disabled="busy">读取最新评价</el-button>
+    </section>
+  `,
+};

+ 91 - 30
frontend/src/main.js

@@ -4,7 +4,8 @@ import { ElConfigProvider, ElAlert, ElButton, ElCard, ElCheckbox, ElDialog, ElDi
 import zhCn from 'element-plus/es/locale/lang/zh-cn';
 import { Button, Field, Checkbox, Tabbar, TabbarItem, Empty, Tag, Loading, Form, Dialog, DropdownMenu, DropdownItem } from 'vant';
 import App from './App.js';
-import { createApi } from './api.js';
+import { ApiError, createApi } from './api.js';
+import { clearSession, readSession, saveSession } from './session.js';
 import { allowedPage } from './access.js';
 import { customerPages } from './pages/customer.js';
 import { merchantPages } from './pages/merchant.js';
@@ -46,6 +47,8 @@ import './customer-ui.css';
 
 const router = createRouter({ history: createWebHashHistory(), routes: [{ path: '/:pathMatch(.*)*', component: App }] });
 const session = reactive({ customer: null, merchant: null, platform: null });
+const restoration = reactive(Object.fromEntries(['customer', 'merchant', 'platform'].map(product => [product, { status: 'pending', error: '' }])));
+const restoreRequests = {}, sessionRevisions = { customer: 0, merchant: 0, platform: 0 }, expiryTimers = {};
 const data = reactive({});
 const notice = reactive({ text: '' });
 const confirmation = reactive({ open: false, title: '', text: '', busy: false, callback: null });
@@ -63,47 +66,98 @@ function navigate(role, page) {
   const prefix = role === 'staff' ? 'merchant/service' : role === 'supervisor' ? 'merchant/approval' : role;
   router.push(`/${prefix}/${page}`);
 }
-function expire(product) {
-  returns[product] = { path: router.currentRoute.value.path, actor: session[product]?.id };
+function expire(product, token = session[product]?.token) {
+  // 另一入口和迟到的旧请求都不能改变当前入口的新登录状态。
+  if (!token || session[product]?.token !== token) return;
+  const active = currentProduct() === product;
+  if (active) returns[product] = { path: router.currentRoute.value.path, actor: session[product]?.id };
+  else returns[product] = { ...returns[product], actor: session[product]?.id };
+  sessionRevisions[product]++;
+  clearTimeout(expiryTimers[product]);
   session[product] = null;
+  clearSession(product);
+  if (!active) return;
   Object.assign(confirmation, { open: false, callback: null });
   notify('登录已过期,请重新登录。未提交内容仅在当前页面会话中保留。');
   navigate(product, 'login');
 }
 const api = createApi(product => session[product], currentProduct, expire);
+function authPath(product) { return product === 'platform' ? '/openapi/v1/platform/auth' : '/openapi/v1/auth'; }
+function accountFromIdentity(product, identity, credential) {
+  if (product === 'platform') {
+    if (identity.kind !== 'PLATFORM' || !identity.permissions.includes('platform:manage')) throw new ApiError('当前账户无权进入此入口。', 403, 'ENTRY_FORBIDDEN');
+    return { ...credential, id: identity.actor_id, actorId: identity.actor_id, username: identity.username, role: 'platform', permissions: identity.permissions, source: 'api' };
+  }
+  if (identity.kind !== (product === 'customer' ? 'CUSTOMER' : 'STAFF')) throw new ApiError('当前账户无权进入此入口。', 403, 'ENTRY_FORBIDDEN');
+  const grants = identity.grants;
+  const storeId = grants.find(grant => grant.store_id === context.storeId)?.store_id || grants[0]?.store_id;
+  const permissions = grants.filter(grant => grant.store_id === storeId).map(grant => grant.permission);
+  if (!permissions.length) throw new ApiError('当前账户尚未分配店铺权限。', 403, 'ENTRY_FORBIDDEN');
+  const role = product === 'customer' ? 'customer' : permissions.includes('approvals:decide') ? 'supervisor' : permissions.includes('conversations:read') ? 'staff' : 'merchant';
+  return { ...credential, id: identity.actor_id, actorId: identity.actor_id, tenantId: identity.tenant_id, storeId, permissions, grants, role, source: 'api' };
+}
+function activateSession(product, account) {
+  sessionRevisions[product]++;
+  clearTimeout(expiryTimers[product]);
+  session[product] = account;
+  restoration[product].status = 'ready';
+  restoration[product].error = '';
+  expiryTimers[product] = setTimeout(() => expire(product, account.token), Math.max(0, account.expiresAt - Date.now()));
+}
+async function restoreSession(product) {
+  if (!restoration[product] || restoration[product].status === 'ready') return;
+  if (restoreRequests[product]) return restoreRequests[product];
+  const revision = sessionRevisions[product];
+  restoration[product].status = 'restoring';
+  restoration[product].error = '';
+  restoreRequests[product] = Promise.resolve().then(async () => {
+    try {
+      const credential = readSession(product);
+      if (!credential) { restoration[product].status = 'ready'; return; }
+      if (credential.expiresAt <= Date.now()) throw new ApiError('登录已过期,请重新登录。', 401, 'SESSION_EXPIRED');
+      // 恢复期间只使用显式 token 验证当前入口,业务组件等待最新权限读取完成。
+      const identity = await api.request('GET', `${authPath(product)}/me`, undefined, { product, token: credential.token });
+      if (sessionRevisions[product] !== revision) return;
+      activateSession(product, accountFromIdentity(product, identity, credential));
+    } catch (error) {
+      if (sessionRevisions[product] !== revision) return;
+      if ([401, 403].includes(error.status)) {
+        clearSession(product);
+        restoration[product].status = 'ready';
+        if (currentProduct() === product) notify(error.status === 401 ? '登录已过期,请重新登录。' : error.message);
+      } else {
+        // 网络和服务异常保留凭据,用户可以重试身份验证,期间停止业务页面请求。
+        restoration[product].error = error.message || '登录验证失败,请重新验证登录。';
+        restoration[product].status = 'error';
+      }
+    } finally { delete restoreRequests[product]; }
+  });
+  return restoreRequests[product];
+}
 const context = {
-  api, session, data, notice, confirmation, navigate, notify,
+  api, session, restoration, restoreSession, data, notice, confirmation, navigate, notify,
   tenantId: import.meta.env.VITE_TENANT_ID || '', storeId: import.meta.env.VITE_STORE_ID || '',
   confirm(title, text, callback) { Object.assign(confirmation, { open: true, title, text, busy: false, callback: markRaw(callback) }); },
   async login(product, credentials) {
-    let account;
     const username = credentials.account || credentials.username;
-    if (product === 'platform') {
-      const auth = await api.request('POST', '/openapi/v1/platform/auth/sessions', { username, password: credentials.password }, { product });
-      const identity = await api.request('GET', '/openapi/v1/platform/auth/me', undefined, { product, token: auth.access_token });
-      account = { id: identity.actor_id, actorId: identity.actor_id, username: identity.username, role: 'platform', permissions: identity.permissions, token: auth.access_token, source: 'api' };
-    } else {
-      const auth = await api.request('POST', '/openapi/v1/auth/sessions', { tenant_id: credentials.tenant || context.tenantId, username, password: credentials.password }, { product });
-      const identity = await api.request('GET', '/openapi/v1/auth/me', undefined, { product, token: auth.access_token });
-      if (identity.kind !== (product === 'customer' ? 'CUSTOMER' : 'STAFF')) {
-        await api.request('DELETE', '/openapi/v1/auth/sessions/current', undefined, { product, token: auth.access_token });
-        throw new Error('当前账户无权进入此入口。');
-      }
-      const grants = identity.grants;
-      const storeId = grants.find(grant => grant.store_id === context.storeId)?.store_id || grants[0]?.store_id;
-      const permissions = grants.filter(grant => grant.store_id === storeId).map(grant => grant.permission);
-      const role = product === 'customer' ? 'customer' : permissions.includes('approvals:decide') ? 'supervisor' : permissions.includes('conversations:read') ? 'staff' : 'merchant';
-      if (!permissions.length) {
-        await api.request('DELETE', '/openapi/v1/auth/sessions/current', undefined, { product, token: auth.access_token });
-        throw new Error('当前账户尚未分配店铺权限。');
-      }
-      account = { id: identity.actor_id, actorId: identity.actor_id, tenantId: identity.tenant_id, storeId, permissions, grants, username, role, token: auth.access_token, source: 'api' };
+    const startedAt = Date.now();
+    const body = { username, password: credentials.password };
+    if (product !== 'platform') body.tenant_id = credentials.tenant || context.tenantId;
+    const auth = await api.request('POST', `${authPath(product)}/sessions`, body, { product });
+    const identity = await api.request('GET', `${authPath(product)}/me`, undefined, { product, token: auth.access_token });
+    let account;
+    try {
+      account = accountFromIdentity(product, identity, { username: identity.username || username, token: auth.access_token, expiresAt: startedAt + auth.expires_in * 1000 });
+    } catch (error) {
+      if (error.code === 'ENTRY_FORBIDDEN') await api.request('DELETE', `${authPath(product)}/sessions/current`, undefined, { product, token: auth.access_token });
+      throw error;
     }
     const previous = returns[product];
     // 只有重新验证的同一身份可以恢复当前入口的未提交内容。
     const previousActor = session[product]?.id || previous?.actor;
     if (previousActor && previousActor !== account.id) clearProductData(product);
-    session[product] = account;
+    saveSession(product, account);
+    activateSession(product, account);
     delete returns[product];
     notify('登录成功。');
     const parts = previous?.path?.split('/').filter(Boolean) || [];
@@ -115,14 +169,21 @@ const context = {
     return account;
   },
   async logout(product) {
-    if (session[product]?.token) await api.request('DELETE', product === 'platform' ? '/openapi/v1/platform/auth/sessions/current' : '/openapi/v1/auth/sessions/current', undefined, { product });
+    const token = session[product]?.token;
+    if (token) {
+      try { await api.request('DELETE', `${authPath(product)}/sessions/current`, undefined, { product, token }); }
+      catch (error) { if (error.status !== 401) throw error; }
+    }
+    if (session[product]?.token && session[product]?.token !== token) return;
+    sessionRevisions[product]++;
+    clearTimeout(expiryTimers[product]);
     session[product] = null;
+    clearSession(product);
     delete returns[product];
     clearProductData(product);
-    navigate(product, 'login');
-    notify('已退出登录。');
+    if (currentProduct() === product) { navigate(product, 'login'); notify('已退出登录。'); }
   },
-  requireLogin(product, path) { returns[product] = { path, actor: session[product]?.id }; navigate(product, 'login'); },
+  requireLogin(product, path) { returns[product] = { path, actor: session[product]?.id || returns[product]?.actor }; navigate(product, 'login'); },
   productOf,
 };
 // ConfigProvider 为全部后台组件提供中文;模板中使用的组件通过官方安装方法逐个注册。

+ 3 - 1
frontend/src/pages/customer.js

@@ -2,6 +2,7 @@ import { computed, nextTick, onBeforeUnmount, reactive, ref, watch } from 'vue';
 import { Search, Sparkles, ChevronLeft, ChevronRight, House, LayoutGrid, ShoppingCart, UserRound, Keyboard, Mouse, Package, ClipboardList, MessageCircle, Headphones, MapPin, Heart, Settings, Link2, Store, Eye, EyeOff, CircleAlert } from '@lucide/vue';
 import { createCustomerService } from '../services/customer.js';
 import ExpandableText from '../components/ExpandableText.js';
+import TicketFeedback from '../components/TicketFeedback.js';
 
 export const customerPages = [
   ['home', '商城首页'], ['categories', '商品分类'], ['product', '商品详情'], ['cart', '购物车'], ['profile', '个人中心'],
@@ -14,7 +15,7 @@ const statusLabels = { DRAFT: '待客户确认', WAITING_REVIEW: '等待主管
 const statusText = { DRAFT: '请检查方案并确认,确认后交由主管审批。', WAITING_REVIEW: '主管正在审核,处理结果会显示在这里。', APPROVED: '审批通过,系统正在复核订单和规则。尚未登记外部操作时可以取消。', PROCESSING: '履约系统已受理换货,等待完成。', RESULT_UNKNOWN: '正在查询同一笔申请的受理结果。请继续等待,可申请人工协助。', COMPLETED: '履约系统已确认换货完成。', REJECTED: '审批未通过,请联系人工了解原因。', NEEDS_REVISION: '适用条件已变化,请修订方案并重新确认。', CANCELLED: '本次申请已取消。', FAILED: '履约系统明确拒绝,请查看业务反馈。' };
 
 export default {
-  components: { Search, Sparkles, ChevronLeft, ChevronRight, House, LayoutGrid, ShoppingCart, UserRound, Keyboard, Mouse, Package, ClipboardList, MessageCircle, Headphones, MapPin, Heart, Settings, Link2, Store, Eye, EyeOff, CircleAlert, ExpandableText },
+  components: { Search, Sparkles, ChevronLeft, ChevronRight, House, LayoutGrid, ShoppingCart, UserRound, Keyboard, Mouse, Package, ClipboardList, MessageCircle, Headphones, MapPin, Heart, Settings, Link2, Store, Eye, EyeOff, CircleAlert, ExpandableText, TicketFeedback },
   props: { context: { type: Object, required: true }, page: { type: String, required: true } },
   setup(props) {
     const ctx = props.context;
@@ -494,6 +495,7 @@ export default {
         </template>
       </template>
       <template v-else-if="page === 'identity'"><div class="hint">关联后,可以在本商家的两个渠道查询本人同一工单。各渠道聊天记录独立。</div><van-tag class="tag">{{state.linked ? 'Webhook 渠道已关联' : '尚未关联'}}</van-tag><ol><li>确认当前 H5 登录身份 {{ctx.session.customer?.username}}</li><li>在目标渠道完成一次身份验证</li><li>检查两个身份并确认关联</li></ol><section v-if="state.identityVerified || state.linked" class="box"><h3>已验证的身份</h3><p>H5:{{ctx.session.customer?.username}}</p><p>目标渠道:{{state.linkedIdentity?.display_name || state.challenge?.display_name}}</p><p>渠道账户:{{state.linkedIdentity?.external_subject || state.challenge?.external_subject}}</p></section><section v-else-if="state.challenge" class="box"><h3>目标渠道验证代码</h3><p class="identity-code">{{state.challenge.challenge_id}}</p><p>请在已登录的目标渠道提交代码,完成后查询验证结果。</p><p>截止时间:{{new Date(state.challenge.expires_at+'Z').toLocaleString()}}</p></section><div v-else class="placeholder">目标渠道验证入口<p>生成验证代码后,在已登录的目标渠道完成验证。</p></div><div class="actions"><van-button @click="verify" :disabled="busy || state.linked">{{state.challenge ? '重新生成验证代码' : '生成验证代码'}}</van-button><van-button v-if="state.challenge && !state.linked" @click="perform(()=>refreshIdentity())" :disabled="busy">查询验证结果</van-button><van-button @click="linkIdentity" :disabled="busy || !state.identityVerified || state.linked">确认关联</van-button></div></template>
+      <TicketFeedback v-if="page === 'ticket' && ticket" :key="ticket.ticket_id" :context="ctx" :service="api" :ticket="ticket" :disabled="busy" @updated="shared.ticket=$event" />
     </div>
     <van-tabbar v-if="root" class="shop-tabs" :model-value="page" :fixed="false" @change="nav"><van-tabbar-item v-for="item in [['home','首页','House'],['categories','分类','LayoutGrid'],['cart','购物车','ShoppingCart'],['profile','我的','UserRound']]" :name="item[0]" :aria-current="page===item[0]?'page':null"><template #icon><component :is="item[2]" :size="22" aria-hidden="true"/></template>{{item[1]}}</van-tabbar-item></van-tabbar><van-button v-if="!['chat','login'].includes(page)" class="ai-float" @click="consult(page==='product' && product ? product.name : page==='order' && shared.order ? '订单 '+shared.order.order_id : '')"><Sparkles :size="20" aria-hidden="true"/><strong>AI</strong><span>购物助手</span></van-button>
   </div>`,

Datei-Diff unterdrückt, da er zu groß ist
+ 4 - 2
frontend/src/pages/merchant.js


+ 2 - 0
frontend/src/services/customer.js

@@ -8,6 +8,8 @@ export function createCustomerService(api) {
     revise: (ticket, body, key) => request('POST', `/after-sales/proposals/${ticket.proposal_id}/revisions`, { ...body, expected_version: ticket.version }, key),
     confirm: (ticket, key) => request('POST', `/after-sales/proposals/${ticket.proposal_id}/confirm`, { proposal_version: ticket.proposal_version, expected_ticket_version: ticket.version }, key),
     ticket: id => request('GET', `/tickets/${id}`),
+    feedback: id => request('GET', `/tickets/${id}/feedback`),
+    submitFeedback: (id, body, key) => request('POST', `/tickets/${id}/feedback`, body, key),
     tickets: (storeId, orderId, cursor) => request('GET', `/tickets?store_id=${storeId}&limit=20${orderId ? `&order_id=${orderId}` : ''}${cursor ? `&cursor=${cursor}` : ''}`),
     cancel: (ticket, key) => request('POST', `/tickets/${ticket.ticket_id}/cancel`, { expected_version: ticket.version, reason: '客户主动取消换货申请' }, key),
     conversations: storeId => request('GET', `/conversations?store_id=${encodeURIComponent(storeId)}&limit=20`),

+ 1 - 0
frontend/src/services/merchant.js

@@ -76,6 +76,7 @@ export function createMerchantService(ctx) {
       return saveTicket(await command('/openapi/v1/after-sales/proposals', { order_id: shared.order.order_id, item_id: shared.order.item_id, quantity, reason, ...control }));
     },
     async ticket(id = shared.ticketId) { return id ? saveTicket(await request('GET', `/openapi/v1/tickets/${id}`)) : null; },
+    feedback: id => request('GET', `/openapi/v1/tickets/${id}/feedback`),
     tickets(cursor, subjectId) { return request('GET', `/openapi/v1/tickets?${new URLSearchParams({ store_id: store(), limit: '20', ...(cursor ? {cursor} : {}), ...(subjectId ? {subject_id:subjectId} : {}) })}`); },
     approvals(cursor) { return request('GET', `/openapi/v1/approvals?${new URLSearchParams({ store_id: store(), limit: '20', ...(cursor ? { cursor } : {}) })}`); },
     async decision(ticket, decision, reason) { requirePermission('approvals:decide'); return saveTicket(await command(`/openapi/v1/approvals/${ticket.approval_id}/decisions`, { expected_version: ticket.version, proposal_version: ticket.proposal_version, decision, reason })); },

+ 32 - 0
frontend/src/session.js

@@ -0,0 +1,32 @@
+const products = new Set(['customer', 'merchant', 'platform']);
+
+function key(product) {
+  if (!products.has(product)) throw new Error('登录入口无效。');
+  return `zhifu.session.${product}`;
+}
+
+export function readSession(product) {
+  const value = sessionStorage.getItem(key(product));
+  if (!value) return null;
+  let credential;
+  try { credential = JSON.parse(value); }
+  catch (error) {
+    if (!(error instanceof SyntaxError)) throw error;
+    clearSession(product);
+    throw new Error('登录记录无法读取,请重新验证登录。');
+  }
+  if (!credential || typeof credential.token !== 'string' || !credential.token || !Number.isFinite(credential.expiresAt) || typeof credential.username !== 'string') {
+    clearSession(product);
+    throw new Error('登录记录不完整,请重新验证登录。');
+  }
+  return { token: credential.token, expiresAt: credential.expiresAt, username: credential.username };
+}
+
+export function saveSession(product, account) {
+  // username 仅用于展示;身份、店铺及权限在每次页面刷新后由 auth/me 重新提供。
+  sessionStorage.setItem(key(product), JSON.stringify({ token: account.token, expiresAt: account.expiresAt, username: account.username }));
+}
+
+export function clearSession(product) {
+  sessionStorage.removeItem(key(product));
+}

+ 283 - 0
frontend/tests/session-refresh.mjs

@@ -0,0 +1,283 @@
+import assert from 'node:assert/strict';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { chromium } from 'playwright';
+
+// 仅使用真实身份接口与只读页面;会话撤销仅针对本脚本创建的会话。
+const accounts = JSON.parse(await readFile(new URL('../../.work/frontend-accounts.json', import.meta.url), 'utf8'));
+const platformAccount = JSON.parse(await readFile(new URL('../../.work/platform-accounts.json', import.meta.url), 'utf8'));
+const base = (process.env.FRONTEND_URL || 'http://127.0.0.1:18762').replace(/\/$/, '');
+const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || 'C:/Users/Administrator/AppData/Local/ms-playwright/chromium-1228/chrome-win64/chrome.exe' });
+const context = await browser.newContext();
+const page = await context.newPage();
+page.setDefaultTimeout(12000);
+page.setDefaultNavigationTimeout(12000);
+const products = ['customer', 'merchant', 'platform'];
+const labels = { customer: 'H5', merchant: '商家后台', platform: '平台后台' };
+const targets = { customer: 'customer/orders', merchant: 'merchant/service/quick-replies', platform: 'platform/dashboard' };
+const usernames = { customer: 'customer01', merchant: 'staff01', platform: platformAccount.username };
+const tokens = new Map();
+const identities = new Map();
+const createdTokens = new Map();
+const checks = [], browserErrors = [], unexpectedWrites = [];
+const started = Date.now();
+const maximumApiRequests = 500;
+const maximumResourceRequests = 3000;
+let apiRequests = 0, resourceRequests = 0, modelRequests = 0, requestLimitExceeded = false;
+
+function authPath(product) {
+  return product === 'platform' ? '/openapi/v1/platform/auth' : '/openapi/v1/auth';
+}
+function countApiRequest() {
+  apiRequests += 1;
+  if (apiRequests > maximumApiRequests) {
+    requestLimitExceeded = true;
+    throw new Error('真实 API 请求次数超过 500 次上限。');
+  }
+}
+page.on('pageerror', error => browserErrors.push(error.message));
+page.on('request', request => {
+  resourceRequests += 1;
+  if (resourceRequests > maximumResourceRequests) {
+    requestLimitExceeded = true;
+    void page.close();
+    return;
+  }
+  const path = new URL(request.url()).pathname;
+  if (!path.startsWith('/openapi/') && !path.startsWith('/admin/')) return;
+  // 达到上限时终止浏览器连接;不继续产生后续业务请求。
+  if (apiRequests >= maximumApiRequests) {
+    requestLimitExceeded = true;
+    void page.close();
+    return;
+  }
+  countApiRequest();
+  if (!['GET', 'HEAD'].includes(request.method()) && !/^\/openapi\/v1\/(platform\/)?auth\//.test(path)) unexpectedWrites.push(`${request.method()} ${path}`);
+  if (request.method() === 'POST' && (/\/agent\/runs$/.test(path) || /\/messages$/.test(path))) modelRequests += 1;
+});
+
+async function route(path) {
+  await page.evaluate(value => { location.hash = `/${value}`; }, path);
+  await page.waitForURL(url => url.hash === `#/${path}` || url.hash === `#/${path.split('/')[0]}/login`);
+}
+async function expectLogin(product) {
+  await page.waitForURL(url => url.hash === `#/${product}/login`);
+  if (product === 'customer') await page.locator('.customer-login-card').waitFor();
+  else {
+    await page.locator('.login-panel').waitFor();
+    assert.equal(await page.locator('.admin-sidebar').count(), 0, `${labels[product]}登录页隐藏管理内容`);
+  }
+}
+async function expectProtected(product) {
+  await page.waitForURL(url => url.hash === `#/${targets[product]}`);
+  if (product === 'customer') {
+    await page.locator('.mobile[data-page="orders"]').waitFor();
+    await page.waitForFunction(() => !document.querySelector('.mobile .content[aria-busy="true"]'));
+    await page.locator('.order-card').filter({ hasText: accounts.order_id }).waitFor();
+    assert.equal(await page.locator('.customer-login-card').count(), 0);
+  } else {
+    await page.locator('.admin-title').getByRole('heading', { name: product === 'merchant' ? '快捷回复' : '平台概览', exact: true }).waitFor();
+    await page.locator('.admin-session').getByText(usernames[product], { exact: true }).waitFor();
+    assert.equal(await page.locator('.login-panel').count(), 0);
+  }
+}
+function identityResponse(product) {
+  // 绑定本次操作新发起的请求,入口切换期间已有请求的响应不能成为刷新结果。
+  const token = tokens.get(product);
+  return page.waitForRequest(request => new URL(request.url()).pathname === `${authPath(product)}/me`
+    && request.headers().authorization === `Bearer ${token}`)
+    .then(async request => {
+      const response = await request.response();
+      assert.ok(response, `${labels[product]}身份查询取得真实 HTTP 响应`);
+      return { status: response.status(), body: await response.json() };
+    });
+}
+async function login(product) {
+  await expectLogin(product);
+  const panel = page.locator(product === 'customer' ? '.customer-login-card' : '.login-panel');
+  if (product === 'merchant') await panel.getByLabel('商家标识').fill(accounts.tenant_id);
+  await panel.getByLabel(product === 'customer' ? '账户' : '员工账户', { exact: true }).fill(usernames[product]);
+  await panel.getByLabel('密码', { exact: true }).fill(product === 'platform' ? platformAccount.password : accounts.password);
+  if (product === 'customer') await panel.getByRole('checkbox').check();
+  const received = page.waitForResponse(response => new URL(response.url()).pathname === `${authPath(product)}/sessions` && response.request().method() === 'POST');
+  await panel.getByRole('button', { name: product === 'customer' ? '登录并继续' : '登录', exact: true }).click();
+  const response = await received;
+  assert.equal(response.status(), 200, `${labels[product]}真实账户登录成功`);
+  const auth = await response.json();
+  tokens.set(product, auth.access_token);
+  createdTokens.set(auth.access_token, product);
+  await expectProtected(product);
+}
+async function reloadAndVerify(product) {
+  await expectProtected(product);
+  // 页面不建立 SSE;等待当前入口的身份与只读查询完成后再开始整页刷新。
+  await page.waitForLoadState('networkidle');
+  const before = page.url();
+  const received = identityResponse(product);
+  await page.reload();
+  const response = await received;
+  assert.equal(response.status, 200, `${labels[product]}刷新后重新验证真实身份`);
+  const identity = response.body;
+  if (product === 'platform') assert.equal(identity.username, usernames[product]);
+  else {
+    assert.equal(identity.kind, product === 'customer' ? 'CUSTOMER' : 'STAFF');
+    assert.equal(identity.tenant_id, accounts.tenant_id);
+    assert.ok(identity.grants.some(grant => grant.store_id === accounts.store_id));
+  }
+  if (identities.has(product)) assert.equal(identity.actor_id, identities.get(product));
+  else identities.set(product, identity.actor_id);
+  await expectProtected(product);
+  assert.equal(page.url(), before, `${labels[product]}刷新保留原地址`);
+}
+async function revoke(product, token = tokens.get(product)) {
+  countApiRequest();
+  const response = await context.request.delete(`${base}${authPath(product)}/sessions/current`, {
+    headers: { Authorization: `Bearer ${token}` }, timeout: 3000,
+  });
+  assert.equal(response.status(), 204, `${labels[product]}独立 HTTP 客户端撤销测试会话`);
+  createdTokens.delete(token);
+}
+async function checkNetworkRecovery(product) {
+  await route(targets[product]);
+  await expectProtected(product);
+  const network = await context.newCDPSession(page);
+  await network.send('Network.enable');
+  // 只阻断 API 连接,静态页面照常加载;不拦截或替换服务端响应。
+  await network.send('Network.setBlockedURLs', { urls: ['*/openapi/v1/*', '*/admin/v1/*'] });
+  try {
+    await page.reload();
+    const retry = page.getByRole('button', { name: '重新验证登录', exact: true });
+    await retry.waitFor();
+    assert.equal(new URL(page.url()).hash, `#/${targets[product]}`, '身份验证失败时保留当前地址');
+    assert.equal(await page.locator('.login-panel, .customer-login-card, .admin-sidebar, .order-card').count(), 0, '恢复失败期间不展示登录表单或受保护内容');
+    await network.send('Network.setBlockedURLs', { urls: [] });
+    const received = identityResponse(product);
+    await retry.click();
+    assert.equal((await received).status, 200);
+    await expectProtected(product);
+    checks.push(`${labels[product]}:API 连接中断后刷新保留凭据,恢复网络后手动重新验证`);
+  } finally {
+    await network.send('Network.setBlockedURLs', { urls: [] });
+    await network.detach();
+  }
+}
+async function logout(product) {
+  await route(product === 'customer' ? 'customer/memory' : targets[product]);
+  const received = page.waitForResponse(response => new URL(response.url()).pathname === `${authPath(product)}/sessions/current` && response.request().method() === 'DELETE');
+  if (product === 'customer') await page.locator('.mobile').getByRole('button', { name: '退出登录', exact: true }).click();
+  else {
+    await page.locator('.admin-session').getByRole('button', { name: '退出登录', exact: true }).click();
+    await page.getByRole('dialog').getByRole('button', { name: '确认操作', exact: true }).click();
+  }
+  assert.equal((await received).status(), 204);
+  createdTokens.delete(tokens.get(product));
+  tokens.delete(product);
+  await expectLogin(product);
+  await page.reload();
+  await expectLogin(product);
+  await route(targets[product]);
+  await expectLogin(product);
+}
+
+async function checkUntrustedStorage() {
+  await route(targets.merchant);
+  await expectProtected('merchant');
+  // 浏览器缓存允许被改写;额外权限字段不能改变真实服务端身份。
+  await page.evaluate(() => {
+    const key = 'zhifu.session.merchant';
+    const credential = JSON.parse(sessionStorage.getItem(key));
+    sessionStorage.setItem(key, JSON.stringify({ ...credential, role: 'supervisor', permissions: ['approvals:decide'], id: 'untrusted-actor', actorId: 'untrusted-actor', storeId: 'untrusted-store' }));
+  });
+  await reloadAndVerify('merchant');
+  assert.equal(await page.getByRole('navigation', { name: '系统导航' }).getByRole('button', { name: '审批待办', exact: true }).count(), 0);
+  await route('merchant/approval/review');
+  await page.getByRole('heading', { name: '页面不可访问', exact: true }).waitFor();
+  assert.equal(await page.getByRole('button', { name: '提交审批决定', exact: true }).count(), 0);
+  checks.push('商家缓存附加身份和权限字段后,刷新仍按真实客服权限显示菜单并阻止审批页面');
+
+  await route(targets.customer);
+  await expectProtected('customer');
+  const previousToken = tokens.get('customer');
+  await page.evaluate(() => { sessionStorage.setItem('zhifu.session.customer', '{invalid-json'); });
+  await page.reload();
+  await page.getByText('登录记录无法读取,请重新验证登录。', { exact: true }).waitFor();
+  await page.getByRole('button', { name: '重新验证登录', exact: true }).click();
+  await expectLogin('customer');
+  await revoke('customer', previousToken);
+  for (const product of ['merchant', 'platform']) {
+    await route(targets[product]);
+    await expectProtected(product);
+  }
+  await route(targets.customer);
+  await login('customer');
+  checks.push('H5 缓存 JSON 损坏后显示恢复错误,手动重新验证可以正常登录,其他入口保持身份');
+}
+
+async function run() {
+  await page.goto(`${base}/#/${targets.customer}`);
+  for (const product of products) {
+    if (product !== 'customer') await route(targets[product]);
+    await login(product);
+    await reloadAndVerify(product);
+    checks.push(`${labels[product]}:真实登录后刷新保留身份与原页面地址`);
+  }
+  for (const product of products) {
+    await route(targets[product]);
+    await reloadAndVerify(product);
+  }
+  checks.push('同一标签页的三个入口分别恢复本人身份,切换入口并刷新保持独立会话');
+  for (const product of products) await checkNetworkRecovery(product);
+  for (const product of products) {
+    await route(targets[product]);
+    await expectProtected(product);
+    await revoke(product);
+    await page.waitForLoadState('networkidle');
+    const received = identityResponse(product);
+    await page.reload();
+    assert.equal((await received).status, 401);
+    await expectLogin(product);
+    await page.reload();
+    await expectLogin(product);
+    for (const other of products.filter(value => value !== product)) {
+      await route(targets[other]);
+      await expectProtected(other);
+    }
+    await route(targets[product]);
+    await login(product);
+    checks.push(`${labels[product]}:服务端撤销后刷新要求登录,其他入口会话继续有效`);
+  }
+  await checkUntrustedStorage();
+  for (const product of products) {
+    await logout(product);
+    for (const other of products.filter(value => tokens.has(value))) {
+      await route(targets[other]);
+      await expectProtected(other);
+    }
+    checks.push(`${labels[product]}:主动退出后刷新与访问业务页均需登录,其他有效入口保持登录`);
+  }
+  assert.deepEqual(browserErrors, [], '浏览器运行异常');
+  assert.deepEqual(unexpectedWrites, [], '整个流程仅允许创建、读取和撤销登录会话');
+  assert.equal(modelRequests, 0, '生成模型调用次数');
+  assert.equal(requestLimitExceeded, false);
+  const result = {
+    date: new Date().toISOString(), checks, browserErrors,
+    apiRequests, resourceRequests, maximumApiRequests, maximumResourceRequests,
+    elapsedMs: Date.now() - started, maximumDurationMs: 180000,
+    generatedModelCalls: 0, businessWrites: 0,
+    networkFailure: 'Chromium 阻断 API 连接后刷新,再解除阻断并手动重新验证登录。',
+  };
+  await mkdir(new URL('../../.work/frontend-verification/', import.meta.url), { recursive: true });
+  await writeFile(new URL('../../.work/frontend-verification/session-refresh.json', import.meta.url), JSON.stringify(result, null, 2));
+  console.log(JSON.stringify(result, null, 2));
+}
+
+let deadline;
+try {
+  await Promise.race([
+    run(),
+    new Promise((_, reject) => { deadline = setTimeout(() => reject(new Error('会话刷新验证超过 180 秒上限。')), 180000); }),
+  ]);
+} finally {
+  clearTimeout(deadline);
+  for (const [token, product] of createdTokens) await revoke(product, token);
+  await browser.close();
+}

+ 68 - 0
scripts/verify_host_channel_worker.py

@@ -0,0 +1,68 @@
+import asyncio
+import json
+import signal
+import subprocess
+import sys
+import time
+
+from verify_channel_worker import MAX_SECONDS, ROOT, inspect_environment
+
+
+def main():
+    enabled, pending, initial = asyncio.run(inspect_environment())
+    if enabled or pending:
+        raise RuntimeError("生命周期验收要求当前没有启用渠道和等待投递记录")
+    windows = sys.platform == "win32"
+    started = time.monotonic()
+    # 独立进程组仅接收本次测试的停止信号;Windows 需要从终端运行以共享控制台。
+    process = subprocess.Popen(
+        [sys.executable, "-m", "zhifu.modules.platform.delivery"],
+        cwd=ROOT,
+        stdout=subprocess.PIPE,
+        stderr=subprocess.PIPE,
+        text=True,
+        creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if windows else 0,
+    )
+    try:
+        while time.monotonic() - started < MAX_SECONDS:
+            if process.poll() is not None:
+                stdout, stderr = process.communicate(timeout=5)
+                raise RuntimeError(f"渠道工作进程提前退出:{stdout}\n{stderr}")
+            _, _, current = asyncio.run(inspect_environment())
+            if current - initial:
+                break
+            time.sleep(0.25)
+        else:
+            raise RuntimeError("渠道工作进程未在 25 秒期限内登记心跳")
+        assert len(current - initial) == 1, "渠道工作进程应只登记一份心跳"
+        # 完成一次空队列轮询后发送真实控制台事件或 POSIX 信号,验证完整清理过程。
+        time.sleep(1)
+        process.send_signal(signal.CTRL_BREAK_EVENT if windows else signal.SIGTERM)
+        stdout, stderr = process.communicate(timeout=10)
+        assert process.returncode == 0, (process.returncode, stdout, stderr)
+        assert "worker=channel-delivery stopped" in stdout, stdout
+        _, _, after = asyncio.run(inspect_environment())
+        assert after == initial, "正常停止后应立即清除本进程心跳"
+        report = {
+            "role": "channel-delivery",
+            "platform": sys.platform,
+            "heartbeat_count": 1,
+            "signal": "CTRL_BREAK_EVENT" if windows else "SIGTERM",
+            "exit_code": process.returncode,
+            "heartbeat_removed": True,
+            "seconds": round(time.monotonic() - started, 2),
+            "generated_model_requests": 0,
+            "external_delivery_requests": 0,
+        }
+        target = ROOT / ".work/channel-worker-host-lifecycle.json"
+        target.parent.mkdir(exist_ok=True)
+        target.write_text(json.dumps(report, indent=2), encoding="utf-8")
+        print(json.dumps(report))
+    finally:
+        if process.poll() is None:
+            process.kill()
+            process.communicate(timeout=5)
+
+
+if __name__ == "__main__":
+    main()

+ 5 - 6
src/zhifu/modules/agent_runtime/service.py

@@ -17,17 +17,16 @@ from zhifu.modules.merchant.models import AgentRelease
 from zhifu.modules.merchant.service import current_release_version
 
 
-async def queue_run(session, auth, store_id, key, digest, messages, *, conversation=None):
+async def queue_run(session, auth, store_id, key, digest, messages):
     settings = AgentSettings()
-    # 会话保留创建时的发布版本;独立运行读取当前发布,运行参数持久保存供重启继续使用。
-    version = conversation.agent_release_version if conversation else await current_release_version(
-        session, auth.tenant_id, store_id
-    )
+    # 接受新运行时读取店铺当前发布,已有会话同样使用此时的最新配置。
+    # 配置保存在本次运行中,补充资料、等待审批和重启恢复继续使用同一份配置。
+    version = await current_release_version(session, auth.tenant_id, store_id)
     release = await session.get(
         AgentRelease, (auth.tenant_id, store_id, version)
     ) if version else None
     require(version is None or release is not None,
-            "RELEASE_NOT_FOUND", "会话引用的发布版本不可用", 409)
+            "RELEASE_NOT_FOUND", "当前发布版本不可用", 409)
     snapshot = release.manifest if release else {
         "model_name": settings.model_name, "model_url": settings.model_url,
         "credential_env": settings.credential_env, "model_timeout": settings.model_timeout,

+ 1 - 3
src/zhifu/modules/channels/service.py

@@ -14,7 +14,6 @@ from zhifu.modules.conversations.api import Message, accept_message
 from zhifu.modules.conversations.models import Conversation
 from zhifu.modules.identity.access_models import AccessSession, Principal
 from zhifu.modules.identity.auth import AuthContext, authorize_store, now
-from zhifu.modules.merchant.service import current_release_version
 from zhifu.modules.storefront.models import ChannelIdentity
 
 
@@ -61,8 +60,7 @@ async def accept_envelope(session, application, body, request_id):
         conversation = Conversation(id=uuid4().bytes, tenant_id=auth.tenant_id,
             store_id=application.store_id, subject_id=auth.actor_id,
             request_key=conversation_key, channel="webhook", external_subject=body.external_user_id,
-            created_at=now(), agent_release_version=await current_release_version(
-                session, auth.tenant_id, application.store_id))
+            created_at=now())
         session.add(conversation)
         await session.flush()
     inbox_id = uuid4()

+ 4 - 5
src/zhifu/modules/conversations/api.py

@@ -18,7 +18,6 @@ from zhifu.modules.conversations.models import Conversation, ConversationEvent
 from zhifu.modules.conversations.service import access, emit, event_view, view
 from zhifu.modules.identity.auth import authenticate, authorize_store, now
 from zhifu.modules.memory.service import enqueue as enqueue_memory
-from zhifu.modules.merchant.service import current_release_version
 from zhifu.modules.notifications.service import notify
 
 router = APIRouter(
@@ -88,6 +87,7 @@ async def create(body: Create, request: Request, key: IdempotencyKey):
 
     Idempotency-Key 在租户和客户范围内唯一。重试返回原会话当前状态,换店铺重用该键返回 409。
     channel 为 h5,created_at 是带 UTC 时区的会话创建时间,重复请求保持原创建时间。
+    会话保存聊天与控制状态,每次创建 Agent 运行时读取店铺当前发布配置。
     """
     async with request.app.state.sessions.begin() as session:
         auth = await authenticate(request, session)
@@ -112,9 +112,6 @@ async def create(body: Create, request: Request, key: IdempotencyKey):
             store_id=body.store_id.bytes,
             request_key=key,
             created_at=now(),
-            agent_release_version=await current_release_version(
-                session, auth.tenant_id, body.store_id.bytes
-            ),
         )
         session.add(conversation)
         await session.flush()
@@ -189,6 +186,8 @@ async def message(conversation_id: UUID, body: Message, request: Request, key: I
     """客户只能发送本人会话消息;客服需要 conversations:reply 且已接管该会话。
 
     客户在 AGENT 模式发言会创建异步运行,payload.run_id 返回运行标识;HUMAN 模式仅保存消息。
+    新运行读取店铺当前已发布版本,原会话的历史消息继续作为上下文。
+    已接受的运行固定保存配置;幂等重试、补充资料、审批等待和重启恢复保留原运行配置。
     同一会话只允许一个活动运行,否则返回 RUN_IN_PROGRESS。
     Agent 运行遵循租户并发上限与交互额度预留;额度不足时排队,账户任务已满返回 429。
     每次模型请求前检查当日 token 预算;不足时运行失败,run.failed 事件包含
@@ -237,7 +236,7 @@ async def accept_message(session, auth, conversation, body, key):
         messages.reverse()
         messages.append(message_to_dict(HumanMessage(content=body.text)))
         run = await queue_run(session, auth, conversation.store_id, "conv:" + uuid4().hex,
-                              digest, messages, conversation=conversation)
+                              digest, messages)
         run.conversation_id = conversation.id
         run.control_version = conversation.control_version
         conversation.active_run_id = run.id

+ 1 - 0
src/zhifu/modules/conversations/models.py

@@ -32,6 +32,7 @@ class Conversation(Base):
     sequence: Mapped[int] = mapped_column(BIGINT(unsigned=True), default=0)
     stream_cursor: Mapped[int] = mapped_column(BIGINT(unsigned=True), default=0)
     created_at: Mapped[datetime] = mapped_column(DATETIME(fsp=6))
+    # 兼容已有数据库列;运行配置由 AgentRun 保存,此字段不参与发布版本选择。
     agent_release_version: Mapped[int | None] = mapped_column(BIGINT(unsigned=True))
 
 

+ 2 - 1
src/zhifu/modules/merchant/api.py

@@ -495,7 +495,8 @@ async def publish_release(
 ):
     """需要 agent:publish。重新检查清单摘要、规则、知识、模型凭据及操作者确认。
 
-    发布记录不可变,新会话引用最新发布;已有会话和等待任务保留其配置快照。
+    发布记录不可变,发布后的新运行使用最新配置,已有会话也按此规则处理下一次消息。
+    已接受的运行保留其配置快照,包括补充资料、等待审批和重启恢复。
     用户确认表示已检查回归记录,此接口不执行付费模型回归。幂等重试不会重复创建版本。
     """
     async with request.app.state.sessions.begin() as session:

+ 39 - 7
src/zhifu/operations/worker.py

@@ -1,6 +1,8 @@
 import argparse
 import asyncio
 import signal
+import sys
+from contextlib import contextmanager
 from importlib import import_module
 
 from zhifu.core.telemetry import heartbeat
@@ -14,12 +16,45 @@ MODULES = {
 }
 
 
+@contextmanager
+def stop_signals(stopped):
+    """按运行平台登记停止信号,退出或初始化失败时恢复已有处理函数。"""
+    loop = asyncio.get_running_loop()
+    windows = sys.platform == "win32"
+    names = (signal.SIGTERM, signal.SIGINT)
+    if windows:
+        names += (signal.SIGBREAK,)
+    previous = {}
+
+    def request_stop(signum, frame):
+        # Windows 使用同步信号处理函数,通过线程安全调度唤醒事件循环。
+        loop.call_soon_threadsafe(stopped.set)
+
+    try:
+        for name in names:
+            handler = signal.getsignal(name)
+            if windows:
+                signal.signal(name, request_stop)
+            else:
+                loop.add_signal_handler(name, stopped.set)
+            previous[name] = handler
+        yield
+    finally:
+        for name, handler in previous.items():
+            if not windows:
+                loop.remove_signal_handler(name)
+            signal.signal(name, handler)
+
+
 async def run(role):
     main = import_module(MODULES[role]).main
     stopped = asyncio.Event()
-    loop = asyncio.get_running_loop()
-    for name in (signal.SIGTERM, signal.SIGINT):
-        loop.add_signal_handler(name, stopped.set)
+    with stop_signals(stopped):
+        await run_monitored(role, main, stopped)
+    print(f"worker={role} stopped", flush=True)
+
+
+async def run_monitored(role, main, stopped):
     async with heartbeat(role):
         task = asyncio.create_task(main())
         shutdown = asyncio.create_task(stopped.wait())
@@ -37,12 +72,9 @@ async def run(role):
         finally:
             shutdown.cancel()
             await asyncio.gather(shutdown, return_exceptions=True)
-            for name in (signal.SIGTERM, signal.SIGINT):
-                loop.remove_signal_handler(name)
-    print(f"worker={role} stopped", flush=True)
 
 
 if __name__ == "__main__":
-    parser = argparse.ArgumentParser(description="容器工作进程与停止信号处理")
+    parser = argparse.ArgumentParser(description="工作进程、心跳与停止信号处理")
     parser.add_argument("role", choices=MODULES)
     asyncio.run(run(parser.parse_args().role))

+ 41 - 12
tests/test_merchant.py

@@ -11,6 +11,7 @@ from tests.test_after_sales import scenario  # noqa: F401
 from zhifu.modules.agent_runtime.config import AgentSettings
 from zhifu.modules.agent_runtime.models import AgentRun, ModelAttempt
 from zhifu.modules.agent_runtime.service import queue_run
+from zhifu.modules.conversations.api import Message, accept_message
 from zhifu.modules.conversations.models import Conversation
 from zhifu.modules.identity.access_models import Principal, StoreGrant
 from zhifu.modules.identity.auth import authenticate_hash
@@ -132,7 +133,7 @@ async def test_channel_validation_and_release_requirements(scenario):  # noqa: F
     assert release.status_code == 409 and release.json()["code"] == "RELEASE_NOT_READY"
 
 
-async def persist_cancelled_snapshot(s, conversation_id=None):
+async def persist_cancelled_snapshot(s, conversation_id=None, key=None):
     # 队列记录与取消状态在同一事务提交,工作进程无法看到可执行任务。
     token = s["headers"]["customer"]["Authorization"].removeprefix("Bearer ")
     async with s["app"].state.sessions.begin() as session:
@@ -140,15 +141,24 @@ async def persist_cancelled_snapshot(s, conversation_id=None):
             session, hashlib.sha256(token.encode()).digest(), str(uuid4())
         )
         conversation = await session.get(
-            Conversation, UUID(conversation_id).bytes
+            Conversation, UUID(conversation_id).bytes, with_for_update=True
         ) if conversation_id else None
-        request_key = str(uuid4())
-        run = await queue_run(
-            session, auth, s["store"].bytes, request_key,
-            hashlib.sha256(request_key.encode()).digest(),
-            [message_to_dict(HumanMessage(content="查询当前店铺规则"))],
-            conversation=conversation,
-        )
+        request_key = key or str(uuid4())
+        if conversation:
+            event = await accept_message(
+                session, auth, conversation,
+                Message(text="查询当前店铺规则",
+                        expected_control_version=conversation.control_version),
+                request_key,
+            )
+            run = await session.get(AgentRun, UUID(event["payload"]["run_id"]).bytes)
+            conversation.active_run_id = None
+        else:
+            run = await queue_run(
+                session, auth, s["store"].bytes, request_key,
+                hashlib.sha256(request_key.encode()).digest(),
+                [message_to_dict(HumanMessage(content="查询当前店铺规则"))],
+            )
         run.status = "CANCELLED"
         return run.id
 
@@ -212,7 +222,15 @@ async def test_agent_release_success_and_execution_snapshots(scenario):  # noqa:
         stale = await call("POST", release_path, "agent", body)
         assert stale.status_code == 409 and stale.json()["code"] == "VERSION_CONFLICT"
         old_conversation = await create_conversation()
-        first_run_id = await persist_cancelled_snapshot(s, old_conversation)
+        async with s["app"].state.sessions.begin() as session:
+            old = await session.get(Conversation, UUID(old_conversation).bytes)
+            assert old.agent_release_version is None
+            # 保留升级前会话可能存在的历史列值,验证它不再参与后续运行的版本选择。
+            old.agent_release_version = 1
+        first_message_key = str(uuid4())
+        first_run_id = await persist_cancelled_snapshot(
+            s, old_conversation, first_message_key
+        )
 
         revised = await call("PUT", document_path, "agent", {
             "expected_version": published.json()["version"], "title": "键盘售后服务说明",
@@ -236,6 +254,10 @@ async def test_agent_release_success_and_execution_snapshots(scenario):  # noqa:
         second = second_response.json()
         assert second["version"] == 2
         assert second["manifest"]["knowledge"][0]["version"] == republished.json()["version"]
+        # 同一消息重试继续返回原运行,不因中途发布而重复创建运行或替换快照。
+        assert await persist_cancelled_snapshot(
+            s, old_conversation, first_message_key
+        ) == first_run_id
         new_conversation = await create_conversation()
         old_run_id = await persist_cancelled_snapshot(s, old_conversation)
         new_run_id = await persist_cancelled_snapshot(s, new_conversation)
@@ -243,17 +265,24 @@ async def test_agent_release_success_and_execution_snapshots(scenario):  # noqa:
         async with s["app"].state.sessions.begin() as session:
             old = await session.get(Conversation, UUID(old_conversation).bytes)
             new = await session.get(Conversation, UUID(new_conversation).bytes)
-            assert old.agent_release_version == 1 and new.agent_release_version == 2
+            assert old.agent_release_version == 1 and new.agent_release_version is None
             for run_id, expected in [
-                (first_run_id, first), (old_run_id, first),
+                (first_run_id, first), (old_run_id, second),
                 (new_run_id, second), (independent_run_id, second),
             ]:
                 stored = await session.get(AgentRun, run_id)
                 assert stored.release_snapshot == expected["manifest"]
+                assert stored.graph_version == expected["manifest"]["graph_version"]
                 assert stored.model_name == settings.model_name
                 assert stored.model_url == settings.model_url
                 assert stored.model_credential_env == settings.credential_env
                 assert stored.status == "CANCELLED" and stored.model_calls == 0
+            continued = await session.get(AgentRun, old_run_id)
+            assert continued.conversation_id == old.id
+            assert [item["data"]["content"] for item in continued.messages] == [
+                "查询当前店铺规则", "查询当前店铺规则",
+            ]
+            assert old.sequence == 2 and old.active_run_id is None
             attempts = await session.scalar(select(func.count()).select_from(ModelAttempt).where(
                 ModelAttempt.tenant_id == s["tenant"].bytes,
             ))

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.