10 Commits

Author SHA1 Message Date
caoxiaozhu
3131112952 style(web): 调整 AI 模式用户消息气泡布局与引用图标
- 用户消息气泡的操作区与时间右对齐
- 引用按钮图标由 mdi-reply 改为 mdi-format-quote-open
2026-06-18 22:13:09 +08:00
caoxiaozhu
a2f67af13e docs: 新增 X-Financial 改进路线图 2026-06-18 22:12:38 +08:00
caoxiaozhu
0cde1f8990 feat(web): 工作台 AI 模式与差旅/风险建议交互优化
- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
2026-06-18 22:12:24 +08:00
caoxiaozhu
a6674a1e76 feat(steward): off_topic 场景细分与引导回复
- 将业务无关输入细分为 greeting / meaningless / off_business 三类场景
- 新增 StewardOffTopicAgent,用 function calling 生成管家语气引导回复
- steward endpoint 与 user_agent_application 串联 off_topic 引导话术
- 补充 planner 与 user agent 的 off_topic 覆盖测试
2026-06-18 22:12:10 +08:00
caoxiaozhu
127d603e7d feat(ontology): 仅放行财务业务相关问题的信号校验
- 新增 _has_supported_business_signal,在加载目录前拦截非财务问题并抛错
- 同步重构 ontology 服务测试覆盖业务信号判定分支
2026-06-18 22:12:00 +08:00
caoxiaozhu
3f17619e0c fix(auth): 登录目录就绪幂等化与并发控制
- employee/settings/user_session_metrics 的 ensure_*_ready 改为按 bind 缓存 + 锁,
  避免每次登录重复建表与并发场景下的竞态
- auth 登录链路先查员工再降级触发目录就绪,并吞掉查询期 SQLAlchemy 异常
- 默认管理员账号由 superadmin 迁移为 admin,兼容历史账号回填
- 补充登录降级与设置持久化相关测试
2026-06-18 22:11:53 +08:00
caoxiaozhu
59ba76c74a feat(startup): 服务端启动 bootstrap 与缓存预热
- 新增 STARTUP_BOOTSTRAP_ENABLED / STARTUP_CACHE_WARMUP_ENABLED 配置开关
- lifespan 拆分 bootstrap 步骤并后台线程预热缓存,失败可降级继续启动
- server_start.sh / web_start.sh 扩展 SERVER_PORT、启动与调度开关的 env 覆盖
- bootstrap_paddleocr_mobile.sh 改用 python3 并补充 poppler-utils 依赖
- 补充启动 bootstrap 与 env 覆盖优先级测试
2026-06-18 22:11:37 +08:00
caoxiaozhu
35372c6661 feat(rules): 更新差旅与通信费用等财务规则表 2026-06-18 22:11:13 +08:00
caoxiaozhu
38653fa365 chore(storage): 清理用户历史报销票据附件
移除测试期残留的 receipt_folder 附件与预览文件,后续通过归档目录维护。
2026-06-18 22:11:10 +08:00
caoxiaozhu
c28e99b714 chore(gitignore): 忽略 .nezha/ 与 .omo/ 本地工具目录 2026-06-18 22:11:06 +08:00
129 changed files with 10736 additions and 3545 deletions

2
.gitignore vendored
View File

@@ -24,3 +24,5 @@ server/storage/finance_reports/
test-results/ test-results/
.codex-remote-attachments/ .codex-remote-attachments/
tmp-*.png tmp-*.png
.nezha/
.omo/

572
docs/improvement-roadmap.md Normal file
View File

@@ -0,0 +1,572 @@
# X-Financial 改进路线图
> 本文档基于 2026-06-18 对代码库的算法层、业务层、工程层综合评估生成。
> 每项改进都附有文件路径佐证,便于后续定位和追踪。
> 维护规则:状态变更请在对应章节同步更新;新增改进项追加到对应优先级末尾。
## 状态约定
| 标记 | 含义 |
|---|---|
| ⏳ | 待启动 |
| 🔄 | 进行中 |
| ✅ | 已完成 |
| ⏸️ | 暂缓(需说明原因) |
| ❌ | 取消(需说明原因) |
## 优先级矩阵
| 优先级 | 编号 | 标题 | 状态 |
|---|---|---|---|
| 🔴 P0 安全 | B2 | HTTP Header 权限漏洞 | ⏳ |
| 🔴 P0 业务核心 | B1 | 审批流转交/加签/撤回/会签 | ⏳ |
| 🔴 P0 共识 | B10 | 800 行硬约束破防 | ⏳ |
| 🟠 P1 算法 | A1 | 风险评分权重自适应 | ⏳ |
| 🟠 P1 算法 | A4 | LLM 票据分类 + 字段置信度 | ⏳ |
| 🟠 P1 算法 | A7 | LLM 幻觉检测 | ⏳ |
| 🟠 P1 业务 | B6 | 规则覆盖不均衡 | ⏳ |
| 🟡 P2 业务 | B3 | 申请/报销拆表 | ⏳ |
| 🟡 P2 业务 | B4 | 可配置审批矩阵 | ⏳ |
| 🟡 P2 算法 | A2 | 异常检测自适应阈值 | ⏳ |
| 🟢 P3 算法 | A3 | 多模型异常检测集成 | ⏳ |
| 🟢 P3 算法 | A5 | 票据分类持续学习 | ⏳ |
| 🟢 P3 算法 | A6 | Prompt 模板集中管理 | ⏳ |
| 🟢 P3 算法 | A8 | 规则冗余建模 | ⏳ |
| 🟢 P3 算法 | A9 | 行为画像 fairness 保护 | ⏳ |
| 🟢 P3 业务 | B5 | 预算跨期/跨科边界 | ⏳ |
| 🟢 P3 业务 | B7 | 审计日志防篡改 | ⏳ |
| 🟢 P3 业务 | B8 | 审批 SLA 监控 | ⏳ |
| 🟢 P3 业务 | B9 | 支付与凭证对接 | ⏳ |
| 🟢 P3 算法 | A10 | 算法模块 800 行拆分 | ⏳ |
---
## 一、算法层面改进
### A1. 风险评分权重自适应调优 ⏳
**优先级**:🟠 P1
**证据**
- `server/src/app/algorithem/risk_graph/engine.py:457-465`
- `server/src/app/services/risk_rule_scoring.py:16-23`
**当前实现**
```python
risk_score = 0.35*S_rule + 0.25*S_anomaly + 0.20*S_graph + 0.15*S_policy + 0.05*S_history
```
五维权重和六因子权重均为硬编码常量,无法反映规则有效性差异。
**问题**
- 已有 `RiskObservationFeedback` 表收集人工反馈,但反馈数据**未反向更新权重**
- 不同费用类型(差旅/招待/通信)的合理权重差异大,目前一刀切
**改进方向**
- 按费用类型分组的权重向量
- 定期基于反馈数据做 logistic regression / Bayesian 更新
- 权重变更需版本化、可回滚
**验收标准**
- [ ] 权重从配置/数据库读取,不再硬编码
- [ ] 反馈数据能触发权重自动调整
- [ ] 不同费用类型可配置独立权重
- [ ] 调整过程有日志和效果对比
---
### A2. 金额异常检测自适应阈值 ⏳
**优先级**:🟡 P2
**证据**`server/src/app/algorithem/risk_graph/engine.py:221-261`
**当前实现**:固定分档阈值 `1.0x→0, 1.25x→30, 1.5x→55, 2.0x→75, 3.0x→95`
**问题**
- 通信费(小额高频)和差旅(大额低频)的"1.5x"含义完全不同
- peer p75 在新部门/新费用类型时样本稀疏
- 已识别 `peer_baseline_insufficient` 不确定性,但无冷启动方案
**改进方向**
- 改为自适应分位数(基于历史数据动态计算)
-`(费用类型 × 部门层级)` 分组维护基线
- 冷启动:全局基线 + 小样本置信度折扣
**验收标准**
- [ ] 阈值按业务维度分组,不再全局统一
- [ ] 新部门/新费用类型有冷启动策略
- [ ] 基线样本不足时有降级机制
- [ ] 增加单元测试覆盖冷启动场景
---
### A3. 多模型异常检测集成策略 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/algorithem/risk_graph/anomaly_models.py`
**当前实现**5 个模型独立输出(`robust_statistics / isolation_proxy / local_outlier / temporal_jump / periodic_deviation`),无集成。
**问题**
- 多模型同时报警时聚合规则未定义
- 单模型 vs 多模型共识的严重度差异未体现
- 模型间冲突无裁决机制
**改进方向**
- 引入 `AnomalyEnsembler` 集成层
- 输出 `consensus_score` + `model_disagreement_flag`
- 高风险图谱评分区分"单点异常"和"多维共识异常"
**验收标准**
- [ ] 实现集成层并接入 engine.py
- [ ] 集成结果包含共识度指标
- [ ] 单元测试覆盖各种模型组合情况
---
### A4. LLM 票据分类与字段置信度 ⏳
**优先级**:🟠 P1
**证据**
- `server/src/app/services/document_intelligence.py:143-153``_classify_with_model` 当前 `return None`
- `server/src/app/services/document_intelligence.py:20-37`(字段抽取全正则)
**问题**
- 规则层单点支撑,非标准票据格式失效
- 无字段级置信度评分,无法判断哪些抽取值需要人工复核
- LLM 分类合并策略代码已存在但未启用
**改进方向**
1. 启用 LLM 分类层(合并逻辑可直接复用)
2. 字段抽取增加置信度:`{field: {value, confidence, source}}`
3. 低置信度字段(< 0.7)自动标记"需人工核对"
**验收标准**
- [ ] LLM 分类层启用并通过对比测试
- [ ] 每个抽取字段附带置信度评分
- [ ] 低置信度字段触发人工复核标记
- [ ] 提供准确率回归测试集
---
### A5. 票据分类持续学习 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/services/document_intelligence_rules.py:120``score_bias` 硬编码)
**问题**新票种ETC 电子票、滴滴行程单新模板)需开发改代码才能识别。
**改进方向**
- 分类规则做成可配置 + 可学习
- 管理员上传样本自动更新关键词权重
- 基于历史已分类票据做 TF-IDF 训练
**验收标准**
- [ ] 后台提供分类规则管理界面
- [ ] 新票种可通过样本上传识别
- [ ] 历史数据可训练关键词权重
---
### A6. Prompt 模板集中管理 ⏳
**优先级**:🟢 P3
**证据**
- `server/src/app/services/ontology_extraction.py`
- `server/src/app/services/ontology_detection.py`
- `server/src/app/services/risk_rule_generation.py`
- `server/src/app/services/user_agent_response.py`
- `server/src/app/services/user_agent_application.py`
- `server/src/app/services/user_agent_review_core.py`
- `server/src/app/services/knowledge_rag.py:214`(查询重写硬编码在方法内)
**问题**
- Prompt 散落在 12+ 文件,无版本化、无回滚、无 A/B 测试
- 相同意图的 prompt 在不同 service 中重复
**改进方向**
- 建立 `prompts/` 集中目录 + `PromptRegistry`
-`(意图, 版本)` 管理
- 支持灰度发布和效果对比
**验收标准**
- [ ] 所有 prompt 迁移到集中目录
- [ ] 支持版本化与回滚
- [ ] 提供 A/B 测试接口
---
### A7. LLM 幻觉检测与事实校验 ⏳
**优先级**:🟠 P1
**证据**:当前系统缺少 LLM 输出的显式幻觉检测。本体解析有 `confidence` 门禁,但生成的解释文本、规则建议、对话回复无校验。
**问题**
- LLM 可能编造不存在的政策条款、错误金额阈值、虚构审批人
- 风险图谱解释文本幻觉会误导审批人
- 唯一兜底是 `data_quality_gate`,仅管输入数据质量
**改进方向**
- 关键输出(金额、政策条款、规则编号)做 grounded checkLLM 输出后用规则引擎反向校验
- 对话回复中的具体数字、日期强制引用证据片段RAG 引用)
**验收标准**
- [ ] 关键数值字段有反向校验机制
- [ ] 对话回复中的事实声明可追溯到证据
- [ ] 校验失败时有明确降级策略
---
### A8. 规则冗余/相关性建模 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/services/risk_rule_scoring.py`(多规则命中简单求和/max
**问题**:相关规则同时命中时分数被夸大。如 `preapproval_absent``date_outside_trip` 可能高度相关。
**改进方向**:引入规则相关矩阵,对相关规则命中分数做去冗余折扣。
**验收标准**
- [ ] 建立规则相关矩阵
- [ ] 命中聚合时考虑冗余
- [ ] 测试验证去冗余效果
---
### A9. 行为画像 fairness 保护 ⏳
**优先级**:🟢 P3
**证据**`server/src/app/algorithem/employee_behavior_profile.py:345``evaluate_weighted_profile` / `calculate_review_priority_score`
**问题**:行为画像影响审核优先级,若基于受保护属性(性别/年龄)产生系统性偏差,会构成隐性歧视。
**改进方向**
- 增加 fairness audit 接口(按人群分组统计风险分布)
- 评分特征显式排除受保护属性
- 定期输出偏差报告
**验收标准**
- [ ] 评分特征清单明确排除受保护属性
- [ ] 提供 fairness audit API
- [ ] 定期偏差报告生成
---
### A10. 算法模块 800 行拆分 ⏳
**优先级**:🟢 P3与 B10 同源,单独追踪算法模块进度)
**证据**
- `server/src/app/algorithem/employee_behavior_profile_tag_rules.py`: **812 行** 🔴
- `server/src/app/algorithem/risk_graph/engine.py`: **794 行** 🟡 临界
**改进方向**
- `employee_behavior_profile_tag_rules.py` 按标签类别拆分(差旅类 / 招待类 / 办公类)
- `engine.py` 的 5 个评分维度rule/anomaly/graph/policy/history拆为 5 个独立打分器
**验收标准**
- [ ] 所有算法文件 ≤ 800 行
- [ ] 拆分前后行为等价(单元测试通过)
- [ ] 拆分后职责边界清晰
---
## 二、业务层面改进
### B1. 审批流转交/加签/撤回/会签 ⏳
**优先级**:🔴 P0
**证据**
- `server/src/app/services/expense_claim_workflow_constants.py`(仅 11 行固定阶段)
- `server/src/app/services/expense_claim_approval_flow.py:28``approve_claim` 串行硬编码)
- 转交/加签/撤回代码中**不存在**
**问题**
- 费控系统核心能力缺失
- 现实中领导出差无法审批是常态
- 无并行审批(会签),多人审批只能串行
- 审批节点调整需改代码
**改进方向**
- 引入审批矩阵:`费用类型 × 金额区间 × 部门` → 审批节点列表
- 支持节点动作:`{approve, reject, return, transfer, countersign, withdraw, add_approver}`
- 短期优先实现"转交"和"撤回"两个最常用动作
**验收标准**
- [ ] 支持转交(审批人转给他人)
- [ ] 支持撤回(提交人在审批中撤回)
- [ ] 支持加签(临时增加审批节点)
- [ ] 支持会签(多节点并行)
- [ ] 审批矩阵可后台配置
- [ ] 关键操作有审计日志
---
### B2. HTTP Header 权限漏洞修复 ⏳
**优先级**:🔴 P0安全
**证据**`server/src/app/api/deps.py:33-213`,通过 `X-Auth-Username / X-Auth-Role-Codes / X-Auth-Is-Admin` 等请求头识别身份。
**问题**
- **任何人只要在请求头加 `X-Auth-Is-Admin: true` 就能获得管理员权限**
- 没有 token、没有签名、没有任何校验
- 足以让所有费控规则形同虚设
**改进方向**
- 引入真正的身份认证JWT 或 session cookie
- 角色信息从服务端 session/token 获取,**绝不信任客户端传来的角色声明**
- 短期方案前置网关nginx剥离这些头并注入真实身份
**验收标准**
- [ ] 客户端无法通过伪造 Header 越权
- [ ] 所有角色信息来自服务端校验
- [ ] 现有 API 行为兼容(不破坏调用方)
- [ ] 安全测试覆盖权限边界
---
### B3. 申请单与报销单拆表 ⏳
**优先级**:🟡 P2
**证据**
- `server/src/app/models/financial_record.py``ExpenseClaim` 通过 `expense_type` 后缀 + `claim_no` 前缀区分
- `server/src/app/models/reimbursement.py``ReimbursementRequest` 几乎废弃service 仅 54 行 CRUD
**问题**
- 查询复杂度高,每个查询都要带 `expense_type IN (...)` 过滤
- 字段冗余(申请单无发票字段但表里有)
- 业务语义混乱("claim"分不清是申请还是报销)
- 索引难优化
**改进方向**(需决策):
- 方案 A保守保留单表增加 `claim_kind` 字段(`application` / `reimbursement`)显式区分
- 方案 B彻底拆分为 `ExpenseApplication` + `ExpenseReimbursement` 两张表,通过 `application_id` 外键关联
- **涉及数据迁移,需用户确认方案**
**验收标准**
- [ ] 方案决策完成
- [ ] 数据迁移脚本可重入、可回滚
- [ ] 迁移前后数据等价校验
- [ ] 现有 API 行为兼容或平滑升级
---
### B4. 可配置审批矩阵 ⏳
**优先级**:🟡 P2
**证据**`server/src/app/services/expense_claim_approval_routing.py``_APPLICATION_BUDGET_REVIEW_USAGE_THRESHOLD = 90%` 等阈值硬编码)
**问题**:什么金额走什么审批、什么情况要预算管理者介入,全部硬编码。不同公司/部门差异大,无法运维配置。
**改进方向**:建立审批矩阵配置表:
```
approval_matrix(expense_type, amount_range, department_level, risk_level)
→ [approver_roles, parallel_or_serial, sla_hours]
```
管理员后台维护,系统按矩阵动态生成审批流。
**验收标准**
- [ ] 审批矩阵可后台配置
- [ ] 系统按矩阵动态生成审批流
- [ ] 配置变更有版本和审计
- [ ] 矩阵未命中时有合理默认值
---
### B5. 预算管控跨期/跨科边界 ⏳
**优先级**:🟢 P3
**证据**
- `server/src/app/services/budget.py`780 行)
- `server/src/app/services/expense_claim_budget_flow.py`112 行)
- 预算占用/释放/核销/转移已实现,但边界场景验证不足
**潜在漏洞**
- 跨财年结转:去年冻结的预算今年初未释放
- 跨期占用Q1 提交的申请 Q2 才审批,占用的是哪个季度?
- 跨科目调剂:差旅预算不够能否临时挪用办公预算?
- 无对应单元测试
**改进方向**
- 增加预算状态周期性对账任务(每日扫描 orphan reservation
- 跨期策略明确化(默认跟随申请提交期,可配置)
- 补充跨期/跨科目单元测试
**验收标准**
- [ ] 跨期/跨科目边界单元测试覆盖
- [ ] 周期性对账任务上线
- [ ] orphan reservation 自动清理
---
### B6. 规则覆盖不均衡补齐 ⏳
**优先级**:🟠 P1
**证据**`server/rules/risk-rules/` 38 条规则分布:
- 差旅travel13 条
- 预算budget13 条
- 申请application5 条
- 报销reimbursement7 条
- 标准standard5 条
**问题**
- **招待费、市场推广、培训费、福利费、软件服务费几乎没有专门规则**
- 缺少供应商关联方交易、连号发票重复报销、跨年度重复报销检测
- 这些恰是真实费控场景最易出问题的领域
**改进方向**
1. 招待费规则(参与人数缺失、人均超标、同城招待、节假日招待)
2. 供应商风险规则(同一供应商高频、关联方、工商信息异常)
3. 重复报销检测(发票号哈希去重、跨期扫描)
**验收标准**
- [ ] 招待费规则集≥5 条)
- [ ] 供应商风险规则集≥3 条)
- [ ] 重复报销检测规则≥2 条)
- [ ] 每条新规则有对应单元测试
---
### B7. 审计日志防篡改 ⏳
**优先级**:🟢 P3
**证据**
- `server/src/app/models/audit_log.py`
- `server/src/app/services/audit.py`72 行)
- before/after JSON 快照完整,但**无 hash chain 或数字签名**
**问题**:数据库管理员(或有 DB 写权限的人)可静默篡改审计日志。
**改进方向**
- 每条日志附加 `prev_hash + current_hash = sha256(prev_hash + payload)`
- 定期锚定到外部存证(区块链 / 公证处 / WORM 存储)
**验收标准**
- [ ] 审计日志实现 hash chain
- [ ] 篡改可被检测
- [ ] 外部存证机制(至少文档化)
---
### B8. 审批 SLA 与时效监控 ⏳
**优先级**:🟢 P3
**证据**:审批节点无超时提醒代码。
**问题**:单据卡在某领导处一周无人管,系统无感知。
**改进方向**
- 每个审批节点配置 SLA如 24h/48h
- 后台定时任务扫描超时单据
- 自动催办 / 升级到上级 / 转交
**验收标准**
- [ ] SLA 可配置
- [ ] 超时自动催办
- [ ] 超时升级机制
- [ ] SLA 报表可查
---
### B9. 支付与凭证对接 ⏳
**优先级**:🟢 P3业务延伸方向
**证据**:状态机到 `paid` 就结束,无银企直连、无会计凭证生成。
**问题**:报销审批通过后仍需财务人工付款、手工录凭证,未形成完整闭环。
**改进方向**
- 银企直连(用友 / 金蝶 / 远光 API
- 自动生成会计凭证(借:管理费用-差旅,贷:银行存款/应付职工薪酬)
**验收标准**
- [ ] 至少接入一个财务系统
- [ ] 凭证自动生成
- [ ] 支付状态回传
---
### B10. 800 行硬约束拆分(业务模块) ⏳
**优先级**:🔴 P0
**证据**services/ 下 ≥ 800 行的文件,共 **20 个**
| 文件 | 行数 | 超标幅度 |
|---|---|---|
| `services/user_agent_application.py` | 1451 | +81% |
| `services/risk_rule_template_executor.py` | 1164 | +45% |
| `services/expense_claim_draft_flow.py` | 1064 | +33% |
| `services/expense_claims.py` | 1042 | +30% |
| `services/receipt_folder.py` | 1034 | +29% |
| `services/steward_planner.py` | 935 | +17% |
| `api/v1/endpoints/agent_assets.py` | 925 | +16% |
| `services/orchestrator_execution.py` | 900 | +12.5% |
| `services/finance_dashboard.py` | 884 | +10.5% |
| `services/knowledge_rag.py` | 877 | +9.6% |
| `services/settings.py` | 873 | +9.1% |
| `services/agent_assets.py` | 856 | +7% |
| `services/employee.py` | 850 | +6.25% |
| `services/employee_behavior_profile_service.py` | 823 | +2.9% |
| `services/risk_rule_generation.py` | 821 | +2.6% |
| `services/agent_foundation_asset_topup.py` | 809 | +1.1% |
| `services/ontology_extraction.py` | 808 | +1% |
| `services/demo_company_simulation_seed.py` | 805 | +0.6% |
| `services/knowledge.py` | 800 | 临界 |
| 另约 20 个文件在 700-800 行区间 | | 🟡 |
**前端超大文件**
| 文件 | 行数 |
|---|---|
| `web/src/views/scripts/TravelReimbursementCreateView.js` | 4066 🔴🔴 |
| `web/src/views/scripts/TravelRequestDetailView.js` | 2861 🔴🔴 |
| `web/src/views/scripts/useTravelReimbursementSubmitComposer.js` | 2173 🔴🔴 |
| `web/src/composables/useRequests.js` | 1799 🔴 |
| `web/src/views/scripts/travelReimbursementReviewModel.js` | 1662 🔴 |
| 多个 `.vue` 文件 | 800-1130 🔴 |
**改进方向**
- 按 AGENTS.md 既定的拆分原则(编排 / 状态 / 持久化 / 权限 / 文件存储 / OCR / 规则审核 / 响应构建 / 序列化)逐个拆
- 优先 Top 5`user_agent_application` / `risk_rule_template_executor` / `expense_claim_draft_flow` / `expense_claims` / `receipt_folder`
- 每次拆分配套定向测试
**验收标准**
- [ ] 所有类/文件 ≤ 800 行
- [ ] 拆分前后行为等价(测试通过)
- [ ] 拆分后职责边界清晰
- [ ] CI 中加入行数检查(防止回潮)
---
## 三、推进原则
1. **P0 优先**B2安全、B1核心能力、B10共识必须先行。
2. **算法优化在 P0 落地后做**:再准的算法也会被权限漏洞和流程缺失抵消。
3. **小步快跑**:每项改进拆成可独立验证的子任务,配套测试。
4. **不破坏既有协议**:对外 API 尽量稳定,内部实现先拆。
5. **800 行约束**所有改动前后检查受影响类行数CI 加入行数门禁。
---
## 四、变更日志
| 日期 | 变更 | 操作人 |
|---|---|---|
| 2026-06-18 | 路线图初始版本,基于代码库全量评估生成 | Sisyphus |

View File

@@ -3,17 +3,17 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312" OCR_VENV_DIR="${ROOT_DIR}/.venv-ocr312"
PYTHON_BIN="${PYTHON_BIN:-python3.12}" PYTHON_BIN="${PYTHON_BIN:-python3}"
PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.3.1}" PADDLEPADDLE_VERSION="${PADDLEPADDLE_VERSION:-3.2.2}"
PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}" PADDLEOCR_VERSION="${PADDLEOCR_VERSION:-3.6.0}"
if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then if ! command -v "${PYTHON_BIN}" >/dev/null 2>&1; then
echo "python3.12 不存在,请先安装 Python 3.12。" >&2 echo "${PYTHON_BIN} 不存在,请先安装 Python 3。" >&2
exit 1 exit 1
fi fi
apt-get update apt-get update
apt-get install -y libgl1 libglib2.0-0 apt-get install -y --no-install-recommends libgl1 libglib2.0-0 poppler-utils
"${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}" "${PYTHON_BIN}" -m venv "${OCR_VENV_DIR}"
"${OCR_VENV_DIR}/bin/pip" install --upgrade pip "${OCR_VENV_DIR}/bin/pip" install --upgrade pip

View File

@@ -88,8 +88,11 @@ if [ ! -f "$ROOT_ENV_FILE" ]; then
fi fi
ENV_OVERRIDE_SERVER_HOST_SET=false ENV_OVERRIDE_SERVER_HOST_SET=false
ENV_OVERRIDE_SERVER_PORT_SET=false
ENV_OVERRIDE_POSTGRES_HOST_SET=false ENV_OVERRIDE_POSTGRES_HOST_SET=false
ENV_OVERRIDE_DATABASE_URL_SET=false ENV_OVERRIDE_DATABASE_URL_SET=false
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=false
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=false
ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false ENV_OVERRIDE_ONLYOFFICE_PUBLIC_URL_SET=false
ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false ENV_OVERRIDE_ONLYOFFICE_BACKEND_URL_SET=false
@@ -107,6 +110,11 @@ if [ "${SERVER_HOST+x}" = x ]; then
ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST" ENV_OVERRIDE_SERVER_HOST="$SERVER_HOST"
fi fi
if [ "${SERVER_PORT+x}" = x ]; then
ENV_OVERRIDE_SERVER_PORT_SET=true
ENV_OVERRIDE_SERVER_PORT="$SERVER_PORT"
fi
if [ "${POSTGRES_HOST+x}" = x ]; then if [ "${POSTGRES_HOST+x}" = x ]; then
ENV_OVERRIDE_POSTGRES_HOST_SET=true ENV_OVERRIDE_POSTGRES_HOST_SET=true
ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST" ENV_OVERRIDE_POSTGRES_HOST="$POSTGRES_HOST"
@@ -117,6 +125,16 @@ if [ "${DATABASE_URL+x}" = x ]; then
ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL" ENV_OVERRIDE_DATABASE_URL="$DATABASE_URL"
fi fi
if [ "${STARTUP_BOOTSTRAP_ENABLED+x}" = x ]; then
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET=true
ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED="$STARTUP_BOOTSTRAP_ENABLED"
fi
if [ "${BACKGROUND_SCHEDULERS_ENABLED+x}" = x ]; then
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET=true
ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED="$BACKGROUND_SCHEDULERS_ENABLED"
fi
if [ "$PREFER_ENV_FILE_FOR_ONLYOFFICE" != true ] && [ "${ONLYOFFICE_ENABLED+x}" = x ]; then if [ "$PREFER_ENV_FILE_FOR_ONLYOFFICE" != true ] && [ "${ONLYOFFICE_ENABLED+x}" = x ]; then
ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET=true
ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED" ENV_OVERRIDE_ONLYOFFICE_ENABLED="$ONLYOFFICE_ENABLED"
@@ -145,6 +163,10 @@ if [ "$ENV_OVERRIDE_SERVER_HOST_SET" = true ]; then
SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST" SERVER_HOST="$ENV_OVERRIDE_SERVER_HOST"
fi fi
if [ "$ENV_OVERRIDE_SERVER_PORT_SET" = true ]; then
SERVER_PORT="$ENV_OVERRIDE_SERVER_PORT"
fi
if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then if [ "$ENV_OVERRIDE_POSTGRES_HOST_SET" = true ]; then
POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST" POSTGRES_HOST="$ENV_OVERRIDE_POSTGRES_HOST"
fi fi
@@ -153,6 +175,14 @@ if [ "$ENV_OVERRIDE_DATABASE_URL_SET" = true ]; then
DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL" DATABASE_URL="$ENV_OVERRIDE_DATABASE_URL"
fi fi
if [ "$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED_SET" = true ]; then
STARTUP_BOOTSTRAP_ENABLED="$ENV_OVERRIDE_STARTUP_BOOTSTRAP_ENABLED"
fi
if [ "$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED_SET" = true ]; then
BACKGROUND_SCHEDULERS_ENABLED="$ENV_OVERRIDE_BACKGROUND_SCHEDULERS_ENABLED"
fi
if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then if [ "$ENV_OVERRIDE_ONLYOFFICE_ENABLED_SET" = true ]; then
ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED" ONLYOFFICE_ENABLED="$ENV_OVERRIDE_ONLYOFFICE_ENABLED"
fi fi

View File

@@ -7,9 +7,11 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db from app.api.deps import get_db
from app.models.financial_record import ExpenseClaim
from app.schemas.common import ErrorResponse from app.schemas.common import ErrorResponse
from app.schemas.steward import ( from app.schemas.steward import (
StewardPlanRequest, StewardPlanRequest,
@@ -21,9 +23,12 @@ from app.schemas.steward import (
StewardThinkingEvent, StewardThinkingEvent,
) )
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.expense_claim_draft_flow import APPROVED_APPLICATION_LINK_STATUSES
from app.services.expense_claims import ExpenseClaimService
from app.services.runtime_chat import RuntimeChatService from app.services.runtime_chat import RuntimeChatService
from app.services.steward_flow_state import StewardFlowStateService from app.services.steward_flow_state import StewardFlowStateService
from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_off_topic_agent import StewardOffTopicAgent
from app.services.steward_planner import StewardPlannerService from app.services.steward_planner import StewardPlannerService
from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent from app.services.steward_runtime_decision_agent import StewardRuntimeDecisionAgent
from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent from app.services.steward_slot_decision_agent import StewardSlotDecisionAgent
@@ -46,8 +51,10 @@ DbSession = Annotated[Session, Depends(get_db)]
) )
def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse: def create_steward_plan(payload: StewardPlanRequest, db: DbSession) -> StewardPlanResponse:
try: try:
plan = _build_steward_planner(db).build_plan(payload) planner = _build_steward_planner(db)
return _attach_conversation_state(db, payload, plan) hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
plan = planner.build_plan(hydrated_payload)
return _attach_conversation_state(db, hydrated_payload, plan)
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@@ -103,15 +110,16 @@ async def _iter_steward_plan_events(
event_id="intent_agent_stream_start", event_id="intent_agent_stream_start",
stage="stream_start", stage="stream_start",
title="读取用户输入", title="读取用户输入",
content="我先判断这句话里是否同时包含申请报销或附件归集事项,再决定处理顺序", content="我先识别申请/报销边界;如果是历史差旅描述,会先查询可关联申请单再决定流程",
status="running", status="running",
).model_dump(mode="json"), ).model_dump(mode="json"),
) )
await asyncio.sleep(0) await asyncio.sleep(0)
try: try:
plan = planner.build_plan(payload) hydrated_payload = _hydrate_required_application_gate(db, payload, planner)
plan = _attach_conversation_state(db, payload, plan) plan = planner.build_plan(hydrated_payload)
plan = _attach_conversation_state(db, hydrated_payload, plan)
except ValueError as exc: except ValueError as exc:
yield _encode_stream_event("error", {"message": str(exc)}) yield _encode_stream_event("error", {"message": str(exc)})
return return
@@ -128,11 +136,179 @@ def _encode_stream_event(event: str, data: dict[str, Any]) -> str:
def _build_steward_planner(db: Session) -> StewardPlannerService: def _build_steward_planner(db: Session) -> StewardPlannerService:
runtime_chat = RuntimeChatService(db)
return StewardPlannerService( return StewardPlannerService(
intent_agent=StewardIntentAgent(RuntimeChatService(db)), intent_agent=StewardIntentAgent(runtime_chat),
off_topic_agent=StewardOffTopicAgent(runtime_chat),
) )
def _hydrate_required_application_gate(
db: Session,
payload: StewardPlanRequest,
planner: StewardPlannerService,
) -> StewardPlanRequest:
context_json = dict(payload.context_json or {})
required_gate = context_json.get("required_application_gate")
if isinstance(required_gate, dict):
travel_gate = required_gate.get("travel")
if isinstance(travel_gate, dict) and travel_gate.get("checked") is True:
return payload
message = planner._clean_text(payload.message)
base_date = planner._resolve_base_date(payload.client_now_iso, context_json)
if not planner._looks_like_ambiguous_travel_flow(message, base_date, payload):
return payload
candidates = _query_required_application_gate_candidates(db, payload, context_json)
next_required_gate = dict(required_gate) if isinstance(required_gate, dict) else {}
next_required_gate["travel"] = {
"checked": True,
"candidate_count": len(candidates),
"candidates": candidates[:5],
}
return payload.model_copy(
update={
"context_json": {
**context_json,
"required_application_gate": next_required_gate,
}
}
)
def _query_required_application_gate_candidates(
db: Session,
payload: StewardPlanRequest,
context_json: dict[str, Any],
) -> list[dict[str, Any]]:
identities = _resolve_required_application_gate_identities(payload, context_json)
stmt = (
select(ExpenseClaim)
.order_by(ExpenseClaim.submitted_at.desc(), ExpenseClaim.updated_at.desc())
.limit(200)
)
candidates: list[dict[str, Any]] = []
for claim in db.scalars(stmt).all():
if not ExpenseClaimService._is_expense_application_claim(claim):
continue
if str(claim.status or "").strip().lower() not in APPROVED_APPLICATION_LINK_STATUSES:
continue
if identities and not _claim_matches_required_application_identity(claim, identities):
continue
if not _claim_matches_required_travel_application(claim, payload.message):
continue
candidates.append(_serialize_required_application_gate_candidate(claim))
return candidates
def _resolve_required_application_gate_identities(
payload: StewardPlanRequest,
context_json: dict[str, Any],
) -> set[str]:
raw_values = [
payload.user_id,
context_json.get("user_id"),
context_json.get("username"),
context_json.get("name"),
context_json.get("employee_id"),
context_json.get("employee_no"),
context_json.get("employee_name"),
]
identities: set[str] = set()
for value in raw_values:
normalized = _normalize_required_application_identity(value)
if normalized:
identities.add(normalized)
return identities
def _normalize_required_application_identity(value: Any) -> str:
return str(value or "").strip().casefold()
def _claim_matches_required_application_identity(claim: ExpenseClaim, identities: set[str]) -> bool:
claim_identities = {
_normalize_required_application_identity(claim.employee_id),
_normalize_required_application_identity(claim.employee_name),
}
claim_identities.discard("")
return bool(claim_identities.intersection(identities))
def _claim_matches_required_travel_application(claim: ExpenseClaim, message: str) -> bool:
expense_type = str(claim.expense_type or "").strip().casefold()
if any(token in expense_type for token in ("travel", "trip", "差旅", "出差")):
return True
claim_text = "".join(
[
str(claim.reason or ""),
str(claim.location or ""),
str(claim.claim_no or ""),
]
)
if "差旅" in claim_text or "出差" in claim_text:
return True
compact_message = str(message or "").replace(" ", "")
location = str(claim.location or "").strip()
return bool(location and location in compact_message and "出差" in compact_message)
def _serialize_required_application_gate_candidate(claim: ExpenseClaim) -> dict[str, Any]:
business_time = _resolve_required_application_business_time(claim)
status_label = _resolve_required_application_status_label(claim.status)
return {
"id": str(claim.id or "").strip(),
"claim_no": str(claim.claim_no or "").strip(),
"reason": str(claim.reason or "").strip(),
"location": str(claim.location or "").strip(),
"business_time": business_time,
"status_label": status_label,
"application_claim_id": str(claim.id or "").strip(),
"application_claim_no": str(claim.claim_no or "").strip(),
"application_reason": str(claim.reason or "").strip(),
"application_location": str(claim.location or "").strip(),
"application_business_time": business_time,
"application_status_label": status_label,
}
def _resolve_required_application_business_time(claim: ExpenseClaim) -> str:
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
for source in (
flag,
flag.get("application_detail"),
flag.get("applicationDetail"),
flag.get("review_form_values"),
flag.get("reviewFormValues"),
):
if not isinstance(source, dict):
continue
value = (
source.get("application_business_time")
or source.get("applicationBusinessTime")
or source.get("business_time")
or source.get("businessTime")
)
if str(value or "").strip():
return str(value).strip()
if claim.occurred_at is not None:
return claim.occurred_at.date().isoformat()
return ""
def _resolve_required_application_status_label(status: Any) -> str:
normalized = str(status or "").strip().lower()
return {
"approved": "已审批",
"completed": "已完成",
}.get(normalized, normalized)
def _attach_conversation_state( def _attach_conversation_state(
db: Session, db: Session,
payload: StewardPlanRequest, payload: StewardPlanRequest,

View File

@@ -43,6 +43,8 @@ class Settings(BaseSettings):
app_port: int = Field(default=8000, alias="SERVER_PORT") app_port: int = Field(default=8000, alias="SERVER_PORT")
server_workers: int = Field(default=1, alias="SERVER_WORKERS") server_workers: int = Field(default=1, alias="SERVER_WORKERS")
web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY") web_concurrency: int | None = Field(default=None, alias="WEB_CONCURRENCY")
startup_bootstrap_enabled: bool = Field(default=True, alias="STARTUP_BOOTSTRAP_ENABLED")
startup_cache_warmup_enabled: bool = Field(default=False, alias="STARTUP_CACHE_WARMUP_ENABLED")
background_schedulers_enabled: bool = Field( background_schedulers_enabled: bool = Field(
default=True, default=True,
alias="BACKGROUND_SCHEDULERS_ENABLED", alias="BACKGROUND_SCHEDULERS_ENABLED",

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from logging import Logger
import threading
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -10,11 +12,13 @@ from app.api.router import api_router
from app.core.config import get_settings from app.core.config import get_settings
from app.core.logging import get_logger, setup_logging from app.core.logging import get_logger, setup_logging
from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS from app.core.openapi import API_DESCRIPTION, OPENAPI_TAGS
from app.db.session import get_session_factory
from app.middleware.logging import AccessLogMiddleware from app.middleware.logging import AccessLogMiddleware
from app.schemas.common import RootStatusRead from app.schemas.common import RootStatusRead
from app.services.agent_foundation import prepare_agent_foundation from app.services.agent_foundation import prepare_agent_foundation
from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler from app.services.digital_employee_reminder_scheduler import digital_employee_reminder_scheduler
from app.services.employee import prepare_employee_directory from app.services.employee import prepare_employee_directory
from app.services.employee import EmployeeService
from app.services.employee_profile_scheduler import employee_profile_scheduler from app.services.employee_profile_scheduler import employee_profile_scheduler
from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler from app.services.finance_dashboard_scheduler import finance_dashboard_scheduler
from app.services.finance_report_scheduler import finance_report_scheduler from app.services.finance_report_scheduler import finance_report_scheduler
@@ -23,6 +27,8 @@ from app.services.knowledge import prepare_knowledge_library
from app.services.knowledge_index_tasks import knowledge_index_task_manager from app.services.knowledge_index_tasks import knowledge_index_task_manager
from app.services.knowledge_rag import shutdown_knowledge_rag_runtime from app.services.knowledge_rag import shutdown_knowledge_rag_runtime
from app.services.knowledge_scheduler import knowledge_index_scheduler from app.services.knowledge_scheduler import knowledge_index_scheduler
from app.services.settings import SettingsService
from app.services.user_session_metrics import UserSessionMetricService
def _effective_server_workers(settings: object) -> int: def _effective_server_workers(settings: object) -> int:
@@ -42,15 +48,55 @@ def _should_start_background_schedulers(settings: object) -> bool:
return _effective_server_workers(settings) <= 1 return _effective_server_workers(settings) <= 1
def _run_startup_bootstrap(logger: Logger) -> None:
steps = (
("employee_directory", prepare_employee_directory),
("agent_foundation", prepare_agent_foundation),
("knowledge_library", prepare_knowledge_library),
("hermes_skills", sync_repository_hermes_skills),
)
for name, step in steps:
try:
step()
except Exception:
logger.exception("Startup bootstrap step failed; continuing degraded name=%s", name)
def _warm_startup_caches(logger: Logger) -> None:
try:
session_factory = get_session_factory()
with session_factory() as db:
SettingsService(db).ensure_settings_ready()
EmployeeService(db).ensure_directory_ready()
UserSessionMetricService(db).ensure_storage_ready()
logger.info("Startup cache warmup complete")
except Exception:
logger.exception("Startup cache warmup failed; continuing without warm cache")
def _start_cache_warmup_thread(logger: Logger) -> None:
thread = threading.Thread(
target=_warm_startup_caches,
args=(logger,),
name="x-financial-startup-cache-warmup",
daemon=True,
)
thread.start()
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]: async def lifespan(_: FastAPI) -> AsyncIterator[None]:
settings = get_settings() settings = get_settings()
logger = get_logger("app.main") logger = get_logger("app.main")
prepare_employee_directory() if settings.startup_bootstrap_enabled:
prepare_agent_foundation() _run_startup_bootstrap(logger)
prepare_knowledge_library() else:
sync_repository_hermes_skills() logger.warning("Startup bootstrap skipped because STARTUP_BOOTSTRAP_ENABLED=false")
if settings.startup_cache_warmup_enabled:
_start_cache_warmup_thread(logger)
schedulers_started = _should_start_background_schedulers(settings) schedulers_started = _should_start_background_schedulers(settings)
if schedulers_started: if schedulers_started:
knowledge_index_scheduler.start() knowledge_index_scheduler.start()

View File

@@ -20,7 +20,7 @@ class SystemSetting(Base):
copyright_text: Mapped[str] = mapped_column(String(255), default="") copyright_text: Mapped[str] = mapped_column(String(255), default="")
theme_skin: Mapped[str] = mapped_column(String(64), default="sky") theme_skin: Mapped[str] = mapped_column(String(64), default="sky")
admin_account: Mapped[str] = mapped_column(String(120), default="superadmin") admin_account: Mapped[str] = mapped_column(String(120), default="admin")
admin_email: Mapped[str] = mapped_column(String(255), default="") admin_email: Mapped[str] = mapped_column(String(255), default="")
session_timeout: Mapped[int] = mapped_column(Integer, default=30) session_timeout: Mapped[int] = mapped_column(Integer, default=30)
conversation_retention_days: Mapped[int] = mapped_column(Integer, default=3) conversation_retention_days: Mapped[int] = mapped_column(Integer, default=3)

View File

@@ -5,6 +5,7 @@ from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
from app.core.config import get_settings from app.core.config import get_settings
@@ -127,8 +128,15 @@ class AuthService:
if not self.settings.setup_completed: if not self.settings.setup_completed:
return None return None
EmployeeService(self.db).ensure_directory_ready() try:
employee = self._find_employee_by_email(identifier) employee = self._find_employee_by_email(identifier)
except SQLAlchemyError:
self.db.rollback()
employee = None
if employee is None:
EmployeeService(self.db).ensure_directory_ready()
employee = self._find_employee_by_email(identifier)
if employee is None or not employee.password_hash: if employee is None or not employee.password_hash:
return None return None

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from collections import Counter from collections import Counter
from datetime import UTC, date, datetime from datetime import UTC, date, datetime
import threading
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import select
@@ -81,11 +82,31 @@ def prepare_employee_directory() -> None:
class EmployeeService: class EmployeeService:
_directory_ready_lock = threading.Lock()
_directory_ready_keys: set[tuple[str, int]] = set()
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
self.repository = EmployeeRepository(db) self.repository = EmployeeRepository(db)
@staticmethod
def _bind_cache_key(db: Session) -> tuple[str, int]:
bind = db.get_bind()
return (bind.url.render_as_string(hide_password=True), id(bind.pool))
def ensure_directory_ready(self) -> None: def ensure_directory_ready(self) -> None:
cache_key = self._bind_cache_key(self.db)
if cache_key in self._directory_ready_keys:
return
with self._directory_ready_lock:
if cache_key in self._directory_ready_keys:
return
self._ensure_directory_ready_uncached()
self._directory_ready_keys.add(cache_key)
def _ensure_directory_ready_uncached(self) -> None:
try: try:
Base.metadata.create_all(bind=self.db.get_bind()) Base.metadata.create_all(bind=self.db.get_bind())
ensure_employee_schema(self.db) ensure_employee_schema(self.db)

View File

@@ -103,11 +103,14 @@ class SemanticOntologyService(
if not query: if not query:
raise ValueError("query 不能为空。") raise ValueError("query 不能为空。")
AgentFoundationService(self.db).ensure_foundation_ready()
context_json = normalize_ontology_context_json(payload.context_json or {}) context_json = normalize_ontology_context_json(payload.context_json or {})
payload = payload.model_copy(update={"context_json": context_json}) payload = payload.model_copy(update={"context_json": context_json})
reference = self._load_reference_catalog()
compact_query = self._compact(query) compact_query = self._compact(query)
if not self._has_supported_business_signal(compact_query, context_json):
raise ValueError("当前系统仅支持财务业务相关问题。")
AgentFoundationService(self.db).ensure_foundation_ready()
reference = self._load_reference_catalog()
entities = self._extract_entities(query, compact_query, reference, context_json=context_json) entities = self._extract_entities(query, compact_query, reference, context_json=context_json)
rule_scenario, scenario_score = self._detect_scenario(compact_query) rule_scenario, scenario_score = self._detect_scenario(compact_query)
time_range, _time_score = self._extract_time_range( time_range, _time_score = self._extract_time_range(

View File

@@ -92,6 +92,92 @@ class OntologyDetectionMixin:
def _looks_like_expense_application(compact_query: str) -> bool: def _looks_like_expense_application(compact_query: str) -> bool:
return looks_like_expense_application_signal(compact_query) return looks_like_expense_application_signal(compact_query)
def _has_supported_business_signal(self, compact_query: str, context_json: dict[str, Any]) -> bool:
has_business_context = (
self._is_expense_application_context(context_json)
or self._resolve_session_type_scenario(context_json) == "knowledge"
or self._resolve_context_scenario(context_json) is not None
)
if self._looks_like_expense_application(compact_query):
return True
domain_keywords = [
keyword
for keywords in SCENARIO_KEYWORDS.values()
for keyword, _weight in keywords
]
if any(keyword in compact_query for keyword in domain_keywords):
return True
if any(keyword in compact_query for keyword in EXPENSE_NARRATIVE_KEYWORDS):
return True
knowledge_keywords = (
"制度",
"规则",
"办法",
"依据",
"政策",
"知识库",
"规定",
"流程",
"口径",
"标准",
"上限",
"额度",
"补贴",
"票据要求",
)
if any(keyword in compact_query for keyword in knowledge_keywords):
return True
approval_keywords = (
"待我审核",
"待审",
"审核",
"审批",
"审核意见",
"审批意见",
"审批通过",
"审批驳回",
"驳回",
"退回",
"审核中心",
"审批中心",
"领导审批",
"财务审核",
"处理意见",
)
if any(keyword in compact_query for keyword in approval_keywords):
return True
if has_business_context and self._looks_like_contextual_business_follow_up(compact_query):
return True
return False
@staticmethod
def _looks_like_contextual_business_follow_up(compact_query: str) -> bool:
if not compact_query:
return False
if compact_query in {
"",
"好的",
"",
"可以",
"",
"继续",
"下一步",
"确认",
"确定",
"补充",
"再补充",
"再看看",
"没问题",
}:
return True
if any(keyword in compact_query for keyword in DRAFT_FOLLOW_UP_KEYWORDS):
return True
return compact_query.startswith(("", "", "", "这个", "那个"))
def _detect_scenario(self, compact_query: str) -> tuple[str, float]: def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
scores = {key: 0.0 for key in SCENARIO_KEYWORDS} scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
for scenario, keywords in SCENARIO_KEYWORDS.items(): for scenario, keywords in SCENARIO_KEYWORDS.items():
@@ -126,6 +212,7 @@ class OntologyDetectionMixin:
return best_scenario, round(min(best_score, 0.34), 2) return best_scenario, round(min(best_score, 0.34), 2)
def _detect_intent( def _detect_intent(
self, self,
compact_query: str, compact_query: str,

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
@@ -28,9 +29,13 @@ from app.services.hermes_sync import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_ADMIN_ACCOUNT = "admin"
@dataclass(frozen=True, slots=True) DEFAULT_ADMIN_PASSWORD = "admin"
class ModelSlotConfig: LEGACY_DEFAULT_ADMIN_ACCOUNTS = {"", "superadmin"}
@dataclass(frozen=True, slots=True)
class ModelSlotConfig:
provider_attr: str provider_attr: str
model_attr: str model_attr: str
endpoint_attr: str endpoint_attr: str
@@ -106,14 +111,27 @@ class OnlyOfficeRuntimeConfig:
class SettingsService: class SettingsService:
def __init__(self, db: Session) -> None: _schema_ready_lock = threading.Lock()
self.db = db _schema_ready_keys: set[tuple[str, int]] = set()
self.repository = SettingsRepository(db)
self.runtime_settings = get_settings() def __init__(self, db: Session) -> None:
self.db = db
self.repository = SettingsRepository(db)
self.runtime_settings = get_settings()
@staticmethod
def _bind_cache_key(db: Session) -> tuple[str, int]:
bind = db.get_bind()
return (bind.url.render_as_string(hide_password=True), id(bind.pool))
def ensure_settings_ready(self) -> tuple[SystemSetting, SystemSettingSecret]: def ensure_settings_ready(self) -> tuple[SystemSetting, SystemSettingSecret]:
Base.metadata.create_all(bind=self.db.get_bind()) cache_key = self._bind_cache_key(self.db)
self._ensure_settings_schema() if cache_key not in self._schema_ready_keys:
with self._schema_ready_lock:
if cache_key not in self._schema_ready_keys:
Base.metadata.create_all(bind=self.db.get_bind())
self._ensure_settings_schema()
self._schema_ready_keys.add(cache_key)
settings_row = self.repository.get_settings() settings_row = self.repository.get_settings()
secrets_row = self.repository.get_secrets() secrets_row = self.repository.get_secrets()
@@ -130,12 +148,17 @@ class SettingsService:
self.db.add(secrets_row) self.db.add(secrets_row)
should_commit = True should_commit = True
if legacy_admin is not None and not secrets_row.admin_password_hash: if legacy_admin is not None and not secrets_row.admin_password_hash:
secrets_row.admin_password_hash = legacy_admin_secret_to_password_hash(legacy_admin) secrets_row.admin_password_hash = legacy_admin_secret_to_password_hash(legacy_admin)
admin_username = str(legacy_admin.get("username", "")).strip() admin_username = str(legacy_admin.get("username", "")).strip()
if admin_username and str(settings_row.admin_account or "").strip() in {"", "superadmin"}: if admin_username and str(settings_row.admin_account or "").strip() in LEGACY_DEFAULT_ADMIN_ACCOUNTS:
settings_row.admin_account = admin_username settings_row.admin_account = admin_username
should_commit = True should_commit = True
elif legacy_admin is None and not secrets_row.admin_password_hash:
secrets_row.admin_password_hash = hash_password(DEFAULT_ADMIN_PASSWORD)
if str(settings_row.admin_account or "").strip() in LEGACY_DEFAULT_ADMIN_ACCOUNTS:
settings_row.admin_account = DEFAULT_ADMIN_ACCOUNT
should_commit = True
if self._sync_onlyoffice_defaults(settings_row, secrets_row): if self._sync_onlyoffice_defaults(settings_row, secrets_row):
should_commit = True should_commit = True
@@ -454,7 +477,7 @@ class SettingsService:
company_code = str(self.runtime_settings.company_code or "XF-001").strip() or "XF-001" company_code = str(self.runtime_settings.company_code or "XF-001").strip() or "XF-001"
admin_email = str(self.runtime_settings.admin_email or "").strip() admin_email = str(self.runtime_settings.admin_email or "").strip()
legacy_admin = read_admin_secret() or {} legacy_admin = read_admin_secret() or {}
admin_account = str(legacy_admin.get("username", "")).strip() or "superadmin" admin_account = str(legacy_admin.get("username", "")).strip() or DEFAULT_ADMIN_ACCOUNT
return SystemSetting( return SystemSetting(
id=SETTINGS_ROW_ID, id=SETTINGS_ROW_ID,

View File

@@ -0,0 +1,157 @@
"""小财管家业务无关输入的引导生成 agent。
当用户的输入被识别为与财务任务无关时(问候、纯数字、闲聊等),
由该 agent 用 function calling 让主模型生成一句管家对主人语气的引导回复。
LLM 不可用或调用失败时,由调用方 fallback 到规则模板。
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any
from app.schemas.steward import StewardPlanRequest
from app.services.runtime_chat import RuntimeChatService
STEWARD_OFF_TOPIC_FUNCTION_NAME = "submit_steward_off_topic_response"
STEWARD_OFF_TOPIC_SCENARIO_PROMPTS: dict[str, str] = {
"greeting": (
"用户发起了礼貌问候,例如「你好」「您好」「早上好」。"
"请像管家一样礼貌地回应主人的问候,温和询问主人今天要办理什么业务,"
"并顺势说明小财管家能帮主人整理费用申请和费用报销两类事项。"
"可以再提示主人试试用具体的话术来表达需求。"
),
"off_business": (
"用户说了有意义的话但与财务无关,例如问天气、聊生活、聊工作日常。"
"请温和地告诉主人:这句话里没有识别到财务事项,"
"小财管家目前只能帮主人整理费用申请和费用报销。"
"再请主人用具体的话术告诉小财管家他/她想办什么业务。"
),
"meaningless": (
"用户输入了与财务无关且难以理解的内容,例如纯数字、纯标点、重复字符。"
"请温和地告诉主人:这句话里好像没有出现费用申请、报销、出差、交通、招待这些关键词,"
"请主人换种说法再告诉小财管家一次。"
),
}
@dataclass(frozen=True, slots=True)
class StewardOffTopicAgentResult:
response_text: str
model_call_traces: list[dict[str, Any]]
class StewardOffTopicAgent:
"""使用大模型 function calling 生成小财管家对业务无关输入的多样化引导。"""
def __init__(self, runtime_chat_service: RuntimeChatService) -> None:
self.runtime_chat_service = runtime_chat_service
self.last_call_traces: list[dict[str, Any]] = []
def generate(
self,
request: StewardPlanRequest,
*,
scenario: str,
) -> StewardOffTopicAgentResult | None:
messages = self._build_messages(request, scenario=scenario)
try:
result = self.runtime_chat_service.complete_with_tool_call(
messages,
tools=[self._build_tool_schema()],
tool_choice={
"type": "function",
"function": {"name": STEWARD_OFF_TOPIC_FUNCTION_NAME},
},
max_tokens=400,
temperature=0.7,
timeout_seconds=15,
max_attempts=1,
)
except Exception:
return None
self.last_call_traces = result.calls_as_dicts()
if result.tool_call is None or result.tool_call.name != STEWARD_OFF_TOPIC_FUNCTION_NAME:
return None
arguments = result.tool_call.arguments or {}
response_text = str(arguments.get("response_text") or "").strip()
if not response_text:
return None
return StewardOffTopicAgentResult(
response_text=response_text,
model_call_traces=self.last_call_traces,
)
@staticmethod
def _build_messages(
request: StewardPlanRequest,
*,
scenario: str,
) -> list[dict[str, Any]]:
scenario_hint = STEWARD_OFF_TOPIC_SCENARIO_PROMPTS.get(
scenario,
STEWARD_OFF_TOPIC_SCENARIO_PROMPTS["off_business"],
)
return [
{
"role": "system",
"content": (
"你是 X-Financial 的小财管家,要像一位贴身的管家那样为主人服务。"
"用户输入的内容与财务任务无关,你需要生成一段温和、尊敬的引导回复。"
"要求:\n"
"1. 始终称呼用户为「主人」「您」,不要用「你」。\n"
"2. 语气尊敬、温和、主动,体现管家的服务意识。\n"
"3. 不要每次都用相同的句式,要根据用户的输入和当前场景变化表达。\n"
"4. 控制在 2-4 句,不要过长。\n"
"5. 必须使用 function calling 输出 response_text不要返回普通文本。\n"
"6. response_text 使用 Markdown第一行用 ### 标题,正文与引导句之间留空行。\n"
"7. 如果主人是在问候,先礼貌回应再引导;"
"如果主人说的是非财务话题,温和说明小财管家能做什么;"
"如果主人的内容没有意义,请他/她换种说法。\n"
f"当前场景:{scenario_hint}"
),
},
{
"role": "user",
"content": json.dumps(
{
"message": request.message,
"scenario": scenario,
},
ensure_ascii=False,
),
},
]
@staticmethod
def _build_tool_schema() -> dict[str, Any]:
return {
"type": "function",
"function": {
"name": STEWARD_OFF_TOPIC_FUNCTION_NAME,
"description": (
"提交小财管家对业务无关输入的引导回复。"
"response_text 是完整 Markdown 文本:"
"第一行 ### 标题(基于场景),空行,正文(歉意或回应 + 能力范围 + 引导句)。"
),
"parameters": {
"type": "object",
"properties": {
"response_text": {
"type": "string",
"description": (
"面向用户的引导回复 Markdown 文本。"
"称呼用户为「主人」「您」,不要用「你」。"
),
},
},
"required": ["response_text"],
},
},
}

View File

@@ -21,6 +21,7 @@ from app.services.steward_constants import BUSINESS_CANONICAL_FIELD_ORDER, BUSIN
from app.services.ontology_field_registry import normalize_ontology_form_values from app.services.ontology_field_registry import normalize_ontology_form_values
from app.services.steward_intent_agent import StewardIntentAgent from app.services.steward_intent_agent import StewardIntentAgent
from app.services.steward_model_plan_builder import StewardModelPlanBuilder from app.services.steward_model_plan_builder import StewardModelPlanBuilder
from app.services.steward_off_topic_agent import StewardOffTopicAgent
CITY_NAMES = ( CITY_NAMES = (
@@ -70,6 +71,20 @@ STEWARD_BUSINESS_SIGNAL_KEYWORDS: tuple[str, ...] = (
*CITY_NAMES, *CITY_NAMES,
) )
# 业务无关输入的场景分类
STEWARD_OFF_TOPIC_SCENARIO_GREETING = "greeting"
STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS = "meaningless"
STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS = "off_business"
# 问候词:用于将"你好"等礼貌问候单独归类为 greeting 场景
STEWARD_GREETING_KEYWORDS: tuple[str, ...] = (
"你好", "您好", "hi", "hello", "hey", "", "哈喽",
"早上好", "上午好", "中午好", "下午好", "晚上好", "早安", "晚安",
"您好呀", "你好呀", "在吗", "在么", "在不在",
)
APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,。;;])[^,。;;]*?(?:申请|出差申请|差旅申请)[^,。;;]*") APPLICATION_SPLIT_PATTERN = re.compile(r"(?:^|[,。;;])[^,。;;]*?(?:申请|出差申请|差旅申请)[^,。;;]*")
REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,。;;?!\n]+)") REIMBURSEMENT_PATTERN = re.compile(r"(?:我要报销|还需要报销|需要报销|报销)([^,。;;?!\n]+)")
MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?") MONTH_DAY_PATTERN = re.compile(r"(?P<month>\d{1,2})\s*月\s*(?P<day>\d{1,2})\s*(?:日|号)?")
@@ -119,8 +134,13 @@ class PlannedTaskDraft:
class StewardPlannerService: class StewardPlannerService:
"""小财管家第一版规划服务:只生成计划,不执行入库类动作。""" """小财管家第一版规划服务:只生成计划,不执行入库类动作。"""
def __init__(self, intent_agent: StewardIntentAgent | None = None) -> None: def __init__(
self,
intent_agent: StewardIntentAgent | None = None,
off_topic_agent: StewardOffTopicAgent | None = None,
) -> None:
self.intent_agent = intent_agent self.intent_agent = intent_agent
self.off_topic_agent = off_topic_agent
def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse: def build_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
message = self._clean_text(request.message) message = self._clean_text(request.message)
@@ -129,8 +149,9 @@ class StewardPlannerService:
base_date = self._resolve_base_date(request.client_now_iso, request.context_json) base_date = self._resolve_base_date(request.client_now_iso, request.context_json)
# 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。 # 业务无关输入拦截(纯数字、问候、闲聊、乱码等):在进入 LLM/规则兜底之前直接返回 off_topic 计划。
if self._is_business_irrelevant_input(message, request): scenario = self._classify_irrelevant_input(message, request)
return self._build_off_topic_plan(request) if scenario is not None:
return self._build_off_topic_plan(request, scenario=scenario)
model_call_traces: list[dict[str, Any]] = [] model_call_traces: list[dict[str, Any]] = []
fallback_reason = "" fallback_reason = ""
if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request): if self.intent_agent is not None and self._should_use_model_intent_recognition(message, base_date, request):
@@ -185,48 +206,179 @@ class StewardPlannerService:
@staticmethod @staticmethod
def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool: def _is_business_irrelevant_input(message: str, request: StewardPlanRequest) -> bool:
"""判断输入是否与小财管家支持的财务事项完全无关。 """判断输入是否与小财管家支持的财务事项完全无关(向后兼容包装)
判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件 判定规则:消息去除所有空白后不含任何业务信号关键词,且没有上传附件
即视为业务无关输入(如纯数字、问候、闲聊、乱码) 实际判定逻辑由 _classify_irrelevant_input 负责,命中任何场景即视为业务无关。
"""
return StewardPlannerService._classify_irrelevant_input(message, request) is not None
@staticmethod
def _classify_irrelevant_input(message: str, request: StewardPlanRequest) -> str | None:
"""把业务无关输入细分为三个场景,便于给出更贴切的引导。
返回值:
- "greeting":礼貌问候("你好"等),无业务关键词
- "meaningless":完全无意义内容(纯数字、纯标点、单字符重复、纯字母数字乱码)
- "off_business":有意义但与财务无关(问天气、聊生活等)
- None消息与业务相关无需走 off_topic 路径
""" """
if request.attachments: if request.attachments:
return False return None
compact = re.sub(r"\s+", "", message) compact = re.sub(r"\s+", "", message)
if not compact: if not compact:
return False return None
return not any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS) if any(keyword in compact for keyword in STEWARD_BUSINESS_SIGNAL_KEYWORDS):
return None
if StewardPlannerService._looks_like_greeting(compact):
return STEWARD_OFF_TOPIC_SCENARIO_GREETING
if StewardPlannerService._looks_like_meaningless(compact):
return STEWARD_OFF_TOPIC_SCENARIO_MEANINGLESS
return STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS
@staticmethod
def _looks_like_greeting(compact_message: str) -> bool:
"""判断消息是否只是礼貌问候(无其他有意义内容)。"""
normalized = compact_message.lower()
for keyword in STEWARD_GREETING_KEYWORDS:
if normalized == keyword.lower() or normalized.startswith(keyword.lower()):
# 整句只是问候词(允许少量标点)
tail = normalized[len(keyword.lower()):]
if not tail or re.fullmatch(r"[!。.?,~\s]+", tail):
return True
return False
@staticmethod
def _looks_like_meaningless(compact_message: str) -> bool:
"""判断消息是否完全没有语义价值(纯数字、纯标点、单字符重复等)。"""
if re.fullmatch(r"\d+", compact_message):
return True
# 纯标点
if re.fullmatch(r"[\W_]+", compact_message):
return True
# 单字符重复(例如 "啊啊啊啊啊"
if len(compact_message) >= 2 and len(set(compact_message)) == 1:
return True
# 短字母数字组合但没有任何业务意义,例如 "abc"、"test123"
# 注意:必须排除已经被关键词命中的情况(前面的判定已保证不命中关键词)
if re.fullmatch(r"[a-zA-Z0-9]+", compact_message) and len(compact_message) <= 12:
return True
return False
def _build_off_topic_plan(
self,
request: StewardPlanRequest,
*,
scenario: str,
) -> StewardPlanResponse:
"""业务无关输入的兜底计划根据场景给出对应引导off_business 场景可由 LLM 增强。"""
base_summary = self._default_off_topic_summary(scenario)
thinking_event = self._build_off_topic_thinking_event(scenario)
suggested_prompts = self._off_topic_suggested_prompts(scenario)
model_call_traces: list[dict[str, Any]] = []
# 仅对 off_business 场景尝试让 LLM 生成多样化引导;问候/无意义场景用规则模板即可。
if (
scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS
and self.off_topic_agent is not None
):
try:
llm_result = self.off_topic_agent.generate(request, scenario=scenario)
if llm_result is not None and llm_result.response_text:
base_summary = llm_result.response_text
model_call_traces = llm_result.model_call_traces
except Exception:
# 失败时静默回退到规则模板
pass
def _build_off_topic_plan(self, request: StewardPlanRequest) -> StewardPlanResponse:
"""业务无关输入的兜底计划:明确告知用户未识别到财务事项,并给出话术示例。"""
return StewardPlanResponse( return StewardPlanResponse(
plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}", plan_id=f"steward_plan_{uuid.uuid4().hex[:12]}",
plan_status="off_topic", plan_status="off_topic",
planning_source="rule_fallback", planning_source="rule_fallback",
next_action="none", next_action="none",
summary="这看起来跟财务任务没什么关系,小财管家没识别到费用申请或费用报销的意图。", summary=base_summary,
thinking_events=[ thinking_events=[thinking_event],
StewardThinkingEvent(
event_id="intent_agent_off_topic",
stage="off_topic",
title="未识别到财务事项",
content=(
"我检查了这句话,没有发现费用申请、报销、出差、交通、招待等财务线索。"
"如果你确实是要处理财务任务,可以参考下面的示例换一种说法。"
),
)
],
tasks=[], tasks=[],
attachment_groups=[], attachment_groups=[],
confirmation_groups=[], confirmation_groups=[],
candidate_flows=[], candidate_flows=[],
suggested_prompts=[ suggested_prompts=suggested_prompts,
model_call_traces=model_call_traces,
)
@staticmethod
def _default_off_topic_summary(scenario: str) -> str:
"""off_topic 场景的默认引导文案LLM 不可用时使用。"""
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
return (
"### 您好主人,很高兴为您服务\n\n"
"请问您今天要办理什么业务?目前小财管家能帮您整理"
"**费用申请**和**费用报销**这两类事项。\n\n"
"要不您换种说法告诉我:"
)
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
return (
"### 抱歉主人,这句话我暂时帮不上忙\n\n"
"我看了您刚才说的这句话,里面聊的不是财务事项。"
"小财管家目前只能帮您整理**费用申请**和**费用报销**这两类业务。\n\n"
"要不您换种说法告诉我:"
)
# meaningless
return (
"### 这句话我暂时没识别到财务事项\n\n"
"很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n"
"要不您换种说法告诉我:"
)
@staticmethod
def _build_off_topic_thinking_event(scenario: str) -> StewardThinkingEvent:
"""off_topic 场景下向用户展示的思考过程摘要。"""
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
return StewardThinkingEvent(
event_id="intent_agent_off_topic_greeting",
stage="off_topic",
title="先回应主人的问候",
content="主人向我打了个招呼,我先礼貌回应一下,再引导他/她说出具体想办什么业务。",
)
if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
return StewardThinkingEvent(
event_id="intent_agent_off_topic_non_business",
stage="off_topic",
title="这句话不在服务范围内",
content="我看了您刚才说的这句话,里面聊的不是财务事项。小财管家目前只能帮您整理费用申请和费用报销。",
)
return StewardThinkingEvent(
event_id="intent_agent_off_topic_meaningless",
stage="off_topic",
title="未识别到财务事项",
content=(
"我仔细看了看您刚才说的这句话,里面好像没有出现"
"费用申请、报销、出差、交通、招待这些财务关键词。"
),
)
@staticmethod
def _off_topic_suggested_prompts(scenario: str) -> list[str]:
"""off_topic 场景下展示给用户的推荐话术。"""
if scenario == STEWARD_OFF_TOPIC_SCENARIO_GREETING:
return [
"我想要申请明天去北京出差3天支撑客户现场实施", "我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费", "我要报销昨天的交通费",
"报销上周出差上海的费用", "上周出差上海的费用需要报销",
], ]
model_call_traces=[], if scenario == STEWARD_OFF_TOPIC_SCENARIO_OFF_BUSINESS:
) return [
"我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费",
"我需要整理上周出差的发票",
]
# meaningless
return [
"我想要申请明天去北京出差3天支撑客户现场实施",
"我要报销昨天的交通费",
"我上周出差去上海的费用需要报销",
]
def _build_rule_fallback_plan( def _build_rule_fallback_plan(
self, self,
@@ -287,10 +439,12 @@ class StewardPlannerService:
planning_source: str = "rule_fallback", planning_source: str = "rule_fallback",
) -> StewardPlanResponse: ) -> StewardPlanResponse:
candidates = self._build_rule_candidate_flows(request, base_date) candidates = self._build_rule_candidate_flows(request, base_date)
gate = self._resolve_required_application_gate(request, "travel")
pending_reason = self._build_pending_flow_reason(gate)
pending = StewardPendingFlowConfirmation( pending = StewardPendingFlowConfirmation(
status="pending", status="pending",
source_message=request.message, source_message=request.message,
reason="当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。", reason=pending_reason,
candidate_flows=candidates, candidate_flows=candidates,
) )
thinking_events = [] thinking_events = []
@@ -308,7 +462,7 @@ class StewardPlannerService:
event_id="intent_pending_flow_confirmation", event_id="intent_pending_flow_confirmation",
stage="flow_confirmation", stage="flow_confirmation",
title="需要确认流程方向", title="需要确认流程方向",
content="我识别到时间、地点和出差事由,但没有识别到明确的申请或报销动作,需要先请你选择流程方向。", content=pending_reason,
) )
) )
return StewardPlanResponse( return StewardPlanResponse(
@@ -316,10 +470,7 @@ class StewardPlannerService:
plan_status="needs_flow_confirmation", plan_status="needs_flow_confirmation",
planning_source=planning_source, # type: ignore[arg-type] planning_source=planning_source, # type: ignore[arg-type]
next_action="confirm_flow", next_action="confirm_flow",
summary=( summary=self._build_pending_flow_summary(gate),
"我识别到这是一次出差事项,但还不能确定你要做的是"
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
),
thinking_events=thinking_events, thinking_events=thinking_events,
pending_flow_confirmation=pending, pending_flow_confirmation=pending,
candidate_flows=candidates, candidate_flows=candidates,
@@ -343,6 +494,24 @@ class StewardPlannerService:
base_date, base_date,
request, request,
) )
gate = self._resolve_required_application_gate(request, "travel")
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
return [
StewardCandidateFlow(
flow_id="travel_application",
label="先发起出差申请",
confidence=0.86,
reason="已先查询你名下可关联的差旅申请单,暂未查到可关联单据,因此应先申请单据。",
ontology_fields=application_fields,
missing_fields=self._resolve_missing_fields("expense_application", application_fields),
)
]
reimbursement_label = "发起费用报销"
reimbursement_reason = "用户描述的也可能是已发生出差事项,需要进入报销材料整理。"
if gate.get("checked"):
candidate_count = int(gate.get("candidate_count") or 0)
reimbursement_label = "关联已有申请单并发起报销"
reimbursement_reason = f"已先查到 {candidate_count} 个可关联申请单,选择后会先请你关联具体单据。"
return [ return [
StewardCandidateFlow( StewardCandidateFlow(
flow_id="travel_application", flow_id="travel_application",
@@ -354,14 +523,60 @@ class StewardPlannerService:
), ),
StewardCandidateFlow( StewardCandidateFlow(
flow_id="travel_reimbursement", flow_id="travel_reimbursement",
label="发起费用报销", label=reimbursement_label,
confidence=0.48, confidence=0.48,
reason="用户描述的也可能是已发生出差事项,需要进入报销材料整理。", reason=reimbursement_reason,
ontology_fields=reimbursement_fields, ontology_fields=reimbursement_fields,
missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields), missing_fields=self._resolve_missing_fields("reimbursement", reimbursement_fields),
), ),
] ]
@staticmethod
def _resolve_required_application_gate(
request: StewardPlanRequest,
expense_type: str,
) -> dict[str, Any]:
context = request.context_json if isinstance(request.context_json, dict) else {}
gates = context.get("required_application_gate")
if not isinstance(gates, dict):
return {}
gate = gates.get(expense_type)
if not isinstance(gate, dict) or not gate.get("checked"):
return {}
try:
candidate_count = max(0, int(gate.get("candidate_count") or 0))
except (TypeError, ValueError):
candidate_count = 0
return {
**gate,
"candidate_count": candidate_count,
"checked": True,
}
@staticmethod
def _build_pending_flow_reason(gate: dict[str, Any]) -> str:
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
return "我已经先查询你名下可关联的差旅申请单,未查到可关联单据,所以当前应先申请单据。"
if gate.get("checked"):
candidate_count = int(gate.get("candidate_count") or 0)
return f"我已经先查询你名下的差旅申请单,查到 {candidate_count} 个可关联申请单,需要你确认是否关联单据后发起报销。"
return "当前话术描述了出差事项,但没有明确说明要补办申请还是发起报销。"
@staticmethod
def _build_pending_flow_summary(gate: dict[str, Any]) -> str:
if gate.get("checked") and int(gate.get("candidate_count") or 0) <= 0:
return "我已先查询可关联申请单,暂未查到可关联单据;这次应先申请单据,再进入后续报销。"
if gate.get("checked"):
candidate_count = int(gate.get("candidate_count") or 0)
return (
f"我已先查询可关联申请单,查到 {candidate_count} 个可关联申请单;"
"你可以选择关联已有申请单发起报销,或改为补办新的出差申请。"
)
return (
"我识别到这是一次出差事项,但还不能确定你要做的是"
"**补办出差申请**还是**发起费用报销**。请先选择一个方向。"
)
def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]: def _extract_task_drafts(self, message: str) -> list[PlannedTaskDraft]:
drafts: list[PlannedTaskDraft] = [] drafts: list[PlannedTaskDraft] = []
first_reimbursement = self._find_first_reimbursement_index(message) first_reimbursement = self._find_first_reimbursement_index(message)
@@ -610,9 +825,11 @@ class StewardPlannerService:
return StewardPlannerService._strip_trailing_connectors(match.group(0)) return StewardPlannerService._strip_trailing_connectors(match.group(0))
reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",。;;的费用") reason = re.sub(r"^.*?(?:出差|差旅)", "", cleaned).strip(",。;;的费用")
return StewardPlannerService._strip_trailing_connectors(reason) or cleaned return StewardPlannerService._strip_trailing_connectors(reason) or cleaned
cleaned = re.sub(r"^报销", "", cleaned) cleaned = re.sub(r"^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销", "", cleaned)
if not cleaned or cleaned in {"费用", "报销单", "报销流程"}:
return ""
cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned) cleaned = re.sub(r"^(?:昨天|前天|明天|后天|\d{1,2}月\d{1,2}(?:日|号)?)的?", "", cleaned)
return cleaned.strip(",。;; ") or segment.strip() return cleaned.strip(",。;; ")
@staticmethod @staticmethod
def _strip_trailing_connectors(value: str) -> str: def _strip_trailing_connectors(value: str) -> str:

View File

@@ -86,6 +86,25 @@ APPLICATION_TRANSPORT_KEYWORDS = {
"火车": ("火车", "高铁", "动车", "铁路", "列车"), "火车": ("火车", "高铁", "动车", "铁路", "列车"),
"轮船": ("轮船", "", "客轮", "邮轮", "坐船"), "轮船": ("轮船", "", "客轮", "邮轮", "坐船"),
} }
APPLICATION_TYPE_DISPLAY_LABELS = {
"travel": "差旅费用申请",
"travel_application": "差旅费用申请",
"expense_application": "费用申请",
"application": "费用申请",
"transport": "交通费用申请",
"transportation": "交通费用申请",
"traffic": "交通费用申请",
"hotel": "住宿费用申请",
"accommodation": "住宿费用申请",
"meeting": "会务费用申请",
"conference": "会务费用申请",
"purchase": "采购费用申请",
"procurement": "采购费用申请",
"training": "培训费用申请",
"business_entertainment": "业务招待申请",
"entertainment": "业务招待申请",
"office": "办公费用申请",
}
APPLICATION_REASON_VERBS = ( APPLICATION_REASON_VERBS = (
"支撑", "支撑",
"支持", "支持",
@@ -316,6 +335,7 @@ class UserAgentApplicationMixin:
if value: if value:
facts[key] = value facts[key] = value
facts["application_type"] = self._normalize_application_type_label(facts.get("application_type", ""))
context_json = payload.context_json or {} context_json = payload.context_json or {}
context_time = self._resolve_application_time_from_context(context_json) context_time = self._resolve_application_time_from_context(context_json)
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time): if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
@@ -476,7 +496,9 @@ class UserAgentApplicationMixin:
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason")) reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
return { return {
"application_type": pick("applicationType", "application_type"), "application_type": UserAgentApplicationMixin._normalize_application_type_label(
pick("applicationType", "application_type")
),
"time": pick("time", "timeRange", "time_range"), "time": pick("time", "timeRange", "time_range"),
"location": pick("location"), "location": pick("location"),
"reason": reason, "reason": reason,
@@ -842,11 +864,40 @@ class UserAgentApplicationMixin:
@staticmethod @staticmethod
def _resolve_application_type_from_text(message: str) -> str: def _resolve_application_type_from_text(message: str) -> str:
return UserAgentApplicationMixin._resolve_application_labeled_value( return UserAgentApplicationMixin._normalize_application_type_label(
message, UserAgentApplicationMixin._resolve_application_labeled_value(
("申请类型", "费用类型"), message,
("申请类型", "费用类型"),
)
) )
@staticmethod
def _normalize_application_type_label(value: object, fallback: str = "") -> str:
raw_value = str(value or "").strip()
if not raw_value:
return str(fallback or "").strip()
normalized_key = raw_value.lower()
if normalized_key in APPLICATION_TYPE_DISPLAY_LABELS:
return APPLICATION_TYPE_DISPLAY_LABELS[normalized_key]
if re.fullmatch(r"(差旅费|差旅|出差)", raw_value):
return "差旅费用申请"
if re.fullmatch(r"(交通费|交通)", raw_value):
return "交通费用申请"
if re.fullmatch(r"(住宿费|住宿|酒店)", raw_value):
return "住宿费用申请"
if re.fullmatch(r"(会务|会议|会务费)", raw_value):
return "会务费用申请"
if re.fullmatch(r"(采购|采购费|办公用品)", raw_value):
return "采购费用申请"
if raw_value.endswith("费用申请") or raw_value.endswith("申请"):
return raw_value
if raw_value.endswith("费用"):
return f"{raw_value}申请"
if raw_value.endswith(""):
return f"{raw_value[:-1]}费用申请"
return raw_value
@staticmethod @staticmethod
def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]: def _resolve_application_missing_slots(payload: UserAgentRequest) -> list[str]:
return [ return [

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
import threading
from typing import Any from typing import Any
from sqlalchemy import or_, select from sqlalchemy import or_, select
@@ -14,10 +15,30 @@ MAX_SESSION_DURATION_MS = 24 * 60 * 60 * 1000
class UserSessionMetricService: class UserSessionMetricService:
_storage_ready_lock = threading.Lock()
_storage_ready_keys: set[tuple[str, int]] = set()
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@staticmethod
def _bind_cache_key(db: Session) -> tuple[str, int]:
bind = db.get_bind()
return (bind.url.render_as_string(hide_password=True), id(bind.pool))
def ensure_storage_ready(self) -> None: def ensure_storage_ready(self) -> None:
cache_key = self._bind_cache_key(self.db)
if cache_key in self._storage_ready_keys:
return
with self._storage_ready_lock:
if cache_key in self._storage_ready_keys:
return
self._ensure_storage_ready_uncached()
self._storage_ready_keys.add(cache_key)
def _ensure_storage_ready_uncached(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind(), tables=[UserSessionMetric.__table__]) Base.metadata.create_all(bind=self.db.get_bind(), tables=[UserSessionMetric.__table__])
def start_session( def start_session(

View File

@@ -1,122 +0,0 @@
{
"id": "0c2b040a-7b4d-49e3-b889-e39aeda53eec",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"file_sha256": "203b92047a43cb41fe2fc0dffcc27da8f1eaebd651494b63badd74c24171a150",
"uploaded_at": "2026-06-15T12:22:13.537867+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "8d37d92c-0dfe-46ae-82b4-126446b3d39b",
"linked_at": "2026-06-15T12:22:13.537867+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620034111042818,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,106 +0,0 @@
{
"id": "1dedc126-9719-40cf-8c02-7b644d62019e",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月21日_上海-深圳.png",
"source_file_name": "2月21日_上海-深圳.png",
"media_type": "image/png",
"size_bytes": 1500480,
"file_sha256": "74dc6598a21fcd581a4e7370612b3dc75133f6e128aa219220606df3d293caa7",
"uploaded_at": "2026-06-15T12:32:42.695935+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "999f2e09-a4a2-48b9-8b5a-a970c8f6f2e7",
"linked_at": "2026-06-15T12:32:42.695935+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "行程单示意\n出票渠道示例平台\n非官方车票\n不可报销\n仅供演示\n创建日期2026年02月15日\nQ\n订单号DEMO202602210001\n单据编号DEMO-IT-000001\n上海虹桥\nG999\n深圳北\n站\n站\nShanghaihongqiao\nShenzhenbei\n2026年02月21日\n08:30出发\n全程约7小时30分\n15:00到达\nDEMO\n乘客示例旅客\n车厢05车\n席别二等座\n-\n扫码无效\n证件号310101199001010000\n座位08A\n票价¥438.00\n仅为演示\n乘车提示请提前到达车站预留充足时间办理安检及检票。\n温馨提示此行程单仅供演示使用不具备法律效力。\n本行程单为演示用途信息为虚构示例非真实有效凭证\n★★★仅供演示使用★★★",
"summary": "行程单示意;出票渠道:示例平台;非官方车票",
"ocr_avg_score": 0.958684408927665,
"ocr_line_count": 34,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.74,
"ocr_classification_evidence": [
"检票",
"二等座",
"票价"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "438元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-21 08:30"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "DEMO202602210001"
},
{
"key": "route",
"label": "行程",
"value": "上海-深圳"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "二等座"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "扫码无效"
},
{
"key": "train_no",
"label": "车次",
"value": "G999"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "座位"
},
{
"key": "id_number",
"label": "身份证号",
"value": "310101199001010000"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "05车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08A"
},
{
"key": "fare",
"label": "票价",
"value": "438.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

View File

@@ -1,122 +0,0 @@
{
"id": "1e92ef32-e1a8-4724-a60e-e0b3b93588c3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"file_sha256": "203b92047a43cb41fe2fc0dffcc27da8f1eaebd651494b63badd74c24171a150",
"uploaded_at": "2026-06-15T12:20:49.368603+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "31514799-5fd6-411b-b53a-2febc24cd24e",
"linked_at": "2026-06-15T12:20:49.368603+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620034111042818,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,56 +0,0 @@
{
"id": "27cbaab2-e421-4794-a09a-a60c4ca329b3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "酒店3.jpg",
"source_file_name": "酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"file_sha256": "903e41fbf4c156288748330365b7411ca43d89cee17f5737bc4e76c007041a6b",
"uploaded_at": "2026-06-15T12:23:31.694634+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "4e9534f4-2e81-4ff3-82dc-8c60f53d5637",
"linked_at": "2026-06-15T12:23:31.694634+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.9887906948725382,
"ocr_line_count": 30,
"page_count": 1,
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.jpg",
"preview_media_type": "image/jpeg"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -1,122 +0,0 @@
{
"id": "3146453b-8374-406c-939b-3d50d6aa1fc3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"file_sha256": "203b92047a43cb41fe2fc0dffcc27da8f1eaebd651494b63badd74c24171a150",
"uploaded_at": "2026-06-15T12:06:10.650184+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "8269f611-2e96-4b15-b89e-853bb7c36def",
"linked_at": "2026-06-15T12:06:10.650184+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620034111042818,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "33a1c4b9-56e1-49d1-823e-5cb4680f5a40",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-03T08:39:19.288158+00:00",
"status": "linked",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "eb1e9fde-b7e8-4f6e-823f-d8252489e7f9",
"linked_at": "2026-06-03T08:39:19.288158+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,96 +0,0 @@
{
"id": "62202861-bd00-4538-9278-e89ca5c62abb",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月22日_深圳-上海.png",
"source_file_name": "2月22日_深圳-上海.png",
"media_type": "image/png",
"size_bytes": 1527491,
"file_sha256": "0a161c49b961c6c38edc53b6f284ceec396665aee852c9512b81780ad072d3cb",
"uploaded_at": "2026-06-15T12:33:19.636062+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "5ebb4320-17a3-431c-a7cb-0c21a4963610",
"linked_at": "2026-06-15T12:33:19.636062+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "行程单示意\n示例编号DEMO20260222A001\n仅供演示使用·非官方凭证一\n打印日期2026年02月15日\n深圳北站\n上海虹桥站\nG998\nShenzhenbei\nShanghaihongqiao\n2026年02月22日\n09:15开\n02车08B号\n二等座\n非官方车票\n乘客示例旅客\n不可报销\n票价¥388.00\n温馨提示\n仅供演示\n• 请提前到达车站,预留充足时间\n身份证44030019*******15\n具体车次、时间以实际为准\n·本行程单不作为乘车凭证\n·仅用于界面设计与功能演示\n示意码DEMO1234\n此码仅为演示占位无实际价值\n订单编号DEMO20260222A001\n购买渠道示例演示平台\n统一客户代码DEMO0000000000\n祝您旅途愉快一路平安\n本行程单仅为示意非官方票据不可用于乘车或报销。",
"summary": "行程单示意示例编号DEMO20260222A001仅供演示使用·非官方凭证一",
"ocr_avg_score": 0.9907940914553981,
"ocr_line_count": 31,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.74,
"ocr_classification_evidence": [
"车次",
"二等座",
"票价"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "388元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-22 09:15"
},
{
"key": "route",
"label": "行程",
"value": "深圳-上海"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "深圳北"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G998"
},
{
"key": "id_number",
"label": "身份证号",
"value": "44030019******"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "02车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "388.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View File

@@ -1,121 +0,0 @@
{
"id": "67f51c17-a2bc-42bd-99a4-199ee32b18c3",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-03T08:40:26.766004+00:00",
"status": "linked",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "977f01f8-e7ab-487b-8055-db8864464784",
"linked_at": "2026-06-03T08:40:26.766004+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,55 +0,0 @@
{
"id": "8678e169-d800-4846-9354-eb768a9b65f8",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "酒店3.jpg",
"source_file_name": "酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"uploaded_at": "2026-06-03T08:41:47.393654+00:00",
"status": "linked",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "42250571-3e84-4f27-b3e8-aa224b5cb2f7",
"linked_at": "2026-06-03T08:41:47.393654+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.988790222009023,
"ocr_line_count": 30,
"page_count": 1,
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.jpg",
"preview_media_type": "image/jpeg"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -1,122 +0,0 @@
{
"id": "a69238a5-65bf-45b7-9a9e-3aa4f8e662b8",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"file_sha256": "618de348b6d8c822af23b6f603167847ef4fe73a149eafbc97ebf29b0932d58d",
"uploaded_at": "2026-06-15T12:22:40.316616+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "b60b4316-9a56-4792-85be-22a751a59bf5",
"linked_at": "2026-06-15T12:22:40.316616+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580971281975508,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,122 +0,0 @@
{
"id": "b7af7d91-7577-4d68-8a9d-ebcb2f552326",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"file_sha256": "618de348b6d8c822af23b6f603167847ef4fe73a149eafbc97ebf29b0932d58d",
"uploaded_at": "2026-06-15T12:05:36.780929+00:00",
"status": "linked",
"linked_claim_id": "e498fc3d-a4b5-40e6-8725-36a0f8476a81",
"linked_claim_no": "RE-20260615120435-R5AYFCFZ",
"linked_item_id": "bc11962c-d345-4bc2-acde-3c3b1348327b",
"linked_at": "2026-06-15T12:05:36.780929+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580971281975508,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -7,7 +7,7 @@ from sqlalchemy.pool import StaticPool
from app.db.base import Base from app.db.base import Base
from app.schemas.auth import LoginRequest from app.schemas.auth import LoginRequest
from app.schemas.settings import SettingsWrite from app.schemas.settings import SettingsWrite
from app.services.auth import AuthService from app.services.auth import AuthService, AuthenticatedUser
from app.services.employee import EmployeeService from app.services.employee import EmployeeService
from app.services.settings import SettingsService from app.services.settings import SettingsService
@@ -97,3 +97,49 @@ def test_reenabled_employee_can_login_again() -> None:
assert result.ok is True assert result.ok is True
assert result.user.username == employee.email assert result.user.username == employee.email
def test_employee_login_skips_directory_bootstrap_when_employee_exists(monkeypatch) -> None:
with build_session() as db:
service = AuthService(db)
calls: list[str] = []
class ExistingEmployee:
email = "demo@example.com"
password_hash = "hash"
employment_status = "在职"
def fail_if_bootstrapped(self) -> None:
calls.append("ensure_directory_ready")
raise AssertionError("existing employee login should not run directory bootstrap")
monkeypatch.setattr(AuthService, "_find_employee_by_email", lambda self, _: ExistingEmployee())
monkeypatch.setattr("app.services.auth.verify_password", lambda password, password_hash: True)
monkeypatch.setattr(
AuthService,
"_build_employee_user",
lambda self, employee: AuthenticatedUser(
username=employee.email,
name="Demo",
role="使用者",
department="",
position="",
grade="",
employee_no="",
manager_name="",
location="",
cost_center="",
finance_owner_name="",
risk_profile={},
role_codes=["user"],
email=employee.email,
avatar="D",
),
)
monkeypatch.setattr(EmployeeService, "ensure_directory_ready", fail_if_bootstrapped)
user = service._authenticate_employee("demo@example.com", "123456")
assert user is not None
assert user.username == "demo@example.com"
assert calls == []

View File

@@ -79,3 +79,63 @@ def test_root_start_can_prefer_env_file_over_inherited_onlyoffice_values(tmp_pat
assert result.returncode == 0, result.stderr assert result.returncode == 0, result.stderr
assert "ONLYOFFICE_ENABLED=true" in result.stdout assert "ONLYOFFICE_ENABLED=true" in result.stdout
assert "ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082" in result.stdout assert "ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082" in result.stdout
def test_web_start_preserves_inherited_runtime_ports(tmp_path: Path) -> None:
result = _run_script_prefix(
tmp_path,
"web/web_start.sh",
env_file_content=(
"WEB_HOST=10.10.10.122\n"
"WEB_PORT=5273\n"
"SERVER_HOST=10.10.10.122\n"
"SERVER_PORT=9000\n"
"POSTGRES_HOST=10.10.10.189\n"
),
env={
"WEB_HOST": "0.0.0.0",
"WEB_PORT": "5173",
"SERVER_HOST": "0.0.0.0",
"SERVER_PORT": "8000",
"POSTGRES_HOST": "www.caoxiaozhu.com",
},
output_vars=["WEB_HOST", "WEB_PORT", "SERVER_HOST", "SERVER_PORT", "POSTGRES_HOST"],
)
assert result.returncode == 0, result.stderr
assert "WEB_HOST=0.0.0.0" in result.stdout
assert "WEB_PORT=5173" in result.stdout
assert "SERVER_HOST=0.0.0.0" in result.stdout
assert "SERVER_PORT=8000" in result.stdout
assert "POSTGRES_HOST=www.caoxiaozhu.com" in result.stdout
def test_server_start_preserves_inherited_runtime_guards(tmp_path: Path) -> None:
result = _run_script_prefix(
tmp_path,
"server/server_start.sh",
env_file_content=(
"SERVER_HOST=10.10.10.122\n"
"SERVER_PORT=9000\n"
"STARTUP_BOOTSTRAP_ENABLED=true\n"
"BACKGROUND_SCHEDULERS_ENABLED=true\n"
),
env={
"SERVER_HOST": "0.0.0.0",
"SERVER_PORT": "8000",
"STARTUP_BOOTSTRAP_ENABLED": "false",
"BACKGROUND_SCHEDULERS_ENABLED": "false",
},
output_vars=[
"SERVER_HOST",
"SERVER_PORT",
"STARTUP_BOOTSTRAP_ENABLED",
"BACKGROUND_SCHEDULERS_ENABLED",
],
)
assert result.returncode == 0, result.stderr
assert "SERVER_HOST=0.0.0.0" in result.stdout
assert "SERVER_PORT=8000" in result.stdout
assert "STARTUP_BOOTSTRAP_ENABLED=false" in result.stdout
assert "BACKGROUND_SCHEDULERS_ENABLED=false" in result.stdout

File diff suppressed because it is too large Load Diff

View File

@@ -186,6 +186,23 @@ def test_legacy_setup_admin_password_is_migrated_to_database(monkeypatch) -> Non
assert service.verify_admin_login("setup-admin", password) is not None assert service.verify_admin_login("setup-admin", password) is not None
def test_default_admin_credentials_are_written_to_database(monkeypatch) -> None:
temp_dir = build_temp_secret_dir()
monkeypatch.setattr(admin_secret, "ADMIN_SECRET_FILE", temp_dir / "missing-admin.json")
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
with build_session(temp_dir / "settings.db") as db:
service = SettingsService(db)
settings_row, secrets_row = service.ensure_settings_ready()
assert settings_row.admin_account == "admin"
assert secrets_row.admin_password_hash
assert service.verify_admin_login("admin", "admin") is not None
assert service.verify_admin_login("superadmin", "admin") is None
def test_settings_service_syncs_models_to_hermes_config(monkeypatch) -> None: def test_settings_service_syncs_models_to_hermes_config(monkeypatch) -> None:
temp_dir = build_temp_secret_dir() temp_dir = build_temp_secret_dir()
hermes_home = temp_dir / ".hermes" hermes_home = temp_dir / ".hermes"

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from fastapi.testclient import TestClient
from app.core.config import get_settings
import app.main as main_module
def _set_bootstrap_step(
monkeypatch,
name: str,
calls: list[str],
*,
should_fail: bool = False,
) -> None:
def step() -> None:
calls.append(name)
if should_fail:
raise RuntimeError(f"{name} failed")
monkeypatch.setattr(main_module, name, step)
def test_lifespan_can_skip_startup_bootstrap(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setenv("STARTUP_BOOTSTRAP_ENABLED", "false")
monkeypatch.setenv("BACKGROUND_SCHEDULERS_ENABLED", "false")
get_settings.cache_clear()
for name in (
"prepare_employee_directory",
"prepare_agent_foundation",
"prepare_knowledge_library",
"sync_repository_hermes_skills",
):
_set_bootstrap_step(monkeypatch, name, calls)
try:
with TestClient(main_module.create_app()) as client:
response = client.get("/")
finally:
get_settings.cache_clear()
assert response.status_code == 200
assert calls == []
def test_lifespan_continues_when_startup_bootstrap_fails(monkeypatch) -> None:
calls: list[str] = []
monkeypatch.setenv("STARTUP_BOOTSTRAP_ENABLED", "true")
monkeypatch.setenv("BACKGROUND_SCHEDULERS_ENABLED", "false")
get_settings.cache_clear()
steps: tuple[tuple[str, bool], ...] = (
("prepare_employee_directory", True),
("prepare_agent_foundation", False),
("prepare_knowledge_library", False),
("sync_repository_hermes_skills", False),
)
for name, should_fail in steps:
_set_bootstrap_step(monkeypatch, name, calls, should_fail=should_fail)
try:
with TestClient(main_module.create_app()) as client:
response = client.get("/")
finally:
get_settings.cache_clear()
assert response.status_code == 200
assert calls == [name for name, _ in steps]

View File

@@ -1,10 +1,18 @@
from __future__ import annotations from __future__ import annotations
import json import json
from datetime import UTC, datetime
from decimal import Decimal
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import get_db
from app.db.base import Base
from app.main import create_app from app.main import create_app
from app.models.financial_record import ExpenseClaim
from app.schemas.steward import StewardAttachmentInput, StewardPlanRequest from app.schemas.steward import StewardAttachmentInput, StewardPlanRequest
from app.services.steward_intent_agent import StewardIntentAgentResult from app.services.steward_intent_agent import StewardIntentAgentResult
from app.services.steward_planner import StewardPlannerService from app.services.steward_planner import StewardPlannerService
@@ -226,6 +234,61 @@ class AmbiguousApplicationFunctionCallingIntentAgent:
) )
def _create_steward_test_client_with_db():
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
TestingSessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app), TestingSessionLocal, app
def _build_endpoint_application_claim(
*,
claim_no: str = "AP-202602-001",
employee_name: str = "张小青",
status: str = "approved",
) -> ExpenseClaim:
return ExpenseClaim(
id=claim_no.lower().replace("-", "_"),
claim_no=claim_no,
employee_name=employee_name,
department_name="产品交付部",
expense_type="travel_application",
reason="辅助国网仿生产服务器部署",
location="上海",
amount=Decimal("1800.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=datetime(2026, 2, 19, tzinfo=UTC),
status=status,
approval_stage="关联单据状态",
risk_flags_json=[
{
"source": "application_detail",
"application_detail": {
"application_business_time": "2026-02-20 至 2026-02-23",
"location": "上海",
"reason": "辅助国网仿生产服务器部署",
},
}
],
)
def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None: def test_steward_planner_uses_llm_function_calling_plan_when_available() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39", message="\u6211\u60f3\u7533\u8bf7\u0037\u6708\u0032\u65e5\u53bb\u5317\u4eac\u51fa\u5dee\uff0c\u5e76\u4e14\u6211\u8981\u62a5\u9500\u6628\u5929\u5ba2\u6237\u73b0\u573a\u6c9f\u901a\u7684\u4ea4\u901a\u8d39",
@@ -393,6 +456,61 @@ def test_steward_planner_rule_fallback_confirms_ambiguous_travel_flow() -> None:
assert result.confirmation_groups == [] assert result.confirmation_groups == []
def test_steward_planner_prefers_application_when_checked_required_application_missing() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产服务器部署",
client_now_iso="2026-06-15T09:30:00+08:00",
context_json={
"required_application_gate": {
"travel": {
"checked": True,
"candidate_count": 0,
"candidates": [],
}
}
},
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert result.planning_source == "rule_fallback"
assert result.next_action == "confirm_flow"
assert result.pending_flow_confirmation.status == "pending"
assert [item.flow_id for item in result.candidate_flows] == ["travel_application"]
assert result.candidate_flows[0].label == "先发起出差申请"
assert "未查到可关联" in result.pending_flow_confirmation.reason
assert "先申请" in result.summary
def test_steward_planner_asks_to_link_application_when_checked_required_application_exists() -> None:
payload = StewardPlanRequest(
message="2月20-23日去上海出差辅助国网仿生产服务器部署",
client_now_iso="2026-06-15T09:30:00+08:00",
context_json={
"required_application_gate": {
"travel": {
"checked": True,
"candidate_count": 2,
"candidates": [
{"claim_no": "AP-202602-001"},
{"claim_no": "AP-202602-002"},
],
}
}
},
)
result = StewardPlannerService(intent_agent=EmptyFunctionCallingIntentAgent()).build_plan(payload)
assert [item.flow_id for item in result.candidate_flows] == [
"travel_application",
"travel_reimbursement",
]
assert result.candidate_flows[1].label == "关联已有申请单并发起报销"
assert "查到 2 个可关联申请单" in result.pending_flow_confirmation.reason
assert "关联已有申请单" in result.summary
def test_steward_planner_splits_application_and_reimbursement_tasks() -> None: def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message=( message=(
@@ -423,6 +541,24 @@ def test_steward_planner_splits_application_and_reimbursement_tasks() -> None:
assert all(action.status == "pending" for action in result.confirmation_groups) assert all(action.status == "pending" for action in result.confirmation_groups)
def test_steward_planner_keeps_bare_reimbursement_intent_generic() -> None:
payload = StewardPlanRequest(
message="我要报销",
user_id="u001",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService().build_plan(payload)
assert len(result.tasks) == 1
task = result.tasks[0]
assert task.task_type == "reimbursement"
assert task.assigned_agent == "reimbursement_assistant"
assert task.ontology_fields.get("expense_type") == "other"
assert "reason" not in task.ontology_fields
assert task.missing_fields == ["time_range", "reason"]
def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None: def test_steward_planner_treats_future_travel_without_apply_word_as_application() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="明天出差北京3天支撑国网仿生产部署并且报销昨天业务招待费", message="明天出差北京3天支撑国网仿生产部署并且报销昨天业务招待费",
@@ -549,6 +685,59 @@ def test_steward_plan_endpoint_persists_application_and_reimbursement_state() ->
assert all("invented_field" not in flow["fields"] for flow in state["flows"].values()) assert all("invented_field" not in flow["fields"] for flow in state["flows"].values())
def test_steward_plan_endpoint_queries_applications_before_ambiguous_travel_choice() -> None:
client, SessionLocal, app = _create_steward_test_client_with_db()
try:
response = client.post(
"/api/v1/steward/plans",
json={
"message": "2月20-23日去上海出差辅助国网仿生产服务器部署",
"user_id": "zhang.xiaoqing",
"client_now_iso": "2026-06-15T09:30:00+08:00",
"context_json": {
"session_type": "steward",
"entry_source": "workbench_ai_inline",
"name": "张小青",
"username": "zhang.xiaoqing",
},
},
)
assert response.status_code == 200
payload = response.json()
assert [item["flow_id"] for item in payload["candidate_flows"]] == ["travel_application"]
assert payload["candidate_flows"][0]["label"] == "先发起出差申请"
assert "未查到可关联单据" in payload["pending_flow_confirmation"]["reason"]
with SessionLocal() as db:
db.add(_build_endpoint_application_claim())
db.commit()
response = client.post(
"/api/v1/steward/plans",
json={
"message": "2月20-23日去上海出差辅助国网仿生产服务器部署",
"user_id": "zhang.xiaoqing",
"client_now_iso": "2026-06-15T09:30:00+08:00",
"context_json": {
"session_type": "steward",
"entry_source": "workbench_ai_inline",
"name": "张小青",
"username": "zhang.xiaoqing",
},
},
)
assert response.status_code == 200
payload = response.json()
assert [item["flow_id"] for item in payload["candidate_flows"]] == [
"travel_application",
"travel_reimbursement",
]
assert payload["candidate_flows"][1]["label"] == "关联已有申请单并发起报销"
assert "查到 1 个可关联申请单" in payload["pending_flow_confirmation"]["reason"]
finally:
app.dependency_overrides.clear()
def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> None: def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="123", message="123",
@@ -566,9 +755,11 @@ def test_steward_planner_returns_off_topic_for_business_irrelevant_input() -> No
assert result.planning_source == "rule_fallback" assert result.planning_source == "rule_fallback"
assert len(result.suggested_prompts) == 3 assert len(result.suggested_prompts) == 3
assert result.thinking_events[0].stage == "off_topic" assert result.thinking_events[0].stage == "off_topic"
# 纯数字应归类为 meaningless 场景
assert "未识别到财务事项" in result.thinking_events[0].title
def test_steward_planner_returns_off_topic_for_pure_greeting() -> None: def test_steward_planner_returns_off_topic_with_friendly_greeting_reply() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="你好", message="你好",
client_now_iso="2026-06-04T09:30:00+08:00", client_now_iso="2026-06-04T09:30:00+08:00",
@@ -582,7 +773,10 @@ def test_steward_planner_returns_off_topic_for_pure_greeting() -> None:
assert result.candidate_flows == [] assert result.candidate_flows == []
assert result.planning_source == "rule_fallback" assert result.planning_source == "rule_fallback"
assert len(result.suggested_prompts) == 3 assert len(result.suggested_prompts) == 3
assert result.thinking_events[0].stage == "off_topic" # 问候场景应礼貌回应主人,不使用"抱歉/没识别到"等生硬措辞
assert "您好主人" in result.summary
assert "很高兴为您服务" in result.summary
assert "先回应主人的问候" in result.thinking_events[0].title
def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None: def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None:
@@ -602,6 +796,86 @@ def test_steward_planner_returns_off_topic_for_pure_punctuation() -> None:
assert result.thinking_events[0].stage == "off_topic" assert result.thinking_events[0].stage == "off_topic"
def test_steward_planner_returns_off_topic_for_off_business_with_llm_response() -> None:
"""有内容但与业务无关的场景:应优先使用 LLM 生成的引导文案。"""
llm_text = (
"### 抱歉主人,这句话我暂时帮不上忙\n\n"
"主人聊的是天气,目前小财管家只能帮您整理**费用申请**和**费用报销**。"
"要不您把想办的财务事项告诉我?"
)
class _FakeOffTopicAgent:
def __init__(self) -> None:
self.calls = 0
self.last_call_traces: list[dict[str, object]] = []
def generate(self, request, *, scenario):
self.calls += 1
from app.services.steward_off_topic_agent import StewardOffTopicAgentResult
return StewardOffTopicAgentResult(
response_text=llm_text,
model_call_traces=[{"slot": "main", "status": "succeeded", "model": "gpt-test"}],
)
agent = _FakeOffTopicAgent()
payload = StewardPlanRequest(
message="想问候您一下",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(off_topic_agent=agent).build_plan(payload)
assert agent.calls == 1
assert result.plan_status == "off_topic"
assert result.summary == llm_text
assert result.model_call_traces and result.model_call_traces[0]["status"] == "succeeded"
# 思考事件应是 off_business 场景对应文案
assert "不在服务范围内" in result.thinking_events[0].title
def test_steward_planner_falls_back_to_template_when_off_topic_agent_raises() -> None:
"""LLM 失败时静默 fallback 到规则模板,不阻断业务无关拦截。"""
class _ExplodingOffTopicAgent:
def generate(self, request, *, scenario):
raise RuntimeError("模型供应商不可用")
payload = StewardPlanRequest(
message="想问候您一下",
client_now_iso="2026-06-04T09:30:00+08:00",
)
result = StewardPlannerService(off_topic_agent=_ExplodingOffTopicAgent()).build_plan(payload)
assert result.plan_status == "off_topic"
# 仍使用 off_business 场景的默认模板
assert "抱歉主人" in result.summary
assert "不在服务范围内" in result.thinking_events[0].title
assert result.model_call_traces == []
def test_steward_planner_skips_off_topic_agent_for_greeting_and_meaningless() -> None:
"""问候与无意义场景不走 LLM节省调用。"""
class _CallCounterOffTopicAgent:
def __init__(self) -> None:
self.calls = 0
def generate(self, request, *, scenario):
self.calls += 1
return None
agent = _CallCounterOffTopicAgent()
service = StewardPlannerService(off_topic_agent=agent)
for message in ("你好", "123", "???"):
result = service.build_plan(StewardPlanRequest(message=message))
assert result.plan_status == "off_topic"
assert agent.calls == 0
def test_steward_planner_preserves_normal_business_flow_after_guard() -> None: def test_steward_planner_preserves_normal_business_flow_after_guard() -> None:
payload = StewardPlanRequest( payload = StewardPlanRequest(
message="我要报销昨天的交通费", message="我要报销昨天的交通费",

View File

@@ -753,6 +753,33 @@ def test_user_agent_application_submit_blocks_overlapping_travel_dates() -> None
assert response.draft_payload is None assert response.draft_payload is None
def test_user_agent_application_maps_preview_travel_type_label() -> None:
session_factory = build_session_factory()
with session_factory() as db:
response = build_application_user_agent_response(
db,
"申请出差2月20-23日上海火车",
context_overrides={
"name": "曹笑竹",
"department_name": "技术部",
"grade": "P5",
"application_preview": {
"fields": {
"applicationType": "travel",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "",
"days": "4天",
"transportMode": "火车",
}
},
},
)
assert "| 申请类型 | 差旅费用申请 |" in response.answer
assert "| 申请类型 | travel |" not in response.answer
def test_user_agent_application_edit_resubmits_returned_application_claim() -> None: def test_user_agent_application_edit_resubmits_returned_application_claim() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:

BIN
web/UI/AI模式.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

@@ -15,7 +15,7 @@
} }
.app { .app {
--sidebar-expanded-width: 184px; --sidebar-expanded-width: 304px;
--sidebar-collapsed-width: 64px; --sidebar-collapsed-width: 64px;
--sidebar-motion: 220ms cubic-bezier(0.4, 0, 0.2, 1); --sidebar-motion: 220ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -43,8 +43,8 @@
} }
.app.sidebar-collapsed .app-sidebar { .app.sidebar-collapsed .app-sidebar {
flex-basis: var(--sidebar-collapsed-width);
width: var(--sidebar-collapsed-width); width: var(--sidebar-collapsed-width);
flex-basis: var(--sidebar-collapsed-width);
overflow: visible; overflow: visible;
z-index: 200; z-index: 200;
} }
@@ -54,6 +54,19 @@
z-index: 1; z-index: 1;
} }
.sidebar-mode-fade-enter-active,
.sidebar-mode-fade-leave-active {
transition:
opacity 180ms var(--ease),
transform 180ms var(--ease);
}
.sidebar-mode-fade-enter-from,
.sidebar-mode-fade-leave-to {
opacity: 0;
transform: translateX(-8px);
}
.app > .main { .app > .main {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
@@ -133,7 +146,7 @@
color: var(--theme-primary-active); color: var(--theme-primary-active);
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
letter-spacing: 0.12em; letter-spacing: 0;
} }
.boot-badge-error { .boot-badge-error {
@@ -217,6 +230,10 @@
background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px; background-size: 100% 100%, 100% 100%, 32px 32px, 32px 32px;
background-attachment: local; background-attachment: local;
} }
.workarea.workbench-workarea.workbench-workarea-ai-mode {
padding: 0;
background: transparent;
}
.workarea.settings-workarea { .workarea.settings-workarea {
padding: 0; padding: 0;
background: #fff; background: #fff;
@@ -312,6 +329,7 @@
} }
.workarea.workbench-workarea { overflow: auto; padding: 16px; } .workarea.workbench-workarea { overflow: auto; padding: 16px; }
.workarea.workbench-workarea.workbench-workarea-ai-mode { padding: 0; }
.mobile-overlay { .mobile-overlay {
position: fixed; position: fixed;

View File

@@ -0,0 +1,676 @@
.ai-rail {
--ai-rail-bg: #f7f9fc;
--ai-rail-panel: rgba(255, 255, 255, 0.76);
--ai-rail-line: rgba(148, 163, 184, 0.14);
--ai-rail-text: #162033;
--ai-rail-muted: #738097;
--ai-rail-accent: #2d72d9;
--ai-rail-amber: #b76b16;
--ai-rail-green: #2f8d7b;
--ai-rail-ink-soft: #41506a;
--ai-rail-accent-soft: rgba(45, 114, 217, 0.08);
position: sticky;
top: 0;
width: 100%;
height: var(--desktop-stage-height, 100dvh);
min-height: var(--desktop-stage-height, 100dvh);
display: grid;
grid-template-rows: auto auto auto auto auto minmax(0, 1fr) auto;
overflow: hidden;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 249, 252, 0.96) 62%, rgba(244, 247, 251, 0.98)),
var(--ai-rail-bg);
border-right: 1px solid rgba(203, 213, 225, 0.54);
box-shadow:
inset -1px 0 0 rgba(255, 255, 255, 0.64),
1px 0 0 rgba(15, 23, 42, 0.02);
color: var(--ai-rail-text);
contain: layout paint;
}
.ai-rail::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.56), transparent 16%),
repeating-linear-gradient(
180deg,
rgba(255, 255, 255, 0.12) 0,
rgba(255, 255, 255, 0.12) 1px,
transparent 1px,
transparent 20px
);
opacity: 0.22;
}
.ai-rail > * {
position: relative;
z-index: 1;
}
.ai-rail-section {
min-width: 0;
}
.ai-rail-brand {
min-width: 0;
min-height: 74px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 16px 18px 10px;
}
.ai-brand-logo {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 1px solid rgba(45, 114, 217, 0.12);
border-radius: 13px;
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.84), rgba(239, 246, 255, 0.7)),
rgba(255, 255, 255, 0.72);
color: var(--ai-rail-accent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.86),
0 8px 18px rgba(45, 114, 217, 0.055);
}
.ai-brand-logo img {
width: 28px;
height: 28px;
object-fit: contain;
}
.ai-brand-logo svg {
width: 26px;
height: 26px;
fill: currentColor;
}
.ai-brand-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.ai-brand-copy strong {
overflow: hidden;
color: #162033;
font-size: 14px;
font-weight: 820;
line-height: 1.22;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-brand-copy small {
overflow: hidden;
color: var(--ai-rail-muted);
font-size: 12px;
font-weight: 560;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-rail-quick {
display: grid;
gap: 6px;
padding: 8px 18px 12px;
}
.ai-quick-btn,
.ai-nav-btn,
.ai-recent-item,
.ai-user-action {
width: 100%;
min-width: 0;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: inherit;
cursor: pointer;
text-align: left;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
color 180ms var(--ease),
transform 180ms var(--ease);
}
.ai-quick-btn {
min-height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 10px;
padding: 0 4px;
color: #111827;
font-size: 14px;
font-weight: 780;
background: transparent;
border-color: transparent;
box-shadow: none;
}
.ai-quick-btn i {
width: 28px;
display: inline-flex;
justify-content: center;
color: #536277;
font-size: 18px;
line-height: 1;
}
.ai-quick-btn.primary {
background: transparent;
border-color: transparent;
box-shadow: none;
}
.ai-quick-btn.active {
color: #173d78;
background: rgba(45, 114, 217, 0.055);
border-color: rgba(45, 114, 217, 0.12);
}
.ai-quick-btn.primary i {
color: var(--ai-rail-amber);
}
.ai-nav-btn:hover,
.ai-recent-item:hover,
.ai-user-action:hover {
background: rgba(255, 255, 255, 0.78);
border-color: rgba(148, 163, 184, 0.28);
box-shadow: 0 8px 18px rgba(31, 48, 68, 0.045);
transform: translateY(-1px);
}
.ai-quick-btn:hover {
color: #0f172a;
background: rgba(15, 23, 42, 0.035);
border-color: transparent;
box-shadow: none;
transform: translateX(2px);
}
.ai-quick-btn:hover i {
color: var(--ai-rail-accent);
}
.ai-quick-btn.primary:hover i {
color: var(--ai-rail-amber);
}
.ai-conversation-search {
min-width: 0;
min-height: 48px;
height: 48px;
display: grid;
grid-template-columns: 28px minmax(0, 1fr) 28px;
align-items: center;
gap: 4px;
padding: 0 6px 0 4px;
border: 1px solid rgba(45, 114, 217, 0.14);
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.8),
0 8px 18px rgba(45, 114, 217, 0.035);
}
.ai-conversation-search > i {
color: #64748b;
font-size: 17px;
line-height: 1;
}
.ai-conversation-search input {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #162033;
font-size: 13px;
font-weight: 650;
letter-spacing: 0;
}
.ai-conversation-search input::placeholder {
color: rgba(115, 128, 151, 0.78);
}
.ai-conversation-search button {
width: 28px;
height: 28px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.ai-conversation-search button:hover {
background: rgba(15, 23, 42, 0.055);
color: #173d78;
}
.ai-rail-divider {
height: 1px;
margin: 0 18px;
background: var(--ai-rail-line);
}
.ai-section-heading {
margin: 0;
padding: 0 10px 8px;
color: #7d8796;
font-size: 12px;
font-weight: 760;
letter-spacing: 0;
}
.ai-rail-nav {
display: grid;
padding: 18px 18px 20px;
}
.ai-nav-list {
position: relative;
display: grid;
gap: 6px;
padding: 2px 0;
}
.ai-nav-btn {
position: relative;
min-height: 48px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 12px;
padding: 7px 10px;
color: var(--ai-rail-ink-soft);
border-radius: 12px;
background: transparent;
box-shadow: none;
}
.ai-nav-btn::before {
content: "";
position: absolute;
left: 0;
width: 3px;
height: 22px;
border-radius: 999px;
background: transparent;
transition:
background 180ms var(--ease),
opacity 180ms var(--ease);
opacity: 0;
}
.ai-nav-btn.active {
border-color: rgba(45, 114, 217, 0.13);
background:
linear-gradient(90deg, rgba(45, 114, 217, 0.095), rgba(255, 255, 255, 0.74)),
var(--ai-rail-panel);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 8px 18px rgba(45, 114, 217, 0.045);
color: #173d78;
}
.ai-nav-btn.active::before {
background: linear-gradient(180deg, var(--ai-rail-accent), var(--ai-rail-green));
opacity: 1;
}
.ai-nav-btn:not(.active):hover::before {
background: rgba(45, 114, 217, 0.36);
opacity: 1;
}
.ai-nav-icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 10px;
background: transparent;
color: #64748b;
transition:
background 180ms var(--ease),
color 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.ai-nav-btn.active .ai-nav-icon {
background:
linear-gradient(145deg, rgba(45, 114, 217, 0.12), rgba(255, 255, 255, 0.72)),
rgba(255, 255, 255, 0.52);
color: var(--ai-rail-accent);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82);
}
.ai-nav-icon i {
font-size: 18px;
line-height: 1;
}
.ai-nav-copy {
min-width: 0;
}
.ai-nav-copy strong,
.ai-recent-title {
min-width: 0;
overflow: hidden;
color: currentColor;
font-size: 14px;
font-weight: 780;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-nav-btn.active .ai-nav-copy strong {
font-weight: 820;
}
.ai-recent-desc {
min-width: 0;
overflow: hidden;
color: var(--ai-rail-muted);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-rail-recents {
min-height: 0;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
overflow: hidden;
padding: 18px 12px 12px;
}
.ai-recents-list {
min-height: 0;
display: grid;
align-content: start;
gap: 5px;
overflow-y: auto;
padding: 1px 4px 1px 0;
-ms-overflow-style: none;
scrollbar-width: none;
}
.ai-recents-list::-webkit-scrollbar {
width: 0;
height: 0;
display: none;
}
.ai-recents-empty {
margin: 10px 8px 0 12px;
padding: 14px 12px;
border: 1px dashed rgba(148, 163, 184, 0.22);
border-radius: 12px;
color: rgba(115, 128, 151, 0.84);
font-size: 12px;
font-weight: 650;
line-height: 1.5;
}
.ai-recent-item {
min-height: 56px;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
gap: 8px;
padding: 8px 10px 8px 18px;
}
.ai-recent-item:focus-visible {
outline: 2px solid rgba(45, 114, 217, 0.32);
outline-offset: 2px;
}
.ai-recent-item::before {
content: "";
position: absolute;
left: 6px;
top: 16px;
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(115, 128, 151, 0.38);
}
.ai-recent-main {
min-width: 0;
display: grid;
gap: 4px;
}
.ai-recent-item:hover .ai-recent-title,
.ai-recent-item.active .ai-recent-title {
color: #173d78;
}
.ai-recent-item:hover::before,
.ai-recent-item.active::before {
background: var(--ai-rail-accent);
}
.ai-recent-item.active {
background: rgba(255, 255, 255, 0.72);
border-color: rgba(183, 107, 22, 0.1);
box-shadow:
0 10px 22px rgba(31, 48, 68, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
}
.ai-recent-time {
color: rgba(107, 114, 128, 0.82);
font-size: 11px;
font-weight: 680;
line-height: 1.35;
}
.ai-recent-title-input {
width: 100%;
min-width: 0;
height: 24px;
padding: 0 6px;
border: 1px solid rgba(45, 114, 217, 0.22);
border-radius: 7px;
outline: 0;
background: rgba(255, 255, 255, 0.86);
color: #173d78;
font-size: 14px;
font-weight: 780;
line-height: 1.2;
letter-spacing: 0;
box-shadow: 0 0 0 3px rgba(45, 114, 217, 0.06);
}
.ai-rail-user {
box-sizing: border-box;
min-width: 0;
height: 72px;
min-height: 72px;
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 44px;
align-items: center;
gap: 12px;
margin: 0;
padding: 12px 14px 12px 18px;
border-top: 1px solid rgba(203, 213, 225, 0.55);
border-radius: 0;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(247, 250, 252, 0.9)),
rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84);
}
.ai-user-avatar {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 50%;
background:
radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.22), transparent 32%),
linear-gradient(135deg, #1f4f96, #2f8d7b);
color: #fff;
font-size: 15px;
font-weight: 820;
box-shadow:
0 8px 16px rgba(45, 114, 217, 0.13),
inset 0 -1px 0 rgba(15, 23, 42, 0.08);
}
.ai-user-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.ai-user-copy strong {
overflow: hidden;
color: #182237;
font-size: 13px;
font-weight: 760;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-user-copy span {
overflow: hidden;
color: var(--ai-rail-muted);
font-size: 12px;
font-weight: 520;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-user-actions {
display: grid;
grid-template-columns: 44px;
justify-content: end;
}
.ai-user-action {
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
color: #708096;
border-radius: 8px;
background: transparent;
box-shadow: none;
}
.ai-user-action i {
font-size: 19px;
}
.ai-rail.rail-collapsed {
grid-template-rows: auto auto auto auto minmax(0, 1fr) auto;
}
.ai-rail.rail-collapsed .ai-rail-brand {
grid-template-columns: 1fr;
justify-items: center;
min-height: 70px;
padding: 14px 10px 8px;
}
.ai-rail.rail-collapsed .ai-rail-quick {
padding: 4px 10px 16px;
}
.ai-rail.rail-collapsed .ai-nav-list {
grid-template-columns: 1fr;
gap: 8px;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.ai-rail.rail-collapsed .ai-nav-list::before,
.ai-rail.rail-collapsed .ai-nav-btn::before {
display: none;
}
.ai-rail.rail-collapsed .ai-quick-btn,
.ai-rail.rail-collapsed .ai-nav-btn {
min-height: 44px;
grid-template-rows: auto;
justify-content: center;
grid-template-columns: 1fr;
align-content: center;
padding: 8px;
}
.ai-rail.rail-collapsed .ai-nav-btn.active {
grid-column: auto;
min-height: 44px;
grid-template-columns: 1fr;
}
.ai-rail.rail-collapsed .ai-quick-btn span,
.ai-rail.rail-collapsed .ai-conversation-search,
.ai-rail.rail-collapsed .ai-brand-copy,
.ai-rail.rail-collapsed .ai-section-heading,
.ai-rail.rail-collapsed .ai-nav-copy,
.ai-rail.rail-collapsed .ai-rail-recents,
.ai-rail.rail-collapsed .ai-user-copy,
.ai-rail.rail-collapsed .ai-user-actions {
display: none;
}
.ai-rail.rail-collapsed .ai-quick-btn i,
.ai-rail.rail-collapsed .ai-brand-logo,
.ai-rail.rail-collapsed .ai-nav-icon,
.ai-rail.rail-collapsed .ai-user-avatar {
margin: 0 auto;
}
.ai-rail.rail-collapsed .ai-rail-user {
grid-template-columns: 1fr;
padding: 12px 10px 14px;
}
@media (max-width: 760px) {
.ai-rail {
max-width: min(320px, 82vw);
}
.ai-rail-quick {
padding-top: 18px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,77 +1,40 @@
/* 1080p / 小高度屏:进一步压缩 AI 助手卡片高度 (排除手机端) */ /* 1080p / 小高度屏:让传统模式顶部趋势卡更紧凑 */
@media (max-height: 980px) and (min-width: 761px) { @media (max-height: 980px) and (min-width: 761px) {
.workbench { .workbench {
--hero-padding-top: 20px; --hero-title-size: 31px;
--hero-padding-bottom: 20px; --trend-card-min-height: 232px;
--hero-title-size: 28px; --capability-row-height: 106px;
--hero-copy-gap: 16px;
--hero-title-bottom-gap: 10px;
--composer-min-height: 108px;
--composer-textarea-height: 48px;
--composer-padding-block: 10px;
--quick-prompts-gap-top: 8px;
--capability-row-height: 96px;
gap: 9px; gap: 9px;
} }
.assistant-hero { .workbench-trend-hero {
--assistant-bg-position: right center; padding: 24px 20px 10px 20px;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.86;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
} }
.assistant-copy p { .workbench-trend-card {
font-size: 14px;
line-height: 1.5;
margin-bottom: 0;
} }
.assistant-composer textarea { .trend-chart-panel {
font-size: 15px; min-height: 128px;
}
.composer-icon-button,
.composer-send-button {
height: 32px;
}
.composer-send-button {
width: 50px;
} }
} }
/* 2K 宽屏但内容区仍偏高时,略收紧(避免 hero 独占过多纵向空间) */ /* 2K 宽屏但内容区仍偏高时,略收紧(避免 hero 独占过多纵向空间) */
@media (min-width: 1920px) and (max-height: 1100px) { @media (min-width: 1920px) and (max-height: 1100px) {
.workbench { .workbench {
--hero-padding-top: 22px; --hero-title-size: 32px;
--hero-padding-bottom: 22px; --trend-card-min-height: 236px;
--hero-title-size: 29px; --capability-row-height: 108px;
--composer-min-height: 114px;
--composer-textarea-height: 50px;
--capability-row-height: 100px;
} }
} }
@media (max-width: 1440px) { @media (max-width: 1440px) {
.workbench { .workbench {
grid-template-rows: auto var(--capability-row-height) minmax(0, 1fr);
gap: 10px; gap: 10px;
} }
.assistant-hero { .trend-summary-panel h1 {
--assistant-bg-position: right center; font-size: 32px;
--assistant-decor-width: clamp(760px, 66vw, 980px);
--assistant-decor-opacity: 0.9;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
}
.assistant-copy {
width: min(940px, 92%);
}
.assistant-copy h1 {
font-size: 33px;
} }
.capability-grid--privileged { .capability-grid--privileged {
@@ -83,7 +46,7 @@
} }
.capability-card { .capability-card {
padding: 17px 12px 17px 22px; padding: 18px 14px 18px 22px;
} }
.capability-copy { .capability-copy {
@@ -109,24 +72,15 @@
.workbench { .workbench {
height: auto; height: auto;
min-height: 100%; min-height: 100%;
grid-template-rows: auto auto auto;
gap: 12px; gap: 12px;
} }
.assistant-hero { .workbench-trend-card {
--assistant-bg-position: right center; grid-template-columns: 1fr;
--assistant-decor-width: clamp(620px, 74vw, 860px);
--assistant-decor-opacity: 0.62;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.82) 0%, rgba(255, 255, 255, 0.5) 58%, rgba(255, 255, 255, 0.06) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09) 100%);
backdrop-filter: blur(10px) saturate(1.12);
-webkit-backdrop-filter: blur(10px) saturate(1.12);
} }
.assistant-copy { .trend-summary-panel {
width: min(820px, 92%); align-content: start;
} }
.capability-grid--privileged { .capability-grid--privileged {
@@ -149,126 +103,91 @@
@media (min-width: 961px) and (max-width: 1440px), @media (min-width: 961px) and (max-width: 1440px),
(min-width: 961px) and (max-height: 820px) { (min-width: 961px) and (max-height: 820px) {
.workbench { .workbench {
--hero-padding-top: 14px; --hero-title-size: 30px;
--hero-padding-bottom: 14px; --trend-card-min-height: 232px;
--hero-title-size: 24px; --capability-row-height: 102px;
--hero-copy-gap: 14px;
--hero-title-bottom-gap: 8px;
--composer-min-height: 92px;
--composer-textarea-height: 38px;
--composer-padding-block: 8px;
--quick-prompts-gap-top: 5px;
--capability-row-height: 82px;
gap: 8px; gap: 8px;
} }
.assistant-hero { .workbench-trend-hero {
--assistant-decor-width: clamp(680px, 60vw, 880px); padding: 24px 18px 10px 18px;
--assistant-decor-opacity: 0.72;
padding: var(--hero-padding-top) 16px var(--hero-padding-bottom) 34px;
} }
.assistant-copy { .workbench-trend-card {
width: min(900px, 92%); min-height: 0;
} }
.assistant-copy h1 { .trend-summary-panel {
margin-bottom: var(--hero-title-bottom-gap); gap: 7px;
}
.trend-summary-panel h1 {
margin-bottom: 28px;
font-size: var(--hero-title-size); font-size: var(--hero-title-size);
line-height: 1.14; line-height: 1.14;
} }
.assistant-composer { .trend-total {
min-height: var(--composer-min-height); font-size: 42px;
gap: 4px;
padding: var(--composer-padding-block) 14px 8px;
} }
.assistant-composer textarea { .trend-summary-panel small {
height: var(--composer-textarea-height); display: none;
min-height: var(--composer-textarea-height); }
max-height: var(--composer-textarea-height);
.trend-chart-panel {
min-height: 0;
}
.trend-chart-head strong {
font-size: 14px; font-size: 14px;
line-height: 1.42;
} }
.composer-toolbar { .trend-chart-source {
gap: 8px;
}
.composer-icon-button,
.composer-send-button {
height: 30px;
}
.composer-icon-button {
width: 30px;
font-size: 17px;
}
.composer-send-button {
width: 46px;
font-size: 16px;
}
.composer-count {
font-size: 12px;
}
.quick-prompts {
gap: 8px;
margin-top: var(--quick-prompts-gap-top);
font-size: 12.5px; font-size: 12.5px;
} }
.quick-prompts button {
min-height: 24px;
padding: 0 10px;
font-size: 12px;
}
.capability-grid { .capability-grid {
gap: 10px; gap: 10px;
} }
.capability-card { .capability-card {
grid-template-columns: 34px minmax(0, 1fr) 14px; grid-template-columns: 40px minmax(0, 1fr) 16px;
gap: 10px; gap: 12px;
padding: 12px 12px 12px 16px; padding: 15px 14px 15px 18px;
} }
.capability-icon { .capability-icon {
--workbench-list-icon-size: 34px; --workbench-list-icon-size: 40px;
--workbench-list-icon-art-size: 20px; --workbench-list-icon-art-size: 24px;
width: 34px; width: 40px;
height: 34px; height: 40px;
} }
.capability-copy { .capability-copy {
gap: 2px; gap: 3px;
} }
.capability-copy strong { .capability-copy strong {
font-size: 13px; font-size: 14px;
line-height: 1.2; line-height: 1.2;
} }
.capability-copy small { .capability-copy small {
font-size: 11px; font-size: 12px;
line-height: 1.22; line-height: 1.22;
} }
.capability-arrow { .capability-arrow {
width: 14px; width: 16px;
min-width: 14px; min-width: 16px;
font-size: 16px; font-size: 17px;
} }
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.workbench { .workbench {
height: auto; height: auto;
grid-template-rows: none;
gap: 14px; gap: 14px;
overflow: visible; overflow: visible;
--workbench-glass-base: --workbench-glass-base:
@@ -279,47 +198,36 @@
--workbench-glass-blur: blur(14px) saturate(1.2); --workbench-glass-blur: blur(14px) saturate(1.2);
} }
.assistant-hero { .workbench-trend-hero {
min-height: auto; padding: 16px;
--assistant-bg-position: right center;
--assistant-decor-width: min(620px, 118vw);
--assistant-decor-opacity: 0.36;
--assistant-readability-mask:
linear-gradient(180deg, rgba(255, 255, 255, 0.86) 0%, rgba(255, 255, 255, 0.76) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.88) 0%, rgba(255, 255, 255, 0.52) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04) 100%);
padding: 24px 18px 24px;
backdrop-filter: blur(9px) saturate(1.1);
-webkit-backdrop-filter: blur(9px) saturate(1.1);
} }
.assistant-copy { .workbench-trend-card {
width: 100%; grid-template-columns: 1fr;
gap: 18px;
} }
.assistant-copy h1 { .trend-summary-panel h1 {
max-width: 320px; max-width: 320px;
font-size: 28px; font-size: 28px;
} }
.assistant-composer { .trend-summary-panel {
padding: 14px; transform: none;
} }
.composer-toolbar { .trend-total {
font-size: 34px;
}
.trend-chart-head {
align-items: flex-start;
flex-direction: column;
gap: 8px; gap: 8px;
flex-wrap: wrap;
} }
.composer-count { .trend-chart-panel {
order: 4; min-height: 148px;
width: 100%;
margin-left: 0;
}
.composer-send-button {
margin-left: auto;
} }
.capability-grid, .capability-grid,
@@ -356,88 +264,33 @@
} }
} }
/* 针对低高度视口(如低于 840px包含大部分笔记本 768px 高度),解除 height: 100% 限制,让内容流式高度,防止纵向元素被过度压扁 (排除手机端) */ /* 针对低高度视口,解除 height: 100% 限制,防止纵向元素被过度压扁 */
@media (max-height: 840px) and (min-width: 761px) { @media (max-height: 840px) and (min-width: 761px) {
.workbench { .workbench {
height: auto; height: auto;
min-height: 100%; min-height: 100%;
grid-template-rows: auto var(--capability-row-height) auto;
} }
} }
/* 手机端/窄屏自适应优化 (560px 以下) */
@media (max-width: 560px) { @media (max-width: 560px) {
/* 常用提问横向滑动展示,避免折行过多撑爆高度 */ .workbench-trend-hero {
.quick-prompts { padding: 14px;
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
gap: 8px;
padding-bottom: 2px;
}
.quick-prompts span {
display: none; /* 隐藏“常用提问:”前缀,以最大化利用横向空间 */
}
.quick-prompts button {
flex-shrink: 0;
padding: 0 10px;
min-height: 26px;
font-size: 12px;
}
/* 隐藏常用提问横滑条的原生滚动条,保持精致视觉 */
.quick-prompts::-webkit-scrollbar {
display: none;
}
.assistant-hero {
--assistant-bg-position: 72% center;
padding: 20px 14px 20px;
} }
} }
/* 手机端/窄屏自适应优化 (480px 以下) */
@media (max-width: 480px) { @media (max-width: 480px) {
/* 输入框更小巧 */ .trend-summary-panel h1 {
.assistant-composer { font-size: 24px;
padding: 10px 12px;
min-height: 94px;
} }
.assistant-composer textarea { .trend-total {
font-size: 14px; font-size: 30px;
height: 42px;
min-height: 42px;
} }
.composer-toolbar { .trend-chart-panel {
gap: 6px; min-height: 132px;
} }
.composer-icon-button,
.composer-send-button {
height: 30px;
font-size: 13px;
}
.composer-icon-button {
width: 30px;
}
.composer-send-button {
width: 46px;
}
/* 限制上传的附件文件芯片的最大宽度,防止溢出 */
.assistant-file-chip {
max-width: 110px;
}
/* AI 财务助手卡片尺寸更精致 */
.capability-card { .capability-card {
padding: 12px 10px 12px 14px; padding: 12px 10px 12px 14px;
gap: 8px; gap: 8px;
@@ -463,7 +316,6 @@
font-size: 11px; font-size: 11px;
} }
/* 重点优化费用进度行的网格区域Grid Area双行重构 */
.progress-row { .progress-row {
display: grid; display: grid;
grid-template-columns: minmax(70px, auto) 1fr minmax(74px, auto); grid-template-columns: minmax(70px, auto) 1fr minmax(74px, auto);
@@ -506,7 +358,7 @@
.progress-result { .progress-result {
grid-area: result; grid-area: result;
width: 100%; width: 100%;
justify-items: end; /* 金额和状态右对齐 */ justify-items: end;
gap: 2px; gap: 2px;
} }
@@ -515,9 +367,9 @@
} }
.progress-status { .progress-status {
font-size: 11px;
min-height: 18px; min-height: 18px;
padding: 0 5px; padding: 0 5px;
font-size: 11px;
} }
.progress-steps { .progress-steps {
@@ -526,7 +378,6 @@
margin-top: 4px; margin-top: 4px;
} }
/* 缩小步骤图图标与连线 */
.progress-step i { .progress-step i {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -541,7 +392,6 @@
top: 7px; top: 7px;
} }
/* 侧边分析栏优化 */
.side-panel { .side-panel {
padding: 8px 10px; padding: 8px 10px;
gap: 4px; gap: 4px;

View File

@@ -1,14 +1,11 @@
.workbench { .workbench {
--hero-padding-top: 26px; --hero-padding-top: 26px;
--hero-padding-bottom: 26px; --hero-padding-bottom: 26px;
--hero-title-size: 30px; --hero-title-size: 34px;
--hero-copy-gap: 6px; --hero-copy-gap: 6px;
--hero-title-bottom-gap: 18px; --hero-title-bottom-gap: 18px;
--composer-min-height: 122px; --trend-card-min-height: 260px;
--composer-textarea-height: 54px; --capability-row-height: 116px;
--composer-padding-block: 12px;
--quick-prompts-gap-top: 10px;
--capability-row-height: 104px;
--workbench-ink: var(--ink, #1e293b); --workbench-ink: var(--ink, #1e293b);
--workbench-text: var(--text, #334155); --workbench-text: var(--text, #334155);
--workbench-muted: var(--muted, #64748b); --workbench-muted: var(--muted, #64748b);
@@ -30,8 +27,8 @@
margin: 0 auto; margin: 0 auto;
height: 100%; height: 100%;
min-width: 0; min-width: 0;
display: grid; display: flex;
grid-template-rows: auto var(--capability-row-height) minmax(0, 1fr); flex-direction: column;
gap: 10px; gap: 10px;
overflow: visible; overflow: visible;
color: var(--workbench-ink); color: var(--workbench-ink);
@@ -41,7 +38,7 @@
background-color: var(--workbench-surface-soft); background-color: var(--workbench-surface-soft);
} }
.workbench :where(button, textarea) { .workbench :where(button) {
font: inherit; font: inherit;
} }
@@ -58,338 +55,139 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; } .workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero { .workbench-trend-hero {
--assistant-bg-position: right center;
--assistant-decor-width: clamp(860px, 62vw, 1180px);
--assistant-decor-opacity: 0.92;
--assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.74) 0%, rgba(255, 255, 255, 0.34) 46%, rgba(255, 255, 255, 0) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.025) 54%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075));
position: relative; position: relative;
z-index: 2; z-index: 2;
flex: 0 0 var(--trend-card-min-height);
height: var(--trend-card-min-height);
min-height: 0; min-height: 0;
display: flex; padding: 24px 28px;
flex-direction: column; overflow: hidden;
justify-content: center; border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
overflow: visible; border-radius: 12px;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px;
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.54)), linear-gradient(120deg, rgba(255, 255, 255, 0.85), rgba(249, 252, 255, 0.7)),
var(--assistant-theme-tint); linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 68%);
background-color: rgba(247, 252, 255, 0.72); backdrop-filter: blur(12px) saturate(140%);
backdrop-filter: blur(14px) saturate(1.18); -webkit-backdrop-filter: blur(12px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(1.18);
box-shadow: box-shadow:
0 12px 28px rgba(15, 23, 42, 0.045), 0 16px 32px rgba(15, 23, 42, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.86), inset 0 1px 0 rgba(255, 255, 255, 0.94);
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07); animation: workbenchItemIn 520ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
isolation: isolate;
animation: workbenchItemIn 560ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
animation-delay: 0ms; animation-delay: 0ms;
} }
.assistant-hero::after { .workbench-trend-card {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 82%;
min-width: 760px;
background: url("../../images/workbench-hero-right-bg.png") var(--assistant-bg-position) / var(--assistant-decor-width) auto no-repeat;
opacity: var(--assistant-decor-opacity);
pointer-events: none;
z-index: 0;
}
.assistant-hero::before {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background:
var(--assistant-readability-mask),
linear-gradient(120deg, rgba(255, 255, 255, 0.36), transparent 22%, transparent 72%, rgba(255, 255, 255, 0.18)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.05), transparent 58%);
pointer-events: none;
z-index: 1;
}
.assistant-copy {
position: relative; position: relative;
z-index: 3; z-index: 1;
width: min(980px, 94%);
display: grid; display: grid;
gap: var(--hero-copy-gap); grid-template-columns: minmax(200px, 0.28fr) minmax(0, 1fr);
align-items: stretch;
gap: 16px;
width: 100%;
height: 100%;
min-height: 0;
padding: 0;
overflow: hidden;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
} }
.assistant-copy h1 { .trend-summary-panel {
margin: 0 0 var(--hero-title-bottom-gap); display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.trend-summary-panel h1 {
margin: 0 0 44px 0;
color: var(--workbench-ink); color: var(--workbench-ink);
font-size: var(--hero-title-size); font-size: var(--hero-title-size);
line-height: 1.18; line-height: 1.16;
font-weight: 880;
}
.trend-summary-panel p {
margin: 0 0 4px;
color: var(--workbench-muted);
font-size: 14px;
font-weight: 650;
}
.trend-total {
background: linear-gradient(110deg, var(--workbench-ink) 20%, var(--workbench-primary-active) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--workbench-ink);
font-size: clamp(38px, 3.3vw, 54px);
line-height: 1;
font-weight: 860;
letter-spacing: -0.5px;
filter: drop-shadow(0 2px 8px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12));
}
.trend-change {
display: inline-flex;
align-items: center;
gap: 5px;
min-height: 26px;
color: var(--workbench-primary-active);
font-size: 13px;
font-weight: 800;
}
.trend-change.is-down {
color: #b45309;
}
.trend-summary-panel small {
color: color-mix(in srgb, var(--workbench-muted) 80%, #ffffff);
font-size: 12px;
font-weight: 650;
}
.trend-chart-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
gap: 8px;
min-width: 0;
min-height: 0;
}
.trend-chart-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
color: var(--workbench-ink);
}
.trend-chart-head strong {
font-size: 15px;
font-weight: 850; font-weight: 850;
} }
.assistant-copy h1 span:not(.typing-cursor) { .trend-chart-source {
color: var(--workbench-primary-active);
display: inline-block;
animation: workbenchItemIn 400ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.typing-cursor {
display: inline-block;
color: var(--workbench-primary-active);
font-weight: 400;
margin-left: 2px;
animation: cursorBlink 0.9s step-end infinite;
}
@keyframes cursorBlink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.assistant-copy p {
max-width: 680px;
margin: 0 0 2px;
color: var(--workbench-muted); color: var(--workbench-muted);
font-size: 15px;
line-height: 1.6;
font-weight: 600;
}
.assistant-copy > * {
animation: workbenchItemIn 480ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)) both;
}
.assistant-copy > h1 { animation-delay: 80ms; }
.assistant-copy > p { animation-delay: 160ms; }
.assistant-copy > .assistant-composer { animation-delay: 240ms; }
.assistant-copy > .assistant-file-strip { animation-delay: 320ms; }
.assistant-copy > .quick-prompts { animation-delay: 320ms; }
.assistant-file-input { display: none; }
.assistant-composer {
position: relative;
z-index: 20;
display: grid;
gap: 6px;
max-width: 920px;
min-height: var(--composer-min-height);
padding: var(--composer-padding-block) 18px 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
border-radius: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.74)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.045), rgba(255, 255, 255, 0.18));
box-shadow:
0 10px 24px rgba(15, 23, 42, 0.045),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06);
backdrop-filter: blur(10px) saturate(1.14);
-webkit-backdrop-filter: blur(10px) saturate(1.14);
transition:
border-color 180ms var(--ease),
background 180ms var(--ease),
box-shadow 180ms var(--ease);
}
.assistant-composer::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background:
linear-gradient(110deg, rgba(255, 255, 255, 0.32), transparent 32%),
linear-gradient(180deg, rgba(255, 255, 255, 0.18), transparent 42%);
pointer-events: none;
}
.assistant-composer > * {
position: relative;
z-index: 1;
}
.assistant-composer:focus-within {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.58);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.78)),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06), rgba(255, 255, 255, 0.22));
box-shadow:
0 0 0 3px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.11),
0 14px 30px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.94),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
}
.assistant-composer textarea {
width: 100%;
min-width: 0;
height: var(--composer-textarea-height);
min-height: var(--composer-textarea-height);
max-height: var(--composer-textarea-height);
resize: none;
border: 0;
padding: 0;
background: transparent;
color: var(--workbench-ink);
font-size: 16px;
line-height: 1.55;
overflow: hidden;
}
.assistant-composer textarea::placeholder {
color: color-mix(in srgb, var(--workbench-muted) 70%, #ffffff);
}
.assistant-composer textarea:focus { outline: none; }
.assistant-composer textarea[readonly] {
color: color-mix(in srgb, var(--workbench-ink) 72%, #ffffff);
cursor: progress;
}
.assistant-intent-status {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
min-height: 28px;
gap: 8px;
padding: 0 10px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
border-radius: 4px;
background: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
color: var(--workbench-primary-active);
font-size: 12px;
font-weight: 750;
line-height: 1.35;
}
.assistant-intent-status i {
font-size: 15px;
}
.composer-toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.composer-icon-button,
.composer-send-button {
display: inline-flex;
align-items: center;
justify-content: center;
height: 36px;
border-radius: 4px;
white-space: nowrap;
}
.composer-icon-button {
width: 36px;
border: 1px solid var(--workbench-line);
background: var(--workbench-surface);
color: var(--workbench-text);
font-size: 19px;
}
.composer-count {
margin-left: auto;
color: color-mix(in srgb, var(--workbench-muted) 75%, #ffffff);
font-size: 13px; font-size: 13px;
font-weight: 650;
}
.composer-send-button {
width: 56px;
background: var(--workbench-primary-active);
color: #fff;
font-size: 18px;
box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
}
.assistant-file-strip {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.assistant-file-note,
.assistant-file-chip {
display: inline-flex;
align-items: center;
max-width: 220px;
min-height: 28px;
padding: 0 10px;
border-radius: 4px;
font-size: 12px;
font-weight: 750;
}
.assistant-file-note {
background: var(--workbench-primary-soft);
color: var(--workbench-primary-active);
}
.assistant-file-chip {
overflow: hidden;
border: 1px solid var(--workbench-line);
background: var(--workbench-surface);
color: var(--workbench-text);
text-overflow: ellipsis;
white-space: nowrap;
}
.assistant-file-clear {
color: var(--workbench-muted);
font-size: 12px;
font-weight: 750;
}
.quick-prompts {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: var(--quick-prompts-gap-top);
margin-bottom: 0;
color: var(--workbench-text);
font-size: 14px;
font-weight: 700; font-weight: 700;
white-space: nowrap;
} }
.quick-prompts button { .workbench-trend-chart {
min-height: 28px; min-height: 0;
padding: 0 14px;
border: 1px solid var(--workbench-line);
border-radius: 4px;
background: rgba(255, 255, 255, 0.86);
color: var(--workbench-text);
font-size: 13px;
font-weight: 650;
}
.quick-prompts .quick-more {
display: inline-flex;
align-items: center;
gap: 4px;
border-color: transparent;
background: transparent;
color: var(--workbench-primary-active);
font-weight: 800;
} }
.capability-grid { .capability-grid {
position: relative; position: relative;
z-index: 1; z-index: 1;
flex: 0 0 var(--capability-row-height);
display: grid; display: grid;
gap: 16px; gap: 16px;
min-height: 0; min-height: 0;
@@ -407,11 +205,11 @@
position: relative; position: relative;
isolation: isolate; isolation: isolate;
display: grid; display: grid;
grid-template-columns: 40px minmax(0, 1fr) 18px; grid-template-columns: 44px minmax(0, 1fr) 18px;
align-items: center; align-items: center;
gap: 14px; gap: 16px;
min-height: 0; min-height: 0;
padding: 16px 18px 16px 22px; padding: 18px 20px 18px 24px;
overflow: visible; overflow: visible;
text-align: left; text-align: left;
border: 1px solid rgba(255, 255, 255, 0.9); border: 1px solid rgba(255, 255, 255, 0.9);
@@ -450,10 +248,10 @@
} }
.capability-icon { .capability-icon {
--workbench-list-icon-size: 40px; --workbench-list-icon-size: 44px;
--workbench-list-icon-art-size: 24px; --workbench-list-icon-art-size: 26px;
width: 40px; width: 44px;
height: 40px; height: 44px;
color: var(--capability-color); color: var(--capability-color);
} }
@@ -467,7 +265,7 @@
.capability-copy strong { .capability-copy strong {
color: var(--workbench-ink); color: var(--workbench-ink);
font-size: 14px; font-size: 15px;
font-weight: 850; font-weight: 850;
line-height: 1.25; line-height: 1.25;
overflow: hidden; overflow: hidden;
@@ -479,7 +277,7 @@
.capability-copy small { .capability-copy small {
overflow: hidden; overflow: hidden;
color: var(--workbench-muted); color: var(--workbench-muted);
font-size: 12px; font-size: 12.5px;
line-height: 1.35; line-height: 1.35;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -529,6 +327,7 @@
} }
.workbench-content-grid { .workbench-content-grid {
flex: 1 1 auto;
display: grid; display: grid;
grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr); grid-template-columns: minmax(640px, 1.8fr) minmax(260px, 0.55fr);
gap: 14px; gap: 14px;
@@ -1034,9 +833,7 @@
} }
.capability-card:hover, .capability-card:hover,
.progress-row:hover, .progress-row:hover {
.quick-prompts button:hover,
.composer-icon-button:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24); border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
color: var(--workbench-primary-active); color: var(--workbench-primary-active);
} }
@@ -1053,9 +850,10 @@
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.assistant-hero, .workbench-trend-hero,
.capability-card, .capability-card,
.workbench-card { .workbench-card {
animation: none !important; animation: none !important;
} }
} }

View File

@@ -276,51 +276,43 @@
} }
.rail-user { .rail-user {
position: relative; box-sizing: border-box;
min-width: 0; min-width: 0;
min-height: 78px; height: 72px;
margin: 0; min-height: 72px;
padding: 16px 20px 18px; display: grid;
border-top: 1px solid #edf2f7; grid-template-columns: 42px minmax(0, 1fr) 44px;
transition: padding var(--rail-motion-duration) var(--rail-motion-ease);
}
.user-summary {
position: relative;
min-width: 0;
min-height: 42px;
display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 12px;
padding: 4px; margin: 0;
color: #64748b; padding: 12px 14px 12px 18px;
border-radius: 4px; border-top: 1px solid rgba(203, 213, 225, 0.55);
cursor: pointer; background:
linear-gradient(180deg, rgba(255, 255, 255, 0.76), rgba(247, 250, 252, 0.9)),
rgba(255, 255, 255, 0.72);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84);
transition: transition:
gap var(--rail-motion-duration) var(--rail-motion-ease), grid-template-columns var(--rail-motion-duration) var(--rail-motion-ease),
padding var(--rail-motion-duration) var(--rail-motion-ease), padding var(--rail-motion-duration) var(--rail-motion-ease);
background 180ms var(--ease);
}
.rail-user:hover .user-summary {
background: rgba(255, 255, 255, 0.72);
} }
.user-avatar { .user-avatar {
flex: 0 0 36px; width: 42px;
width: 36px; height: 42px;
height: 36px;
display: grid; display: grid;
place-items: center; place-items: center;
border: 2px solid #fff; border: 2px solid rgba(255, 255, 255, 0.92);
border-radius: 999px; border-radius: 50%;
background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); background:
box-shadow: 0 6px 14px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18); radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.22), transparent 32%),
linear-gradient(135deg, #1f4f96, #2f8d7b);
box-shadow:
0 8px 16px rgba(45, 114, 217, 0.13),
inset 0 -1px 0 rgba(15, 23, 42, 0.08);
color: #fff; color: #fff;
font-size: 14px; font-size: 15px;
font-weight: 800; font-weight: 820;
transition: transition:
flex-basis var(--rail-motion-duration) var(--rail-motion-ease),
width var(--rail-motion-duration) var(--rail-motion-ease), width var(--rail-motion-duration) var(--rail-motion-ease),
height var(--rail-motion-duration) var(--rail-motion-ease); height var(--rail-motion-duration) var(--rail-motion-ease);
} }
@@ -328,9 +320,7 @@
.user-copy { .user-copy {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
max-width: 116px; display: grid;
display: flex;
flex-direction: column;
gap: 2px; gap: 2px;
opacity: 1; opacity: 1;
transition: transition:
@@ -341,9 +331,10 @@
} }
.user-copy strong { .user-copy strong {
color: #334155; color: #182237;
font-size: 14px; font-size: 13px;
font-weight: 750; font-weight: 760;
line-height: 1.25;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -352,57 +343,47 @@
.user-copy span { .user-copy span {
color: #64748b; color: #64748b;
font-size: 12px; font-size: 12px;
font-weight: 520;
line-height: 1.25;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.user-summary .mdi { .user-actions {
flex: 0 0 18px; display: grid;
font-size: 18px; grid-template-columns: 44px;
transition: justify-content: end;
max-width var(--rail-motion-duration) var(--rail-motion-ease),
opacity var(--rail-fade-duration) var(--rail-motion-ease) var(--rail-label-delay);
will-change: max-width, opacity;
} }
.user-menu { .user-action {
position: absolute;
right: 20px;
bottom: calc(100% - 6px);
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.1);
opacity: 0;
transform: translateY(8px);
pointer-events: none;
transition: all 180ms var(--ease);
z-index: 4;
}
.rail-user:hover .user-menu {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.user-menu-item {
width: 100%; width: 100%;
height: 38px; min-width: 0;
display: flex; height: 44px;
display: grid;
align-items: center; align-items: center;
gap: 8px; justify-content: center;
padding: 0 12px; padding: 0;
border: 0; border: 1px solid transparent;
border-radius: 4px; border-radius: 10px;
background: transparent; background: transparent;
color: #64748b;
cursor: pointer;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease),
color 180ms var(--ease);
}
.user-action:hover {
border-color: rgba(148, 163, 184, 0.28);
background: rgba(255, 255, 255, 0.78);
color: #dc2626; color: #dc2626;
font-size: 13px; }
font-weight: 700;
transition: all 180ms var(--ease); .user-action i {
font-size: 20px;
line-height: 1;
} }
/* ========================================= */ /* ========================================= */
@@ -489,33 +470,14 @@
} }
.rail-collapsed .rail-user { .rail-collapsed .rail-user {
position: relative; grid-template-columns: 42px;
z-index: 6;
padding: 14px 8px;
overflow: visible;
}
.rail-collapsed .user-summary {
justify-content: center; justify-content: center;
padding: 4px; padding: 14px 8px;
gap: 0;
} }
.rail-user-menu-floating { .rail-collapsed .user-copy,
position: fixed; .rail-collapsed .user-actions {
z-index: 12000; display: none;
min-width: 132px;
padding: 8px;
border: 1px solid rgba(226, 232, 240, 0.96);
border-radius: 4px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 16px 32px rgba(15, 23, 42, 0.14);
transform: translateY(-50%);
animation: railUserMenuIn 180ms var(--rail-motion-ease) both;
}
.rail-user-menu-floating .user-menu-item {
width: 100%;
} }
:global(.rail-tooltip-popper) { :global(.rail-tooltip-popper) {

View File

@@ -1,7 +1,7 @@
.employee-risk-profile-card { .employee-risk-profile-card {
display: grid; display: grid;
gap: 10px; gap: 12px;
padding: 12px 14px; padding: 14px 16px;
} }
.employee-risk-head { .employee-risk-head {
@@ -74,28 +74,28 @@
.employee-risk-body { .employee-risk-body {
display: grid; display: grid;
gap: 10px; gap: 12px;
} }
.employee-risk-decision-panel { .employee-risk-decision-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 32%); grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr);
align-items: stretch; align-items: stretch;
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
border: 1px solid #e2e8f0; border: 1px solid #e5e7eb;
border-radius: 4px; border-radius: 2px;
background: #f8fafc; background: #ffffff;
} }
.employee-risk-decision-panel.medium { .employee-risk-decision-panel.medium {
border-color: #fed7aa; border-color: #f3e8d9;
background: #fff7ed; background: #fffcf7;
} }
.employee-risk-decision-panel.high { .employee-risk-decision-panel.high {
border-color: #fecaca; border-color: #fecaca;
background: #fef2f2; background: #fff7f7;
} }
.employee-risk-decision-main { .employee-risk-decision-main {
@@ -110,13 +110,15 @@
font-size: 10px; font-size: 10px;
font-weight: 850; font-weight: 850;
line-height: 1.5; line-height: 1.5;
letter-spacing: .03em;
text-transform: uppercase;
} }
.employee-risk-decision-main strong { .employee-risk-decision-main strong {
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 15px;
font-weight: 850; font-weight: 900;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -143,8 +145,8 @@
justify-content: center; justify-content: center;
gap: 5px; gap: 5px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #e2e8f0; border: 1px solid #e5e7eb;
border-radius: 4px; border-radius: 2px;
background: #fff; background: #fff;
} }
@@ -152,8 +154,8 @@
min-width: 0; min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 900;
line-height: 1.5; line-height: 1.45;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
@@ -165,12 +167,75 @@
color: #b91c1c; color: #b91c1c;
} }
.employee-risk-decision-action p {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.5;
}
.employee-risk-review-summary {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
}
.employee-risk-review-item {
min-width: 0;
flex: 1 1 180px;
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid #e5e7eb;
border-radius: 2px;
background: #fff;
}
.employee-risk-review-item.medium {
border-color: #f3e8d9;
background: #fffcf7;
}
.employee-risk-review-item.high {
border-color: #fecaca;
background: #fff7f7;
}
.employee-risk-review-item dt,
.employee-risk-review-item dd {
margin: 0;
}
.employee-risk-review-item dt {
color: #64748b;
font-size: 10px;
font-weight: 850;
line-height: 1.4;
}
.employee-risk-review-item dd {
color: #334155;
font-size: 12px;
font-weight: 700;
line-height: 1.5;
overflow-wrap: anywhere;
}
.employee-risk-review-item.high dd {
color: #991b1b;
}
.employee-risk-review-item.medium dd {
color: #9a3412;
}
.employee-risk-profile-section { .employee-risk-profile-section {
display: grid; display: grid;
gap: 8px; gap: 8px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #e2e8f0; border: 1px solid #e5e7eb;
border-radius: 4px; border-radius: 2px;
background: #fff; background: #fff;
} }
@@ -205,16 +270,16 @@
.employee-risk-evidence-row { .employee-risk-evidence-row {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 5px; gap: 0;
padding: 8px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 2px;
background: #f8fafc; background: #f8fafc;
overflow: hidden;
} }
.employee-risk-evidence-row.medium { .employee-risk-evidence-row.medium {
border-color: #fed7aa; border-color: #f3e8d9;
background: #fffbf5; background: #fffcf7;
} }
.employee-risk-evidence-row.high { .employee-risk-evidence-row.high {
@@ -222,12 +287,26 @@
background: #fff7f7; background: #fff7f7;
} }
.employee-risk-evidence-row[open] {
background: #fff;
}
.employee-risk-evidence-row summary {
list-style: none;
cursor: pointer;
}
.employee-risk-evidence-row summary::-webkit-details-marker {
display: none;
}
.employee-risk-evidence-title { .employee-risk-evidence-title {
min-height: 20px; min-height: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 8px 10px;
color: #0f172a; color: #0f172a;
font-size: 11px; font-size: 11px;
font-weight: 850; font-weight: 850;
@@ -262,13 +341,26 @@
color: #b91c1c; color: #b91c1c;
} }
.employee-risk-evidence-title::after {
content: '展开';
flex: 0 0 auto;
color: #94a3b8;
font-size: 10px;
font-weight: 800;
}
.employee-risk-evidence-row[open] .employee-risk-evidence-title::after {
content: '收起';
}
.employee-risk-evidence-row ul { .employee-risk-evidence-row ul {
display: grid; display: grid;
gap: 3px; gap: 3px;
margin: 0; margin: 0;
padding: 0; padding: 0 10px 10px 10px;
list-style: none; list-style: none;
align-content: start; align-content: start;
border-top: 1px solid #e2e8f0;
} }
.employee-risk-evidence-row li { .employee-risk-evidence-row li {
@@ -291,6 +383,10 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.employee-risk-review-item {
flex-basis: 100%;
}
.employee-risk-title-wrap, .employee-risk-title-wrap,
.employee-risk-section-head { .employee-risk-section-head {
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -380,6 +380,14 @@
min-width: 0; min-width: 0;
} }
.topbar-utility-actions {
display: inline-flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
}
.topbar-icon-btn { .topbar-icon-btn {
position: relative; position: relative;
width: 34px; width: 34px;
@@ -1113,6 +1121,68 @@
font-size: 16px; font-size: 16px;
} }
.topbar-ai-mode-toggle {
flex: 0 0 38px;
width: 38px;
height: 38px;
display: inline-grid;
place-items: center;
padding: 0;
border: 2px solid transparent;
border-radius: 50%;
background:
linear-gradient(#ffffff, #ffffff) padding-box,
conic-gradient(from 210deg, #15b8c8, #4f6fef, #b65cff, #ec4899, #f59e0b, #15b8c8) border-box;
box-shadow:
0 8px 18px rgba(79, 111, 239, 0.16),
0 0 0 1px rgba(255, 255, 255, 0.78) inset;
transition:
transform 180ms var(--ease),
box-shadow 180ms var(--ease),
filter 180ms var(--ease);
}
.topbar-ai-mode-toggle__glyph {
display: inline-block;
background: linear-gradient(135deg, #0ea5b7 4%, #4f6fef 34%, #a855f7 58%, #ec4899 76%, #f59e0b 96%);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
font-size: 15px;
font-weight: 950;
line-height: 1;
letter-spacing: 0;
}
.topbar-ai-mode-toggle:hover,
.topbar-ai-mode-toggle:focus-visible {
transform: translateY(-1px);
box-shadow:
0 12px 24px rgba(79, 111, 239, 0.2),
0 0 0 4px rgba(236, 72, 153, 0.08),
0 0 0 1px rgba(255, 255, 255, 0.86) inset;
}
.topbar-ai-mode-toggle:focus-visible {
outline: 2px solid color-mix(in srgb, var(--theme-primary-active) 72%, #ffffff);
outline-offset: 3px;
}
.topbar-ai-mode-toggle.active {
filter: saturate(1.1);
box-shadow:
0 12px 24px rgba(79, 111, 239, 0.22),
0 0 0 4px rgba(14, 165, 183, 0.09),
0 0 0 1px rgba(255, 255, 255, 0.88) inset;
}
.topbar-ai-mode-toggle:not(.active) {
filter: saturate(0.82);
box-shadow:
0 6px 14px rgba(15, 23, 42, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.82) inset;
}
.kpi-chip { .kpi-chip {
display: grid; display: grid;
grid-template-columns: auto auto; grid-template-columns: auto auto;
@@ -1259,6 +1329,10 @@
gap: 12px; gap: 12px;
} }
.topbar-utility-actions {
gap: 8px;
}
.topbar-icon-btn { .topbar-icon-btn {
width: 30px; width: 30px;
height: 30px; height: 30px;
@@ -1271,6 +1345,16 @@
font-size: 12px; font-size: 12px;
} }
.topbar-ai-mode-toggle {
flex: 0 0 34px;
width: 34px;
height: 34px;
}
.topbar-ai-mode-toggle__glyph {
font-size: 14px;
}
.kpi-chips { .kpi-chips {
gap: 8px; gap: 8px;
} }
@@ -1329,6 +1413,7 @@
.search-wrap, .search-wrap,
.search-wrap.wide, .search-wrap.wide,
.topbar-toolset, .topbar-toolset,
.topbar-utility-actions,
.detail-alert-strip, .detail-alert-strip,
.month-chip, .month-chip,
.qa-filter, .qa-filter,
@@ -1344,6 +1429,15 @@
justify-content: flex-end; justify-content: flex-end;
} }
.topbar-utility-actions {
justify-content: flex-end;
}
.topbar-ai-mode-toggle {
width: 34px;
height: 34px;
}
.range-shell { .range-shell {
flex: 1; flex: 1;
} }
@@ -1505,6 +1599,10 @@
justify-content: space-between; justify-content: space-between;
} }
.topbar-ai-mode-toggle {
flex: 0 0 34px;
}
.range-combo { .range-combo {
display: grid; display: grid;
gap: 8px; gap: 8px;

View File

@@ -56,16 +56,52 @@
object-fit: cover; object-fit: cover;
} }
.message-bubble { .message-bubble-compact-guidance {
max-width: min(100%, 760px); max-width: min(100%, 640px);
padding: 12px 14px; padding: 10px 12px;
border: 1px solid #d8e4f0; border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
border-radius: 4px; background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
background: #ffffff; box-shadow: 0 8px 18px rgba(148, 163, 184, 0.12);
color: #24324a; }
font-size: var(--wb-fs-bubble, 13px);
line-height: 1.62; .message-bubble-compact-guidance .message-meta {
box-shadow: 0 10px 22px rgba(148, 163, 184, 0.14); margin-bottom: 6px;
}
.message-bubble-compact-guidance .message-meta strong {
font-size: 12px;
}
.message-bubble-compact-guidance .message-answer-content {
font-size: 12px;
}
.message-bubble-compact-guidance .message-answer-markdown {
display: grid;
gap: 6px;
}
.message-bubble-compact-guidance .message-answer-markdown :deep(h3) {
margin: 0;
padding-left: 8px;
border-left: 3px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.42);
color: #17324a;
font-size: 12px;
font-weight: 860;
line-height: 1.4;
}
.message-bubble-compact-guidance .message-answer-markdown :deep(ul),
.message-bubble-compact-guidance .message-answer-markdown :deep(ol) {
gap: 4px;
}
.message-bubble-compact-guidance .message-answer-markdown :deep(li) {
line-height: 1.55;
}
.message-bubble-compact-guidance .message-suggested-actions {
margin-top: 8px;
} }
.message-row.has-steward-plan .message-bubble { .message-row.has-steward-plan .message-bubble {
@@ -135,7 +171,7 @@
.steward-intent-event-list { .steward-intent-event-list {
margin: 0; margin: 0;
padding: 0 12px 12px 30px; padding: 0 12px 12px 44px;
display: grid; display: grid;
gap: 7px; gap: 7px;
} }
@@ -274,6 +310,42 @@
color: #24324a; color: #24324a;
} }
.message-answer-markdown {
display: grid;
gap: 8px;
word-break: break-word;
overflow-wrap: anywhere;
}
.message-answer-markdown :deep(h1),
.message-answer-markdown :deep(h2),
.message-answer-markdown :deep(h3),
.message-answer-markdown :deep(h4) {
margin: 0;
color: #0f172a;
line-height: 1.35;
}
.message-answer-markdown :deep(h1) {
font-size: 15px;
font-weight: 860;
}
.message-answer-markdown :deep(h2) {
font-size: 14px;
font-weight: 850;
}
.message-answer-markdown :deep(h3) {
font-size: 13px;
font-weight: 840;
}
.message-answer-markdown :deep(h4) {
font-size: 12px;
font-weight: 820;
}
.message-answer-markdown :deep(p), .message-answer-markdown :deep(p),
.message-answer-markdown :deep(li), .message-answer-markdown :deep(li),
.message-answer-markdown :deep(td), .message-answer-markdown :deep(td),
@@ -281,16 +353,66 @@
.message-answer-markdown :deep(blockquote) { .message-answer-markdown :deep(blockquote) {
margin: 0; margin: 0;
color: inherit; color: inherit;
line-height: 1.62; line-height: 1.6;
} }
.message-answer-markdown :deep(p + p), .message-answer-markdown :deep(p + p),
.message-answer-markdown :deep(p + ul), .message-answer-markdown :deep(p + ul),
.message-answer-markdown :deep(p + ol),
.message-answer-markdown :deep(ul + p), .message-answer-markdown :deep(ul + p),
.message-answer-markdown :deep(ol + p) { .message-answer-markdown :deep(ol + p),
.message-answer-markdown :deep(blockquote + p) {
margin-top: 8px; margin-top: 8px;
} }
.message-answer-markdown :deep(ul),
.message-answer-markdown :deep(ol) {
margin: 0;
padding-left: 1.2em;
display: grid;
gap: 6px;
}
.message-answer-markdown :deep(li) {
margin: 0;
}
.message-answer-markdown :deep(li > p) {
margin: 0;
}
.message-answer-markdown :deep(blockquote) {
padding: 8px 10px;
border-left: 3px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.38);
border-radius: 4px;
background: #f8fbff;
color: #475569;
}
.message-answer-markdown :deep(code) {
padding: 0 5px;
border-radius: 4px;
background: #eef6fb;
color: #1d4ed8;
font-size: 0.95em;
}
.message-answer-markdown :deep(pre) {
margin: 0;
padding: 10px 12px;
overflow: auto;
border-radius: 4px;
background: #0f172a;
color: #e2e8f0;
}
.message-answer-markdown :deep(pre code) {
padding: 0;
background: transparent;
color: inherit;
font-size: 12px;
}
.message-answer-markdown :deep(strong) { .message-answer-markdown :deep(strong) {
color: #0f172a; color: #0f172a;
font-weight: 850; font-weight: 850;
@@ -649,6 +771,40 @@
gap: 8px; gap: 8px;
} }
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions {
grid-template-columns: repeat(auto-fit, minmax(136px, 1fr));
gap: 6px;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-btn {
min-height: 40px;
padding: 8px 10px;
grid-template-columns: 22px minmax(0, 1fr);
gap: 8px;
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18);
background: #ffffff;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-icon {
width: 22px;
height: 22px;
font-size: 13px;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-copy {
gap: 0;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-title {
font-size: 12px;
line-height: 1.35;
}
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-btn small,
.message-bubble-compact-guidance .message-suggested-actions.compact-guidance-actions .message-suggested-action-arrow {
display: none;
}
.structured-card-reveal-enter-active { .structured-card-reveal-enter-active {
transition: transition:
opacity 220ms cubic-bezier(0.2, 0, 0, 1), opacity 220ms cubic-bezier(0.2, 0, 0, 1),

View File

@@ -0,0 +1,40 @@
.workbench-mode-fade-enter-active,
.workbench-mode-fade-leave-active {
transition:
opacity 220ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)),
transform 220ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1)),
filter 220ms var(--ease, cubic-bezier(0.2, 0.8, 0.2, 1));
transform-origin: 50% 24px;
will-change: opacity, transform, filter;
}
.workbench-mode-fade-enter-from,
.workbench-mode-fade-leave-to {
opacity: 0;
transform: translateY(10px) scale(0.992);
filter: blur(2px);
}
.workbench-mode-fade-enter-to,
.workbench-mode-fade-leave-from {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
@media (prefers-reduced-motion: reduce) {
.workbench-mode-fade-enter-active,
.workbench-mode-fade-leave-active {
transition: none;
will-change: auto;
}
.workbench-mode-fade-enter-from,
.workbench-mode-fade-leave-to,
.workbench-mode-fade-enter-to,
.workbench-mode-fade-leave-from {
opacity: 1;
transform: none;
filter: none;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

View File

@@ -7,168 +7,35 @@
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。" note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/> />
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }"> <article class="panel workbench-trend-hero">
<div class="assistant-copy"> <div class="workbench-trend-card" aria-label="报销趋势同比">
<h1 class="assistant-hero-title"> <div class="trend-summary-panel">
{{ typedTitlePrefix }}<span v-if="titleTypingDone">小财管家</span><span v-if="!titleTypingDone" class="typing-cursor">|</span> <h1>报销趋势</h1>
</h1> <p>{{ reimbursementTrendRangeLabel }}</p>
<strong class="trend-total">{{ reimbursementTrendTotalLabel }}</strong>
<span class="trend-change" :class="reimbursementTrendGrowthTone">
<i :class="reimbursementTrendGrowthIcon" aria-hidden="true"></i>
{{ reimbursementTrendGrowthLabel }} 同比去年同期
</span>
<small>{{ displayUserName }} · {{ reimbursementTrendSignalLabel }}</small>
</div>
<input <div class="trend-chart-panel">
ref="fileInputRef" <div class="trend-chart-head">
class="assistant-file-input" <strong>月度报销明细</strong>
type="file" <span class="trend-chart-source">与分析看板同源</span>
multiple </div>
accept=".pdf,.jpg,.jpeg,.png,.webp,.doc,.docx,.xls,.xlsx"
@change="handleWorkbenchFilesChange"
/>
<div class="assistant-composer"> <TrendChart
<textarea class="workbench-trend-chart"
ref="assistantInputRef" mode="compareAmount"
v-model="assistantDraft" :labels="reimbursementTrendLabels"
maxlength="1000" :claim-amount="reimbursementTrendAmounts"
rows="2" :comparison-amount="reimbursementTrendPreviousAmounts"
placeholder="一次性描述申请、报销和附件处理事项,小财管家会先拆解再执行..." primary-label="本期"
:readonly="isComposerPending" comparison-label="去年同期"
@keydown.enter.prevent="handleWorkbenchEnter" compact
/> />
<div
v-if="composerPendingLabel"
class="assistant-intent-status"
role="status"
aria-live="polite"
>
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ composerPendingLabel }}</span>
</div>
<div v-if="workbenchDateTagLabel" class="workbench-date-chip-row">
<span class="workbench-date-chip">
<i class="mdi mdi-calendar-check"></i>
<span>{{ workbenchDateTagLabel }}</span>
<button
type="button"
aria-label="移除日期"
:disabled="Boolean(pendingAction)"
@click="removeWorkbenchDateTag"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</div>
<div class="composer-toolbar">
<button
type="button"
class="composer-icon-button"
title="上传附件"
aria-label="上传附件"
:disabled="Boolean(pendingAction)"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
<div class="workbench-date-anchor">
<button
type="button"
class="composer-icon-button"
:class="{ active: workbenchDatePickerOpen }"
title="选择日期"
aria-label="选择日期"
:aria-expanded="workbenchDatePickerOpen"
:disabled="Boolean(pendingAction)"
@click.stop="toggleWorkbenchDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="workbenchDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="日期选择"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'single' }"
@click="setWorkbenchDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: workbenchDateMode === 'range' }"
@click="setWorkbenchDateMode('range')"
>
时间段
</button>
</div>
<div v-if="workbenchDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="workbenchSingleDate" type="date" @change="handleWorkbenchDateInputChange('single')" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="workbenchRangeStartDate" type="date" @change="handleWorkbenchDateInputChange('range-start')" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="workbenchRangeEndDate" type="date" :min="workbenchRangeStartDate" @change="handleWorkbenchDateInputChange('range-end')" />
</label>
</div>
<p v-if="workbenchDateMode === 'range' && !workbenchCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
</div>
</div>
<span class="composer-count">{{ assistantDraft.length }}/1000</span>
<button
type="button"
class="composer-send-button"
:disabled="Boolean(pendingAction)"
:aria-label="composerPendingLabel || expenseActionLabel"
@click="handleExpenseConversationAction"
>
<i :class="pendingAction ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
</button>
</div>
</div>
<div v-if="selectedFiles.length" class="assistant-file-strip">
<span class="assistant-file-note">已带入 {{ selectedFiles.length }} 份附件</span>
<span v-for="file in selectedFiles" :key="file.name" class="assistant-file-chip">{{ file.name }}</span>
<button type="button" class="assistant-file-clear" @click="clearSelectedFiles">清空</button>
</div>
<div class="quick-prompts" aria-label="常用提问">
<span>常用提问</span>
<button
v-for="prompt in quickPromptItems"
:key="prompt"
type="button"
@click="applyQuickPrompt(prompt)"
>
{{ prompt }}
</button>
<button type="button" class="quick-more" @click="emit('open-assistant')">
更多
<i class="mdi mdi-chevron-right"></i>
</button>
</div> </div>
</div> </div>
</article> </article>
@@ -303,29 +170,21 @@
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue' import PanelHead from '../shared/PanelHead.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue' import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import TrendChart from '../charts/TrendChart.vue'
import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue' import ExpenseStatsDetailModal from './ExpenseStatsDetailModal.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue' import PersonalWorkbenchProgressPanel from './PersonalWorkbenchProgressPanel.vue'
import workbenchHeroBackground from '../../assets/images/hero-3d-banner.png'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import { import {
buildExpenseStatItems, buildExpenseStatItems,
filterAssistantCapabilitiesForUser, filterAssistantCapabilitiesForUser,
quickPromptItems,
resolveWorkbenchCapabilityGridClass, resolveWorkbenchCapabilityGridClass,
} from '../../data/personalWorkbench.js' } from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js' import { fetchAgentRuns } from '../../services/agentAssets.js'
import { clearUserConversations, fetchLatestConversation } from '../../services/orchestrator.js'
import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js' import { fetchCurrentEmployeeLatestProfile } from '../../services/reimbursements.js'
import {
ASSISTANT_SESSION_SNAPSHOT_EVENT,
hasAssistantSessionSnapshot
} from '../../utils/assistantSessionSnapshot.js'
import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js' import { buildWorkbenchCapabilityAssistantPayload } from '../../utils/personalWorkbenchAssistantEntry.js'
import { import {
buildProfileOperationsFromAgentRuns, buildProfileOperationsFromAgentRuns,
@@ -344,35 +203,6 @@ const props = defineProps({
const emit = defineEmits(['open-assistant', 'open-document']) const emit = defineEmits(['open-assistant', 'open-document'])
const { currentUser } = useSystemState() const { currentUser } = useSystemState()
const { toast } = useToast()
const assistantDraft = ref('')
const assistantInputRef = ref(null)
const fileInputRef = ref(null)
const selectedFiles = ref([])
const pendingAction = ref('')
let pendingActionTimer = 0
const {
workbenchDatePickerOpen,
workbenchDateMode,
workbenchSingleDate,
workbenchRangeStartDate,
workbenchRangeEndDate,
workbenchDateTagLabel,
workbenchCanApplyDateSelection,
clearWorkbenchDateSelection,
toggleWorkbenchDatePicker,
closeWorkbenchDatePicker,
setWorkbenchDateMode,
handleWorkbenchDatePickerOutside,
handleWorkbenchDateInputChange,
removeWorkbenchDateTag,
buildWorkbenchPromptText
} = useWorkbenchComposerDate({
draft: assistantDraft,
focusInput: focusAssistantInput
})
const latestExpenseConversation = ref(null)
const hasLocalExpenseSnapshot = ref(false)
const expenseStatsModalOpen = ref(false) const expenseStatsModalOpen = ref(false)
const expenseProfileModalOpen = ref(false) const expenseProfileModalOpen = ref(false)
const employeeProfile = ref(null) const employeeProfile = ref(null)
@@ -380,59 +210,13 @@ const employeeProfileRuns = ref([])
const employeeProfileLoading = ref(false) const employeeProfileLoading = ref(false)
const employeeProfileError = ref('') const employeeProfileError = ref('')
let employeeProfileLoadSeq = 0 let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const SESSION_TYPE_STEWARD = 'steward' const SESSION_TYPE_STEWARD = 'steward'
const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|| hasLocalExpenseSnapshot.value
)
const displayUserName = computed(() => { const displayUserName = computed(() => {
const user = currentUser.value || {} const user = currentUser.value || {}
return String(user.name || user.username || '同事').trim() || '同事' return String(user.name || user.username || '同事').trim() || '同事'
}) })
const heroTitleText = computed(() => `嗨,${displayUserName.value},我是您的 `)
const typedTitlePrefix = ref('')
const titleTypingDone = ref(false)
let typingInterval = null
const startTypewriter = () => {
typedTitlePrefix.value = ''
titleTypingDone.value = false
clearInterval(typingInterval)
let i = 0
const text = heroTitleText.value
typingInterval = setInterval(() => {
if (i < text.length) {
typedTitlePrefix.value += text.charAt(i)
i++
} else {
clearInterval(typingInterval)
titleTypingDone.value = true
}
}, 60)
}
watch(displayUserName, (newVal, oldVal) => {
if (oldVal !== newVal && titleTypingDone.value) {
typedTitlePrefix.value = `嗨,${newVal},我是您的 `
}
})
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
const isComposerPending = computed(() => Boolean(pendingAction.value))
const composerPendingLabel = computed(() => {
if (pendingAction.value === 'intent') {
return '正在识别意图,准备进入对应助手...'
}
if (pendingAction.value === 'expense') {
return '正在恢复最近报销会话...'
}
return ''
})
const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value)) const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value)) const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary)) const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
@@ -468,133 +252,100 @@ const currentUserProfileKey = computed(() => {
const user = currentUser.value || {} const user = currentUser.value || {}
return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|') return [user.username, user.email, user.name, user.employeeNo, user.employee_no].map((item) => String(item || '').trim()).filter(Boolean).join('|')
}) })
function buildSelectedFileKey(file) {
return [file?.name, file?.size, file?.lastModified, file?.type].join('__') function formatCurrencyValue(value) {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY',
minimumFractionDigits: 0,
maximumFractionDigits: 0
}).format(Number(value) || 0)
} }
function mergeSelectedFiles(existingFiles, incomingFiles) { function normalizeTrendRows(rows = []) {
const nextFiles = [] return rows.map((row, index) => {
const seen = new Set() const amount = Number(row?.amount || 0)
const previousAmount = Number(row?.previousAmount || row?.previous_amount || 0)
for (const file of existingFiles) { return {
const key = buildSelectedFileKey(file) key: String(row?.key || `trend-${index}`),
if (seen.has(key)) continue label: String(row?.label || `${index + 1}`),
seen.add(key) amount,
nextFiles.push(file) amountLabel: String(row?.amountLabel || row?.amount_label || formatCurrencyValue(amount)),
} previousKey: String(row?.previousKey || row?.previous_key || `previous-${index}`),
previousAmount,
let overflowCount = 0 previousAmountLabel: String(
row?.previousAmountLabel || row?.previous_amount_label || formatCurrencyValue(previousAmount)
for (const file of incomingFiles) { )
const key = buildSelectedFileKey(file)
if (seen.has(key)) continue
if (nextFiles.length >= MAX_ATTACHMENTS) {
overflowCount += 1
continue
} }
seen.add(key) })
nextFiles.push(file)
}
return {
files: nextFiles,
overflowCount
}
}
function resolveCurrentUserId() {
const user = currentUser.value || {}
return String(user.username || user.name || 'anonymous').trim() || 'anonymous'
} }
const sourceReimbursementTrendRows = computed(() => normalizeTrendRows(props.workbenchSummary.reimbursementTrendRows || []))
const reimbursementTrendHasSignal = computed(() =>
sourceReimbursementTrendRows.value.some((item) => item.amount > 0 || item.previousAmount > 0)
)
const reimbursementTrendRows = computed(() => sourceReimbursementTrendRows.value)
const reimbursementTrendSignalLabel = computed(() =>
reimbursementTrendHasSignal.value ? '来自你的真实单据' : '暂无单据时展示空走势'
)
const reimbursementTrendLabels = computed(() => reimbursementTrendRows.value.map((item) => item.label))
const reimbursementTrendAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.amount))
const reimbursementTrendPreviousAmounts = computed(() => reimbursementTrendRows.value.map((item) => item.previousAmount))
const reimbursementTrendTotal = computed(() =>
reimbursementTrendRows.value.reduce((total, item) => total + item.amount, 0)
)
const reimbursementTrendPreviousTotal = computed(() =>
reimbursementTrendRows.value.reduce((total, item) => total + item.previousAmount, 0)
)
const reimbursementTrendTotalLabel = computed(() => formatCurrencyValue(reimbursementTrendTotal.value))
const reimbursementTrendRangeLabel = computed(() => {
const rows = reimbursementTrendRows.value
const first = rows[0]
const last = rows[rows.length - 1]
if (!first || !last) {
return '近 6 个月'
}
return `${first.label} - ${last.label}`
})
const reimbursementTrendGrowthRate = computed(() => {
const previousTotal = reimbursementTrendPreviousTotal.value
if (previousTotal > 0) {
return ((reimbursementTrendTotal.value - previousTotal) / previousTotal) * 100
}
return reimbursementTrendTotal.value > 0 ? 100 : 0
})
const reimbursementTrendGrowthLabel = computed(() => {
const value = reimbursementTrendGrowthRate.value
const prefix = value >= 0 ? '+' : ''
return `${prefix}${value.toFixed(1)}%`
})
const reimbursementTrendGrowthTone = computed(() =>
reimbursementTrendGrowthRate.value >= 0 ? 'is-up' : 'is-down'
)
const reimbursementTrendGrowthIcon = computed(() =>
reimbursementTrendGrowthRate.value >= 0 ? 'mdi mdi-arrow-up-right' : 'mdi mdi-arrow-down-right'
)
function buildAssistantPayload() { function buildAssistantPayload() {
return { return {
prompt: buildWorkbenchPromptText(), prompt: '',
source: 'workbench', source: 'workbench',
sessionType: SESSION_TYPE_STEWARD, sessionType: SESSION_TYPE_STEWARD,
files: Array.from(selectedFiles.value) files: []
} }
} }
function clearSelectedFiles() {
selectedFiles.value = []
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function resetWorkbenchDraft() {
assistantDraft.value = ''
clearSelectedFiles()
clearWorkbenchDateSelection()
}
function clearPendingAction() {
pendingAction.value = ''
if (pendingActionTimer) {
window.clearTimeout(pendingActionTimer)
pendingActionTimer = 0
}
}
function startPendingAction(action) {
clearPendingAction()
pendingAction.value = action
pendingActionTimer = window.setTimeout(() => {
if (pendingAction.value !== action) {
return
}
clearPendingAction()
toast('进入助手耗时较长,请稍后重试。')
}, 16000)
}
function shouldShowIntentPending(payload = {}) {
return !props.assistantModalOpen
&& String(payload.prompt || '').trim()
&& String(payload.source || 'workbench').trim() === 'workbench'
&& !String(payload.sessionType || '').trim()
}
function emitAssistant(payload) { function emitAssistant(payload) {
emit('open-assistant', payload) emit('open-assistant', payload)
resetWorkbenchDraft()
}
async function loadLatestConversation() {
const payload = await fetchLatestConversation(resolveCurrentUserId(), SESSION_TYPE_EXPENSE, {
preferRecoverable: true
})
return payload?.found ? payload.conversation || null : null
}
function focusAssistantInput() {
nextTick(() => {
assistantInputRef.value?.focus()
})
}
function applyQuickPrompt(prompt) {
assistantDraft.value = String(prompt || '').trim()
focusAssistantInput()
} }
function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) { function openPromptAssistant(prompt, sessionType = SESSION_TYPE_STEWARD) {
if (pendingAction.value) { emitAssistant({
return prompt: String(prompt || '').trim(),
}
const payload = {
prompt: buildWorkbenchPromptText(prompt),
source: 'workbench', source: 'workbench',
sessionType, sessionType,
files: Array.from(selectedFiles.value), files: [],
conversation: null conversation: null
} })
if (shouldShowIntentPending(payload)) {
startPendingAction('intent')
}
emitAssistant(payload)
} }
function openWorkbenchTarget(item) { function openWorkbenchTarget(item) {
@@ -614,10 +365,6 @@ function openWorkbenchTarget(item) {
} }
function openCapabilityAssistant(item) { function openCapabilityAssistant(item) {
if (pendingAction.value) {
return
}
emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload())) emitAssistant(buildWorkbenchCapabilityAssistantPayload(item, buildAssistantPayload()))
} }
@@ -669,122 +416,10 @@ function closeExpenseProfileModal() {
expenseProfileModalOpen.value = false expenseProfileModalOpen.value = false
} }
function handleWorkbenchEnter(event) {
if (event.isComposing) {
return
}
handleExpenseConversationAction()
}
function triggerFileUpload() {
fileInputRef.value?.click()
}
function handleWorkbenchFilesChange(event) {
const mergeResult = mergeSelectedFiles(selectedFiles.value, Array.from(event.target.files ?? []))
selectedFiles.value = mergeResult.files
if (mergeResult.overflowCount > 0) {
toast(`一次最多上传 ${MAX_ATTACHMENTS} 份附件,已保留前 ${MAX_ATTACHMENTS} 份。`)
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
async function refreshLatestExpenseConversation() {
refreshLocalExpenseSnapshot()
try {
latestExpenseConversation.value = await loadLatestConversation()
} catch (error) {
console.warn('Failed to refresh latest expense conversation:', error)
latestExpenseConversation.value = null
}
}
function refreshLocalExpenseSnapshot() {
hasLocalExpenseSnapshot.value = hasAssistantSessionSnapshot(resolveCurrentUserId(), SESSION_TYPE_EXPENSE)
}
function handleAssistantSessionSnapshotChange(event) {
const sessionType = String(event?.detail?.sessionType || '').trim()
if (!sessionType || sessionType === SESSION_TYPE_EXPENSE) {
refreshLocalExpenseSnapshot()
}
}
async function clearKnowledgeHistoryBeforeExpense() {
await clearUserConversations(resolveCurrentUserId(), SESSION_TYPE_KNOWLEDGE)
}
async function handleExpenseConversationAction() {
if (pendingAction.value) {
return
}
const nextPayload = buildAssistantPayload()
const shouldOpenImmediately = Boolean(nextPayload.prompt || nextPayload.files.length)
if (shouldOpenImmediately) {
if (shouldShowIntentPending(nextPayload)) {
startPendingAction('intent')
}
emitAssistant({
...nextPayload,
conversation: null
})
void clearKnowledgeHistoryBeforeExpense().catch((error) => {
console.warn('Failed to clear knowledge history before expense:', error)
})
return
}
startPendingAction('expense')
try {
await clearKnowledgeHistoryBeforeExpense()
const conversation = await loadLatestConversation()
latestExpenseConversation.value = conversation
emitAssistant({
...nextPayload,
conversation
})
} catch (error) {
console.warn('Failed to open expense conversation:', error)
toast(error?.message || '打开报销会话失败,请稍后重试。')
} finally {
clearPendingAction()
}
}
onMounted(() => { onMounted(() => {
startTypewriter()
refreshLocalExpenseSnapshot()
refreshLatestExpenseConversation()
loadCurrentEmployeeProfile() loadCurrentEmployeeProfile()
document.addEventListener('click', handleWorkbenchDatePickerOutside)
window.addEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
}) })
onBeforeUnmount(() => {
clearInterval(typingInterval)
clearPendingAction()
document.removeEventListener('click', handleWorkbenchDatePickerOutside)
window.removeEventListener(ASSISTANT_SESSION_SNAPSHOT_EVENT, handleAssistantSessionSnapshotChange)
})
watch(
() => props.assistantModalOpen,
(open, previous) => {
if (open) {
clearPendingAction()
}
if (previous && !open) {
refreshLatestExpenseConversation()
}
}
)
watch(currentUserProfileKey, (nextKey, previousKey) => { watch(currentUserProfileKey, (nextKey, previousKey) => {
if (nextKey && nextKey !== previousKey) { if (nextKey && nextKey !== previousKey) {
loadCurrentEmployeeProfile() loadCurrentEmployeeProfile()
@@ -794,6 +429,5 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
<style scoped src="../../assets/styles/components/personal-workbench.css"></style> <style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-glass.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-glass.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="trend-chart"> <div class="trend-chart" :class="{ 'trend-chart-compact': compact, 'trend-chart-dark': dark }">
<div class="chart-toolbar"> <div class="chart-toolbar">
<div class="chart-legend"> <div class="chart-legend">
<span <span
@@ -39,6 +39,10 @@ const props = defineProps({
claimCount: { type: Array, default: () => [] }, claimCount: { type: Array, default: () => [] },
claimAmount: { type: Array, default: () => [] }, claimAmount: { type: Array, default: () => [] },
categoryAmountSeries: { type: Array, default: () => [] }, categoryAmountSeries: { type: Array, default: () => [] },
comparisonAmount: { type: Array, default: () => [] },
primaryLabel: { type: String, default: '报销金额' },
comparisonLabel: { type: String, default: '去年同期' },
compact: { type: Boolean, default: false },
applications: { type: Array, default: () => [] }, applications: { type: Array, default: () => [] },
approved: { type: Array, default: () => [] } approved: { type: Array, default: () => [] }
}) })
@@ -46,6 +50,7 @@ const props = defineProps({
const chartElement = shallowRef(null) const chartElement = shallowRef(null)
const themeColors = useThemeColors() const themeColors = useThemeColors()
const isCountMode = computed(() => props.mode === 'count') const isCountMode = computed(() => props.mode === 'count')
const isComparisonMode = computed(() => props.mode === 'compareAmount')
const chartColors = computed(() => ({ const chartColors = computed(() => ({
primary: themeColors.value.chartPrimary, primary: themeColors.value.chartPrimary,
blue: themeColors.value.chartBlue, blue: themeColors.value.chartBlue,
@@ -93,14 +98,30 @@ const stackedAmountData = computed(() => props.labels.map((_, index) => [
index, index,
...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0)) ...amountCategorySeries.value.map((item) => Number(item.data?.[index] || 0))
])) ]))
const activeColor = computed(() => ( const activeColor = computed(() => {
isCountMode.value ? chartColors.value.primary : chartColors.value.blue return isCountMode.value ? chartColors.value.primary : chartColors.value.blue
)) })
const comparisonColor = computed(() => '#cbd5e1')
const legendLabel = computed(() => ( const legendLabel = computed(() => (
isCountMode.value ? '报销数量' : '报销金额' isCountMode.value ? '报销数量' : (isComparisonMode.value ? props.primaryLabel : '报销金额')
)) ))
const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元')) const unitLabel = computed(() => (isCountMode.value ? '单位:单' : '单位:元'))
const legendItems = computed(() => { const legendItems = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
color: activeColor.value,
title: `${props.primaryLabel} ${unitLabel.value}`
},
{
name: props.comparisonLabel,
color: comparisonColor.value,
title: `${props.comparisonLabel} ${unitLabel.value}`
}
]
}
if (amountCategorySeries.value.length) { if (amountCategorySeries.value.length) {
return amountCategorySeries.value.map((item, index) => ({ return amountCategorySeries.value.map((item, index) => ({
name: item.name || `费用类型 ${index + 1}`, name: item.name || `费用类型 ${index + 1}`,
@@ -114,23 +135,144 @@ const legendItems = computed(() => {
title: `${legendLabel.value} ${unitLabel.value}` title: `${legendLabel.value} ${unitLabel.value}`
}] }]
}) })
const maxValue = computed(() => Math.max(...activeSeries.value.map((value) => Number(value || 0)), 1)) const comparisonSeries = computed(() => (
Array.isArray(props.comparisonAmount) ? props.comparisonAmount : []
))
const maxValue = computed(() => {
const values = [
...activeSeries.value.map((value) => Number(value || 0)),
...(isComparisonMode.value ? comparisonSeries.value.map((value) => Number(value || 0)) : [])
]
const rawMax = Math.max(...values, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
const compactScale = computed(() => ({
axisLabelSize: props.compact ? 12 : 11,
comparisonLineWidth: props.compact ? 3 : 2.5,
comparisonSymbolSize: props.compact ? 7.5 : 6,
defaultLineWidth: props.compact ? 3 : 2.5,
defaultSymbolSize: props.compact ? 8 : 7,
gridBottom: props.compact ? 18 : 22,
gridLeft: props.compact ? 42 : 36,
gridRight: props.compact ? 28 : 24,
gridTop: props.compact ? 10 : 12,
primaryLineWidth: props.compact ? 3.8 : 3,
primarySymbolSize: props.compact ? 8.5 : 7
}))
const chartGrid = computed(() => ({
top: compactScale.value.gridTop,
right: compactScale.value.gridRight,
bottom: compactScale.value.gridBottom,
left: compactScale.value.gridLeft,
containLabel: true
}))
const stackedMaxValue = computed(() => { const stackedMaxValue = computed(() => {
if (!amountCategorySeries.value.length) { if (isComparisonMode.value || !amountCategorySeries.value.length) {
return maxValue.value return maxValue.value
} }
const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value const dailyTotals = props.labels.map((_, index) => amountCategorySeries.value
.reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0)) .reduce((sum, item) => sum + Number(item.data?.[index] || 0), 0))
return Math.max(...dailyTotals, 1) const rawMax = Math.max(...dailyTotals, 0)
if (isCountMode.value) {
return Math.max(rawMax, 5)
}
return Math.max(rawMax, 100)
})
function getFormattedMax(val, isCount) {
if (isCount) {
const base = Math.max(val, 4)
if (base <= 4) return 4
if (base <= 6) return 6
if (base <= 10) return 10
return Math.ceil(base / 2) * 2
} else {
const base = Math.max(val, 100)
if (base <= 100) return 100
if (base <= 200) return 200
if (base <= 500) return 500
if (base <= 1000) return 1000
if (base <= 2000) return 2000
if (base <= 5000) return 5000
return Math.ceil(base / 1000) * 1000
}
}
const yAxisMax = computed(() => {
const calculatedMax = Math.ceil(stackedMaxValue.value * 1.18)
return getFormattedMax(calculatedMax, isCountMode.value)
}) })
const ariaLabel = computed(() => const ariaLabel = computed(() =>
props.labels.map((label, index) => ( props.labels.map((label, index) => (
isCountMode.value isComparisonMode.value
? `${label}${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
: isCountMode.value
? `${label}报销${claimCountSeries.value[index] || 0}` ? `${label}报销${claimCountSeries.value[index] || 0}`
: `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}` : `${label}报销金额${formatCurrency(claimAmountSeries.value[index] || 0)}`
)).join('') )).join('')
) )
const chartSeries = computed(() => { const chartSeries = computed(() => {
if (isComparisonMode.value) {
return [
{
name: props.primaryLabel,
type: 'line',
data: claimAmountSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.primarySymbolSize,
lineStyle: {
width: compactScale.value.primaryLineWidth,
color: activeColor.value
},
itemStyle: {
color: '#ffffff',
borderColor: activeColor.value,
borderWidth: props.compact ? 3 : 2.5
},
areaStyle: {
opacity: 1,
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: toRgba(activeColor.value, 0.12) },
{ offset: 1, color: toRgba(activeColor.value, 0.01) }
]
}
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
},
{
name: props.comparisonLabel,
type: 'line',
data: comparisonSeries.value,
smooth: true,
symbol: 'circle',
symbolSize: compactScale.value.comparisonSymbolSize,
lineStyle: {
width: compactScale.value.comparisonLineWidth,
color: comparisonColor.value,
type: 'dashed'
},
itemStyle: {
color: '#ffffff',
borderColor: comparisonColor.value,
borderWidth: props.compact ? 2.5 : 2
},
tooltip: {
valueFormatter: (value) => formatCurrency(value)
}
}
]
}
if (!isCountMode.value && amountCategorySeries.value.length) { if (!isCountMode.value && amountCategorySeries.value.length) {
return [{ return [{
name: '费用类型占比', name: '费用类型占比',
@@ -151,15 +293,15 @@ const chartSeries = computed(() => {
barWidth: 16, barWidth: 16,
smooth: isCountMode.value, smooth: isCountMode.value,
symbol: isCountMode.value ? 'circle' : 'none', symbol: isCountMode.value ? 'circle' : 'none',
symbolSize: 7, symbolSize: compactScale.value.defaultSymbolSize,
lineStyle: { lineStyle: {
width: 2.5, width: compactScale.value.defaultLineWidth,
color: activeColor.value color: activeColor.value
}, },
itemStyle: { itemStyle: {
color: isCountMode.value ? '#ffffff' : activeColor.value, color: isCountMode.value ? '#ffffff' : activeColor.value,
borderColor: activeColor.value, borderColor: activeColor.value,
borderWidth: isCountMode.value ? 2.5 : 0, borderWidth: isCountMode.value ? (props.compact ? 3 : 2.5) : 0,
borderRadius: [4, 4, 0, 0] borderRadius: [4, 4, 0, 0]
}, },
areaStyle: { areaStyle: {
@@ -190,13 +332,7 @@ const chartOptions = computed(() => ({
animationDurationUpdate: 1200, animationDurationUpdate: 1200,
animationEasing: 'linear', animationEasing: 'linear',
animationEasingUpdate: 'linear', animationEasingUpdate: 'linear',
grid: { grid: chartGrid.value,
top: 12,
right: 24,
bottom: 22,
left: 36,
containLabel: true
},
tooltip: { tooltip: {
trigger: 'axis', trigger: 'axis',
confine: true, confine: true,
@@ -221,20 +357,22 @@ const chartOptions = computed(() => ({
axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } }, axisLine: { lineStyle: { color: 'rgba(148, 163, 184, 0.28)' } },
axisLabel: { axisLabel: {
color: '#64748b', color: '#64748b',
fontSize: 11, fontSize: compactScale.value.axisLabelSize,
fontWeight: 700 fontWeight: 700
} }
}, },
yAxis: { yAxis: {
type: 'value', type: 'value',
min: 0, min: 0,
max: Math.ceil(stackedMaxValue.value * 1.18), max: yAxisMax.value,
splitNumber: 5, interval: props.compact ? (yAxisMax.value / 2) : undefined,
splitNumber: props.compact ? 2 : 5,
name: '', name: '',
axisLabel: { axisLabel: {
color: '#64748b', color: '#64748b',
fontSize: 11, fontSize: compactScale.value.axisLabelSize,
fontWeight: 700, fontWeight: 700,
margin: props.compact ? 12 : 8,
formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value)) formatter: (value) => (isCountMode.value ? `${Math.round(value)}` : formatAxisCurrency(value))
}, },
splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } } splitLine: { lineStyle: { color: 'rgba(226, 232, 240, 0.75)' } }
@@ -352,6 +490,16 @@ function formatTooltip(params) {
if (!first) { if (!first) {
return '' return ''
} }
if (isComparisonMode.value) {
const index = Number(first.dataIndex || 0)
const label = props.labels[index] || first.axisValueLabel || first.name || ''
return [
label,
`${props.primaryLabel}${formatCurrency(claimAmountSeries.value[index] || 0)}`,
`${props.comparisonLabel}${formatCurrency(comparisonSeries.value[index] || 0)}`
].join('<br/>')
}
if (!isCountMode.value && amountCategorySeries.value.length) { if (!isCountMode.value && amountCategorySeries.value.length) {
return formatStackedTooltip(first) return formatStackedTooltip(first)
} }
@@ -406,6 +554,11 @@ function formatAxisCurrency(value) {
flex-direction: column; flex-direction: column;
} }
.trend-chart-compact {
height: 100%;
min-height: 124px;
}
.chart-toolbar { .chart-toolbar {
min-height: 30px; min-height: 30px;
display: flex; display: flex;
@@ -465,4 +618,39 @@ function formatAxisCurrency(value) {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
} }
.trend-chart-compact .chart-toolbar {
min-height: 28px;
margin-bottom: 6px;
}
.trend-chart-compact .chart-legend {
gap: 6px 14px;
font-size: 13px;
}
.trend-chart-compact .legend-pill {
max-width: 128px;
}
.trend-chart-compact .chart-legend i {
width: 9px;
height: 9px;
}
.trend-chart-compact .chart-unit {
padding: 2px 8px;
font-size: 12.5px;
}
.trend-chart-dark .chart-legend,
.trend-chart-dark .legend-pill {
color: #64748b;
}
.trend-chart-dark .chart-unit {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: #64748b;
}
</style> </style>

View File

@@ -0,0 +1,321 @@
<template>
<aside class="ai-rail" :class="{ 'rail-collapsed': collapsed }" aria-label="AI模式导航">
<section class="ai-rail-brand" aria-label="当前产品标识">
<span class="ai-brand-logo" aria-hidden="true">
<img v-if="brandLogo" :src="brandLogo" alt="" />
<svg v-else viewBox="0 0 36 36">
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
</svg>
</span>
<span class="ai-brand-copy">
<strong>{{ displayBrandName }}</strong>
<small>AI 财务工作台</small>
</span>
</section>
<section class="ai-rail-section ai-rail-quick" aria-label="对话操作">
<template v-for="action in quickActions" :key="action.event">
<label
v-if="action.event === 'search' && conversationSearchOpen"
class="ai-conversation-search"
>
<i class="mdi mdi-magnify" aria-hidden="true"></i>
<input
ref="conversationSearchInputRef"
v-model="conversationSearchQuery"
type="search"
placeholder="搜索对话标题"
@keydown.esc.prevent="closeConversationSearch"
/>
<button
type="button"
:aria-label="conversationSearchQuery ? '清空对话搜索' : '关闭对话搜索'"
@click="handleConversationSearchAuxAction"
>
<i class="mdi mdi-close" aria-hidden="true"></i>
</button>
</label>
<button
v-else
type="button"
class="ai-quick-btn"
:class="{ primary: action.primary }"
@click="handleQuickAction(action.event)"
>
<i :class="action.icon" aria-hidden="true"></i>
<span>{{ action.label }}</span>
</button>
</template>
</section>
<div class="ai-rail-divider"></div>
<nav class="ai-rail-section ai-rail-nav" aria-label="业务导航">
<div class="ai-nav-list">
<button
v-for="item in businessNavItems"
:key="item.id"
type="button"
class="ai-nav-btn"
:class="{ active: activeView === item.id }"
:aria-current="activeView === item.id ? 'page' : undefined"
@click="emit('navigate', item.id)"
>
<span class="ai-nav-icon" aria-hidden="true">
<i :class="item.aiIcon"></i>
</span>
<span class="ai-nav-copy">
<strong>{{ item.displayLabel }}</strong>
</span>
</button>
</div>
</nav>
<div class="ai-rail-divider"></div>
<section class="ai-rail-section ai-rail-recents" aria-label="最近对话">
<h2 class="ai-section-heading">最近对话</h2>
<div class="ai-recents-list">
<div
v-for="recent in filteredConversationHistory"
:key="recent.id"
role="button"
tabindex="0"
class="ai-recent-item"
:class="{ active: activeConversationId === recent.id }"
:aria-current="activeConversationId === recent.id ? 'true' : undefined"
@click="handleRecentClick(recent)"
@dblclick.stop="startEditingRecentTitle(recent)"
@keydown.enter.prevent="emit('open-recent', recent)"
@keydown.space.prevent="emit('open-recent', recent)"
>
<span class="ai-recent-main">
<input
v-if="editingConversationId === recent.id"
ref="editingTitleInputRef"
v-model="editingConversationTitle"
class="ai-recent-title-input"
type="text"
aria-label="编辑对话标题"
@click.stop
@dblclick.stop
@keydown.enter.prevent="commitRecentTitleEdit(recent)"
@keydown.esc.prevent="cancelRecentTitleEdit"
@blur="commitRecentTitleEdit(recent)"
/>
<span v-else class="ai-recent-title">{{ recent.title }}</span>
<span class="ai-recent-desc">{{ recent.desc }}</span>
</span>
<span class="ai-recent-time">{{ recent.time }}</span>
</div>
<p v-if="!normalizedConversationHistory.length" class="ai-recents-empty">暂无历史对话</p>
<p v-else-if="!filteredConversationHistory.length" class="ai-recents-empty">没有匹配的对话</p>
</div>
</section>
<section class="ai-rail-user" aria-label="当前用户">
<div class="ai-user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
<div class="ai-user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.subtitle }}</span>
</div>
<div class="ai-user-actions" aria-label="用户操作">
<button type="button" class="ai-user-action ai-user-logout" aria-label="退出系统" @click="emit('logout')">
<i class="mdi mdi-logout-variant" aria-hidden="true"></i>
</button>
</div>
</section>
</aside>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue'
import { resolveAiSidebarBusinessViewIds } from '../../utils/aiSidebarBusinessAccess.js'
const props = defineProps({
navItems: { type: Array, required: true },
activeView: { type: String, required: true },
activeConversationId: { type: String, default: '' },
brandName: { type: String, default: '' },
brandLogo: { type: String, default: '' },
currentUser: {
type: Object,
default: () => ({
name: '系统管理员',
role: '管理员',
avatar: '管'
})
},
collapsed: {
type: Boolean,
default: false
},
conversationHistory: { type: Array, default: () => [] }
})
const emit = defineEmits(['navigate', 'new-chat', 'open-recent', 'rename-conversation', 'logout'])
const conversationSearchOpen = ref(false)
const conversationSearchQuery = ref('')
const conversationSearchInputRef = ref(null)
const editingConversationId = ref('')
const editingConversationTitle = ref('')
const editingTitleInputRef = ref(null)
let recentClickTimer = null
const quickActions = [
{
label: '新建对话',
icon: 'mdi mdi-plus',
event: 'new-chat',
primary: true
},
{
label: '查询对话',
icon: 'mdi mdi-magnify',
event: 'search'
}
]
const displayBrandName = computed(() => String(props.brandName || '易财费控').trim() || '易财费控')
const sidebarMeta = {
overview: { label: '分析看板', icon: 'mdi mdi-chart-line-variant' },
documents: { label: '单据中心', icon: 'mdi mdi-file-document-outline' },
receiptFolder: { label: '票据夹', icon: 'mdi mdi-receipt-text-outline' },
budget: { label: '预算管理', icon: 'mdi mdi-chart-donut' },
policies: { label: '财务政策', icon: 'mdi mdi-book-open-page-variant-outline' },
audit: { label: '规则管理', icon: 'mdi mdi-shield-check-outline' },
digitalEmployees: { label: '数字员工', icon: 'mdi mdi-robot-outline' },
employees: { label: '员工管理', icon: 'mdi mdi-account-group-outline' },
settings: { label: '系统设置', icon: 'mdi mdi-tune-vertical' }
}
const aiBusinessViewIds = computed(() => new Set(resolveAiSidebarBusinessViewIds(props.currentUser)))
const businessNavItems = computed(() =>
props.navItems
.filter((item) => aiBusinessViewIds.value.has(item.id))
.map((item) => ({
...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label,
aiIcon: sidebarMeta[item.id]?.icon ?? 'mdi mdi-circle-outline'
}))
)
const normalizedConversationHistory = computed(() => (
Array.isArray(props.conversationHistory) ? props.conversationHistory : []
))
const filteredConversationHistory = computed(() => {
const query = conversationSearchQuery.value.trim().toLowerCase()
if (!query) {
return normalizedConversationHistory.value
}
return normalizedConversationHistory.value.filter((recent) => (
String(recent.title || '').toLowerCase().includes(query)
))
})
const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员',
subtitle:
props.currentUser?.email ||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
props.currentUser?.role ||
'审批负责人',
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
}))
function handleQuickAction(event) {
if (event === 'new-chat') {
emit('new-chat')
return
}
if (event === 'search') {
openConversationSearch()
}
}
function openConversationSearch() {
conversationSearchOpen.value = true
void nextTick(() => {
resolveInputElement(conversationSearchInputRef.value)?.focus()
})
}
function closeConversationSearch() {
conversationSearchOpen.value = false
conversationSearchQuery.value = ''
}
function handleConversationSearchAuxAction() {
if (conversationSearchQuery.value) {
conversationSearchQuery.value = ''
return
}
closeConversationSearch()
}
function startEditingRecentTitle(recent = {}) {
clearRecentClickTimer()
editingConversationId.value = String(recent.id || '').trim()
editingConversationTitle.value = String(recent.title || '').trim()
void nextTick(() => {
const input = resolveInputElement(editingTitleInputRef.value)
input?.focus()
input?.select()
})
}
function handleRecentClick(recent = {}) {
clearRecentClickTimer()
recentClickTimer = window.setTimeout(() => {
emit('open-recent', recent)
recentClickTimer = null
}, 180)
}
function clearRecentClickTimer() {
if (recentClickTimer) {
window.clearTimeout(recentClickTimer)
recentClickTimer = null
}
}
function cancelRecentTitleEdit() {
editingConversationId.value = ''
editingConversationTitle.value = ''
}
function commitRecentTitleEdit(recent = {}) {
if (editingConversationId.value !== String(recent.id || '').trim()) {
return
}
const title = editingConversationTitle.value.trim()
cancelRecentTitleEdit()
if (!title || title === String(recent.title || '').trim()) {
return
}
emit('rename-conversation', {
id: recent.id,
title
})
}
function resolveInputElement(value) {
return Array.isArray(value) ? value[0] : value
}
onBeforeUnmount(() => {
clearRecentClickTimer()
})
</script>
<style scoped src="../../assets/styles/components/ai-sidebar-rail.css"></style>

View File

@@ -56,63 +56,24 @@
</ElTooltip> </ElTooltip>
</nav> </nav>
<div <section class="rail-user" aria-label="当前用户">
class="rail-user" <div class="user-avatar" aria-hidden="true">{{ displayUser.avatar }}</div>
@mouseenter="openCollapsedUserMenu" <div class="user-copy">
@mouseleave="closeCollapsedUserMenu" <strong>{{ displayUser.name }}</strong>
@focusin="openCollapsedUserMenu" <span>{{ displayUser.subtitle }}</span>
@focusout="handleUserFocusOut" </div>
> <div class="user-actions" aria-label="用户操作">
<div v-if="!collapsed" class="user-menu" role="menu" aria-label="用户菜单"> <button type="button" class="user-action user-logout" aria-label="退出系统" @click="emit('logout')">
<button class="user-menu-item" type="button" @click="emit('logout')"> <i class="mdi mdi-logout-variant" aria-hidden="true"></i>
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button> </button>
</div> </div>
</section>
<Teleport to="body">
<div
v-if="collapsed && userMenuOpen"
class="rail-user-menu-floating"
:style="userMenuStyle"
role="menu"
aria-label="用户菜单"
@mouseenter="clearUserMenuCloseTimer"
@mouseleave="closeCollapsedUserMenu"
>
<button class="user-menu-item" type="button" @click="handleLogout">
<i class="mdi mdi-logout-variant"></i>
<span>退出系统</span>
</button>
</div>
</Teleport>
<ElTooltip
:content="userTooltipContent"
placement="top"
effect="light"
:disabled="!collapsed || userMenuOpen"
:show-after="180"
:hide-after="0"
:offset="10"
popper-class="rail-tooltip-popper"
>
<div class="user-summary" tabindex="0" :aria-label="userTooltipContent">
<span class="user-avatar">{{ displayUser.avatar }}</span>
<span class="user-copy">
<strong>{{ displayUser.name }}</strong>
<span>{{ displayUser.role }}</span>
</span>
<i class="mdi mdi-chevron-up"></i>
</div>
</ElTooltip>
</div>
</aside> </aside>
</template> </template>
<script setup> <script setup>
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs' import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue' import { computed } from 'vue'
const props = defineProps({ const props = defineProps({
navItems: { type: Array, required: true }, navItems: { type: Array, required: true },
@@ -164,99 +125,16 @@ const decoratedNavItems = computed(() =>
const displayUser = computed(() => ({ const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员', name: props.currentUser?.name || '系统管理员',
role: props.currentUser?.role || '管理员', subtitle:
avatar: props.currentUser?.avatar || '管' props.currentUser?.email ||
(props.currentUser?.username && props.currentUser?.username !== props.currentUser?.name ? props.currentUser.username : '') ||
props.currentUser?.role ||
'管理员',
avatar: props.currentUser?.avatar || String(props.currentUser?.name || '管').trim().slice(0, 1) || '管'
})) }))
const displayCompanyName = computed(() => props.companyName || '易财费控') const displayCompanyName = computed(() => props.companyName || '易财费控')
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏')) const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
const userMenuOpen = ref(false)
let userMenuCloseTimer = null
const userMenuPosition = reactive({
top: 0,
left: 0
})
const userMenuStyle = computed(() => ({
top: `${userMenuPosition.top}px`,
left: `${userMenuPosition.left}px`
}))
function resolveUserMenuAnchor(element) {
return element?.querySelector?.('.user-summary') || element
}
function clearUserMenuCloseTimer() {
if (userMenuCloseTimer) {
clearTimeout(userMenuCloseTimer)
userMenuCloseTimer = null
}
}
function openCollapsedUserMenu(event) {
if (!props.collapsed) {
return
}
clearUserMenuCloseTimer()
const anchor = resolveUserMenuAnchor(event?.currentTarget)
if (!anchor?.getBoundingClientRect) {
return
}
const rect = anchor.getBoundingClientRect()
userMenuPosition.top = rect.top + rect.height / 2
userMenuPosition.left = rect.right + 12
userMenuOpen.value = true
}
function closeCollapsedUserMenu() {
clearUserMenuCloseTimer()
userMenuCloseTimer = setTimeout(() => {
userMenuOpen.value = false
userMenuCloseTimer = null
}, 120)
}
function closeCollapsedUserMenuNow() {
clearUserMenuCloseTimer()
userMenuOpen.value = false
}
function handleUserFocusOut(event) {
if (!props.collapsed) {
return
}
const container = event.currentTarget
const nextTarget = event.relatedTarget
if (nextTarget && container?.contains(nextTarget)) {
return
}
closeCollapsedUserMenuNow()
}
function handleLogout() {
closeCollapsedUserMenuNow()
emit('logout')
}
watch(
() => props.collapsed,
(isCollapsed) => {
if (!isCollapsed) {
closeCollapsedUserMenuNow()
}
}
)
onBeforeUnmount(() => {
closeCollapsedUserMenuNow()
})
</script> </script>

View File

@@ -1,10 +1,11 @@
<template> <template>
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }"> <header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group"> <div v-if="!isWorkbenchAiHome" class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div> <div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1> <h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p> <p>{{ currentView.desc }}</p>
</div> </div>
<div v-else class="title-group" aria-hidden="true"></div>
<div class="top-actions"> <div class="top-actions">
<template v-if="isChat"> <template v-if="isChat">
@@ -278,12 +279,23 @@
</Transition> </Transition>
</div> </div>
<button class="company-switcher" type="button" aria-label="切换公司"> <button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span> <span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i> <i class="mdi mdi-chevron-down"></i>
</button> </button>
</div> <button
</template> type="button"
class="topbar-ai-mode-toggle"
:class="{ active: isTopbarAiMode }"
:aria-pressed="isTopbarAiMode"
:aria-label="topbarWorkbenchModeTitle"
:title="topbarWorkbenchModeTitle"
@click="toggleTopbarWorkbenchMode"
>
<span class="topbar-ai-mode-toggle__glyph">AI</span>
</button>
</div>
</template>
<template v-else-if="isDocuments"> <template v-else-if="isDocuments">
<div class="kpi-chips"> <div class="kpi-chips">
@@ -345,18 +357,36 @@
</div> </div>
</template> </template>
<template v-else-if="isEmployees"> <template v-else-if="isEmployees">
<div class="kpi-chips"> <div class="kpi-chips">
<div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }"> <div v-for="kpi in employeeKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span> <span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span> <span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span> <span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div> </div>
</div> </div>
</template> </template>
</div>
</header> <div v-if="showAiModeUtilityActions" class="topbar-utility-actions" aria-label="AI模式快捷操作">
</template> <button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
<button
type="button"
class="topbar-ai-mode-toggle"
:class="{ active: isTopbarAiMode }"
:aria-pressed="isTopbarAiMode"
:aria-label="topbarWorkbenchModeTitle"
:title="topbarWorkbenchModeTitle"
@click="toggleTopbarWorkbenchMode"
>
<span class="topbar-ai-mode-toggle__glyph">AI</span>
</button>
</div>
</div>
</header>
</template>
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
@@ -394,14 +424,18 @@ const props = defineProps({
type: Object, type: Object,
default: () => null default: () => null
}, },
workbenchSummary: { workbenchSummary: {
type: Object, type: Object,
default: () => null default: () => null
}, },
companyName: { workbenchMode: {
type: String, type: String,
default: '' default: 'traditional'
}, },
companyName: {
type: String,
default: ''
},
detailMode: { detailMode: {
type: Boolean, type: Boolean,
default: false default: false
@@ -431,10 +465,11 @@ const emit = defineEmits([
'update:overviewDashboard', 'update:overviewDashboard',
'batchApprove', 'batchApprove',
'openChat', 'openChat',
'newApplication', 'newApplication',
'openDocument', 'openDocument',
'navigate' 'navigate',
]) 'toggleWorkbenchMode'
])
const isChat = computed(() => props.activeView === 'chat') const isChat = computed(() => props.activeView === 'chat')
const isOverview = computed(() => props.activeView === 'overview') const isOverview = computed(() => props.activeView === 'overview')
const isWorkbench = computed(() => props.activeView === 'workbench') const isWorkbench = computed(() => props.activeView === 'workbench')
@@ -444,12 +479,16 @@ const isRequests = computed(() => props.activeView === 'requests')
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees') const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isApproval = computed(() => props.activeView === 'approval') const isApproval = computed(() => props.activeView === 'approval')
const isPolicies = computed(() => props.activeView === 'policies') const isPolicies = computed(() => props.activeView === 'policies')
const isEmployees = computed(() => props.activeView === 'employees') const isEmployees = computed(() => props.activeView === 'employees')
const eyebrowLabel = computed(() => ( const eyebrowLabel = computed(() => (
String(props.currentView?.eyebrow || '').trim() String(props.currentView?.eyebrow || '').trim()
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations') || (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
)) ))
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司') const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const isTopbarAiMode = computed(() => props.workbenchMode === 'ai')
const topbarWorkbenchModeTitle = computed(() => (isTopbarAiMode.value ? 'AI 模式,点击切换传统模式' : '传统模式,点击切换 AI 模式'))
const isWorkbenchAiHome = computed(() => isWorkbench.value && isTopbarAiMode.value)
const showAiModeUtilityActions = computed(() => isTopbarAiMode.value && !isWorkbench.value)
const MAX_NOTIFICATION_ITEMS = 30 const MAX_NOTIFICATION_ITEMS = 30
const { const {
markDocumentInboxRowRead, markDocumentInboxRowRead,
@@ -576,12 +615,16 @@ const readNotifications = computed(() => notificationItems.value.filter((item) =
const activeNotifications = computed(() => ( const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
)) ))
const topbarNotificationCount = computed(() => { const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0 return count > 0 ? Math.min(count, 99) : 0
}) })
function clearDocumentInboxInitialRefreshTimer() { function toggleTopbarWorkbenchMode() {
emit('toggleWorkbenchMode')
}
function clearDocumentInboxInitialRefreshTimer() {
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') { if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(documentInboxInitialRefreshTimer) window.clearTimeout(documentInboxInitialRefreshTimer)
documentInboxInitialRefreshTimer = null documentInboxInitialRefreshTimer = null

View File

@@ -18,32 +18,45 @@
<p>{{ decisionDescription }}</p> <p>{{ decisionDescription }}</p>
</div> </div>
<div class="employee-risk-decision-action"> <div class="employee-risk-decision-action">
<span>建议结论</span> <span>是否建议通过</span>
<strong :class="decisionTone">{{ decisionAction }}</strong> <strong :class="decisionTone">{{ decisionBadgeLabel }}</strong>
<p>{{ decisionAction }}</p>
</div> </div>
</section> </section>
<section class="employee-risk-profile-section" aria-label="单据风险依据"> <dl class="employee-risk-review-summary" aria-label="审核建议摘要">
<div
v-for="item in reviewSummaryItems"
:key="item.key"
:class="['employee-risk-review-item', item.tone]"
>
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<section class="employee-risk-profile-section" aria-label="单据关键依据">
<div class="employee-risk-section-head"> <div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span> <span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small> <small>{{ stageBasisHint }}</small>
</div> </div>
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list"> <div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
<article <details
v-for="item in compactEvidenceItems" v-for="(item, index) in compactEvidenceItems"
:key="item.code" :key="item.code"
:class="['employee-risk-evidence-row', item.tone]" :class="['employee-risk-evidence-row', item.tone]"
:open="index === 0"
> >
<div class="employee-risk-evidence-title"> <summary class="employee-risk-evidence-title">
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<strong>{{ item.status }}</strong> <strong>{{ item.status }}</strong>
</div> </summary>
<ul v-if="item.evidence.length"> <ul v-if="item.evidence.length">
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li> <li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
</ul> </ul>
</article> </details>
</div> </div>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据</p> <p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据依据</p>
</section> </section>
</div> </div>
</article> </article>
@@ -95,12 +108,12 @@ export default {
} }
return 'normal' return 'normal'
}) })
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议') const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : 'AI建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据') const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单关键依据' : '报销单关键依据')
const stageBasisHint = computed(() => ( const stageBasisHint = computed(() => (
props.isApplicationDocument props.isApplicationDocument
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。' ? '默认只展开最关键的申请依据,其他细节点开查看。'
: '仅展示报销单本身的票据、金额、行程和规则命中依据。' : '默认只展开最关键的报销依据,其他细节点开查看。'
)) ))
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title) const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
const decisionAction = computed(() => { const decisionAction = computed(() => {
@@ -111,25 +124,26 @@ export default {
}) })
const decisionBadgeLabel = computed(() => { const decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') { if (decisionTone.value === 'high') {
return '高风险' return '不通过'
} }
if (decisionTone.value === 'medium') { if (decisionTone.value === 'medium') {
return '需关注' return '待补充'
} }
return '可审批' return '可通过'
}) })
const decisionDescription = computed(() => { const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length const riskCount = currentRiskCards.value.length
const subject = props.isApplicationDocument ? '申请' : '报销'
if (riskCount) { if (riskCount) {
if (!props.isApplicationDocument && riskExplanationItems.value.length) { if (!props.isApplicationDocument && riskExplanationItems.value.length) {
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分` return `当前${subject}识别 ${riskCount} 个需核对风险点,已补充说明但仍建议先核对票据与行程`
} }
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}识别 ${riskCount} 个需核对风险点,审批人应优先查看高风险依据。` return `当前${subject}识别 ${riskCount} 个需核对风险点,优先查看高风险依据。`
} }
if (materialIssues.value.length || sceneIssues.value.length) { if (materialIssues.value.length || sceneIssues.value.length) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。` return `当前${subject}存在材料或业务说明不完整,建议补齐后再处理。`
} }
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。` return `当前${subject}未发现中高风险阻断项,可按流程继续处理。`
}) })
const stageEvidenceItems = computed(() => ( const stageEvidenceItems = computed(() => (
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence() props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
@@ -139,6 +153,38 @@ export default {
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
return sourceItems.map((item) => ({ ...item })) return sourceItems.map((item) => ({ ...item }))
}) })
const stageRiskFactSummary = computed(() => buildStageRiskFactSummary({
isApplicationDocument: props.isApplicationDocument,
riskCount: currentRiskCards.value.length,
highCount: highRiskCards.value.length,
mediumCount: mediumRiskCards.value.length,
materialIssueCount: materialIssues.value.length,
sceneIssueCount: sceneIssues.value.length
}))
const stageReviewBasisSummary = computed(() => buildStageReviewBasisSummary(
compactEvidenceItems.value,
props.isApplicationDocument
))
const reviewSummaryItems = computed(() => [
{
key: 'fact',
label: '风险概览',
tone: decisionTone.value,
value: stageRiskFactSummary.value
},
{
key: 'basis',
label: '重点依据',
tone: decisionTone.value,
value: stageReviewBasisSummary.value
},
{
key: 'action',
label: '审核建议',
tone: decisionTone.value,
value: decisionAction.value
}
])
function buildApplicationEvidence() { function buildApplicationEvidence() {
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card))) const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
@@ -217,28 +263,68 @@ export default {
decisionDescription, decisionDescription,
decisionAction, decisionAction,
decisionTitle, decisionTitle,
reviewSummaryItems,
stageBasisHint, stageBasisHint,
stageBasisTitle, stageBasisTitle,
stageEvidenceItems, stageEvidenceItems,
stageReviewBasisSummary,
stageRiskFactSummary,
stageTitle stageTitle
} }
} }
} }
function buildStageRiskFactSummary({
isApplicationDocument,
riskCount = 0,
highCount = 0,
mediumCount = 0,
materialIssueCount = 0,
sceneIssueCount = 0
} = {}) {
const subject = isApplicationDocument ? '申请单' : '报销单'
if (riskCount > 0) {
return `${subject}识别 ${riskCount} 个需核对风险点,高风险 ${highCount} 个,中风险 ${mediumCount} 个。`
}
const issueCount = materialIssueCount + sceneIssueCount
if (issueCount > 0) {
return `${subject}暂无中高风险命中,但仍有 ${issueCount} 个材料或业务说明项需要补齐。`
}
return `${subject}未识别到中高风险阻断项。`
}
function buildStageReviewBasisSummary(evidenceItems = [], isApplicationDocument = false) {
const abnormalLabels = evidenceItems
.filter((item) => isAbnormalEvidence(item))
.map((item) => String(item?.label || '').trim())
.filter(Boolean)
if (abnormalLabels.length) {
return `重点核对${abnormalLabels.join('、')}`
}
return isApplicationDocument
? '重点看申请金额、预算触发和事由是否一致。'
: '重点看票据、金额、行程和附件是否一致。'
}
function resolveDecision(tone, isApplicationDocument) { function resolveDecision(tone, isApplicationDocument) {
const subject = isApplicationDocument ? '申请' : '报销'
const map = { const map = {
normal: { normal: {
title: `当前${subject}未发现中高风险阻断项`, title: '建议通过',
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}` action: isApplicationDocument
? '可按权限继续审核,系统会按预算结果决定是否进入下一步。'
: '可按权限继续审批,后续进入财务或付款流程。'
}, },
medium: { medium: {
title: `当前${subject}存在中风险,建议核对后处理`, title: '建议补充后通过',
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。' action: isApplicationDocument
? '建议补充预算占用、申请事由和金额依据后再通过。'
: '建议补充票据、金额或业务说明后再通过。'
}, },
high: { high: {
title: `当前${subject}存在高风险,不建议直接通过`, title: '不建议通过',
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。' action: isApplicationDocument
? '建议退回补充申请依据,或要求预算管理者复核。'
: '建议退回补充票据、行程说明或超标原因。'
} }
} }
return map[tone] || map.normal return map[tone] || map.normal

View File

@@ -278,6 +278,7 @@
<div <div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length" v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions" class="message-suggested-actions"
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
> >
<button <button
v-for="action in message.suggestedActions" v-for="action in message.suggestedActions"

View File

@@ -9,7 +9,11 @@ import { fetchAllApprovalExpenseClaims, fetchExpenseClaimDetail } from '../servi
import { fetchOntologyParse } from '../services/ontology.js' import { fetchOntologyParse } from '../services/ontology.js'
import { fetchLatestConversation } from '../services/orchestrator.js' import { fetchLatestConversation } from '../services/orchestrator.js'
import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js' import { clearAssistantSessionSnapshotForDraftClaim } from '../utils/assistantSessionSnapshot.js'
import { ASSISTANT_SCOPE_SESSION_STEWARD } from '../utils/assistantSessionScope.js' import {
ASSISTANT_SCOPE_SESSION_STEWARD,
buildUnsupportedBusinessScopeConversation,
resolveAssistantScopeGuard
} from '../utils/assistantSessionScope.js'
import { buildDetailAlerts } from '../utils/detailAlerts.js' import { buildDetailAlerts } from '../utils/detailAlerts.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js'
import { import {
@@ -293,10 +297,11 @@ export function useAppShell() {
view === 'documents' view === 'documents'
&& activeView.value === 'documents' && activeView.value === 'documents'
&& route.name === 'app-documents' && route.name === 'app-documents'
setView(view) const navigation = setView(view)
if (shouldRefreshCurrentDocumentCenter) { if (shouldRefreshCurrentDocumentCenter) {
void reloadDocumentCenterRequests() void reloadDocumentCenterRequests()
} }
return navigation
} }
function openFinancialAssistantCreate(source) { function openFinancialAssistantCreate(source) {
@@ -459,6 +464,36 @@ export function useAppShell() {
smartEntryRevealToken.value += 1 smartEntryRevealToken.value += 1
return return
} }
const prompt = String(payload.prompt || '').trim()
const files = Array.isArray(payload.files) ? payload.files : []
const scopeGuard = prompt
? resolveAssistantScopeGuard(prompt, String(payload.sessionType || '').trim(), {
attachmentCount: files.length
})
: null
if (scopeGuard?.blocked) {
smartEntryOpen.value = true
smartEntryContext.value = {
prompt: '',
source: payload.source ?? 'workbench',
request: payload.request ?? selectedRequest.value,
files,
conversation: buildUnsupportedBusinessScopeConversation(prompt, {
attachmentCount: files.length
}),
scope: null,
sessionType: ASSISTANT_SCOPE_SESSION_STEWARD,
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
? payload.budgetContext
: null,
initialPromptAutoSubmit: false,
initialApplicationPreview: null
}
smartEntrySessionId.value += 1
return
}
const [conversation, sessionType] = await Promise.all([ const [conversation, sessionType] = await Promise.all([
resolveSmartEntryConversation(payload), resolveSmartEntryConversation(payload),
resolveSmartEntrySessionType(payload) resolveSmartEntrySessionType(payload)

View File

@@ -147,7 +147,7 @@ export function useNavigation() {
return resolveAppViewFromRoute(route) return resolveAppViewFromRoute(route)
}, },
set(view) { set(view) {
setView(view) void setView(view)
} }
}) })
@@ -159,10 +159,10 @@ export function useNavigation() {
const targetName = resolveTargetRouteName(view) const targetName = resolveTargetRouteName(view)
if (route.name === targetName) { if (route.name === targetName) {
return return Promise.resolve()
} }
router.push({ name: targetName, params: {}, query: {}, hash: '' }) return router.push({ name: targetName, params: {}, query: {}, hash: '' })
} }
return { activeView, currentView, setView, navItems } return { activeView, currentView, setView, navItems }

View File

@@ -0,0 +1,79 @@
// AI 模式下的申请单草稿模型。
// 独立于旧的引导式报销/申请状态机,只在 AI 对话页内驱动逐项收集,
// 不调用旧视图的申请模板/预览钩子,也不复用 buildLocalApplicationPreview。
const DEFAULT_FIELD_STEPS = [
{ key: 'reason', label: '出差事由', prompt: '先告诉我这次出差的事由,例如:去上海支持上海电力部署项目。' },
{ key: 'time_range', label: '出差时间/天数', prompt: '出差时间和天数是什么例如2026-06-20 至 2026-06-22出差 3 天。' },
{ key: 'location', label: '出差地点', prompt: '出差地点是哪里?可以填城市或具体客户地点。' },
{ key: 'amount', label: '预计金额', prompt: '预计金额是多少?如果还没有汇总,可以回复“待核算”。' }
]
const SUMMARY_STEP_KEY = 'summary'
function normalizeAnswer(value) {
return String(value || '').trim()
}
export function getAiApplicationSteps() {
return DEFAULT_FIELD_STEPS
}
export function createAiApplicationDraft(expenseType, expenseTypeLabel) {
return {
expenseType: normalizeAnswer(expenseType),
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
values: {},
stepKey: DEFAULT_FIELD_STEPS[0].key
}
}
export function getAiApplicationCurrentStep(draft) {
const stepKey = normalizeAnswer(draft?.stepKey)
return DEFAULT_FIELD_STEPS.find((step) => step.key === stepKey) || DEFAULT_FIELD_STEPS[0]
}
export function buildAiApplicationStepPrompt(draft) {
const step = getAiApplicationCurrentStep(draft)
const label = normalizeAnswer(draft?.expenseTypeLabel) || '出差申请'
const stepIndex = Math.max(0, DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key))
return [
`好的,那我们先把${label}申请在当前对话里理一下。`,
'',
`${stepIndex + 1} 步 · ${step.label}`,
step.prompt
].join('\n')
}
export function applyAiApplicationAnswer(draft, answer, files = []) {
const current = draft && typeof draft === 'object' ? draft : createAiApplicationDraft()
const step = getAiApplicationCurrentStep(current)
const nextValues = { ...(current.values || {}) }
nextValues[step.key] = normalizeAnswer(answer)
const currentIndex = DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key)
const nextStep = DEFAULT_FIELD_STEPS[currentIndex + 1]
return {
...current,
values: nextValues,
stepKey: nextStep ? nextStep.key : SUMMARY_STEP_KEY
}
}
export function isAiApplicationDraftComplete(draft) {
return Boolean(draft) && normalizeAnswer(draft?.stepKey) === SUMMARY_STEP_KEY
}
export function buildAiApplicationSummary(draft) {
const label = normalizeAnswer(draft?.expenseTypeLabel) || '出差申请'
const values = draft?.values || {}
const lines = [`已完成「${label}」的要点收集,请核对:`, '']
DEFAULT_FIELD_STEPS.forEach((step) => {
const value = normalizeAnswer(values[step.key])
lines.push(`- ${step.label}${value || '待补充'}`)
})
lines.push('', '如果哪一项需要修改,直接告诉我;确认无误后我会帮你整理成申请草稿内容,再提交到申请助手生成单据。')
return lines.join('\n')
}

View File

@@ -0,0 +1,113 @@
// AI 模式下的报销草稿模型。
// 独立于旧的引导式报销状态机,只在 AI 对话页内驱动逐项收集,
// 不调用 steward、不复用 guidedFlow 的编排流程。
const DEFAULT_FIELD_STEPS = [
{ key: 'reason', label: '事由', prompt: '先告诉我这笔报销的事由,例如:项目现场支持、客户接待。' },
{ key: 'time_range', label: '发生时间', prompt: '费用是什么时候发生的例如2026-06-15。' },
{ key: 'location', label: '地点/对象', prompt: '费用发生的地点或对象是哪里?' },
{ key: 'amount', label: '金额', prompt: '本次报销金额是多少?' },
{ key: 'attachments', label: '票据', prompt: '票据可以现在上传,或回复“稍后上传”。' }
]
const SUMMARY_STEP_KEY = 'summary'
function normalizeAnswer(value) {
return String(value || '').trim()
}
function normalizeFileNames(files) {
return Array.from(files || [])
.map((file) => String(file?.name || '').trim())
.filter(Boolean)
}
export function getAiExpenseSteps() {
return DEFAULT_FIELD_STEPS
}
export function createAiExpenseDraft(expenseType, expenseTypeLabel) {
return {
expenseType: normalizeAnswer(expenseType),
expenseTypeLabel: normalizeAnswer(expenseTypeLabel),
applicationClaim: null,
values: {},
stepKey: DEFAULT_FIELD_STEPS[0].key
}
}
export function getAiExpenseCurrentStep(draft) {
const stepKey = normalizeAnswer(draft?.stepKey)
return DEFAULT_FIELD_STEPS.find((step) => step.key === stepKey) || DEFAULT_FIELD_STEPS[0]
}
export function buildAiExpenseStepPrompt(draft) {
const step = getAiExpenseCurrentStep(draft)
const label = normalizeAnswer(draft?.expenseTypeLabel) || '报销'
const stepIndex = Math.max(0, DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key))
return [
`已选择「${label}」,我们逐项把信息理一下。`,
'',
`${stepIndex + 1} 步 · ${step.label}`,
step.prompt
].join('\n')
}
export function applyAiExpenseAnswer(draft, answer, files = []) {
const current = draft && typeof draft === 'object' ? draft : createAiExpenseDraft()
const step = getAiExpenseCurrentStep(current)
const nextValues = { ...(current.values || {}) }
const fileNames = normalizeFileNames(files)
if (step.key === 'attachments') {
if (fileNames.length) {
nextValues.attachment_names = Array.from(
new Set([...(nextValues.attachment_names || []), ...fileNames])
)
}
nextValues.attachments = normalizeAnswer(answer)
|| (nextValues.attachment_names?.length ? `已选择 ${nextValues.attachment_names.length} 份附件` : '稍后上传')
} else {
nextValues[step.key] = normalizeAnswer(answer)
}
const currentIndex = DEFAULT_FIELD_STEPS.findIndex((item) => item.key === step.key)
const nextStep = DEFAULT_FIELD_STEPS[currentIndex + 1]
return {
...current,
values: nextValues,
stepKey: nextStep ? nextStep.key : SUMMARY_STEP_KEY
}
}
export function isAiExpenseDraftComplete(draft) {
return Boolean(draft) && normalizeAnswer(draft?.stepKey) === SUMMARY_STEP_KEY
}
export function buildAiExpenseSummary(draft) {
const label = normalizeAnswer(draft?.expenseTypeLabel) || '报销'
const values = draft?.values || {}
const application = draft?.applicationClaim || null
const lines = [`已完成「${label}」的信息收集,请核对:`, '']
if (application && normalizeAnswer(application.application_claim_no)) {
const parts = [
application.application_claim_no,
application.application_reason,
application.application_business_time,
application.application_location
].map(normalizeAnswer).filter(Boolean)
lines.push(`- 关联申请单:${parts.join(' / ')}`)
}
DEFAULT_FIELD_STEPS.forEach((step) => {
const value = step.key === 'attachments'
? (values.attachment_names?.length
? values.attachment_names.join('、')
: normalizeAnswer(values.attachments) || '稍后上传')
: normalizeAnswer(values[step.key])
lines.push(`- ${step.label}${value || '待补充'}`)
})
return lines.join('\n')
}

View File

@@ -0,0 +1,81 @@
export const AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS = ['documents', 'receiptFolder', 'policies']
const ROLE_VIEW_ADDITIONS = {
budget: ['budget'],
finance: ['overview', 'audit', 'digitalEmployees'],
manager: ['overview', 'employees'],
executive: ['budget', 'overview'],
admin: ['budget', 'overview', 'audit', 'digitalEmployees', 'employees']
}
function normalizeRoleCode(value) {
const normalized = String(value || '').trim().toLowerCase()
return normalized === 'auditor' ? 'budget_monitor' : normalized
}
function normalizedRoleCodes(user = {}) {
return Array.isArray(user.roleCodes)
? user.roleCodes.map((item) => normalizeRoleCode(item)).filter(Boolean)
: []
}
function normalizeProfileText(user = {}) {
return [
user.role,
user.position,
user.department,
user.departmentName,
user.department_name,
user.grade
]
.map((item) => String(item || '').trim().toLowerCase())
.filter(Boolean)
.join(' ')
}
function isPlatformAdminProfile(user = {}, roleCodes = []) {
const username = String(user.username || user.account || '').trim().toLowerCase()
const role = String(user.role || '').trim().toLowerCase()
return (
Boolean(user.isAdmin)
|| username === 'admin'
|| role === 'admin'
|| role === '管理员'
|| role === '系统管理员'
|| roleCodes.includes('admin')
)
}
function addViewIds(target, ids = []) {
ids.forEach((id) => target.add(id))
}
export function resolveAiSidebarBusinessViewIds(user = {}) {
const roleCodes = normalizedRoleCodes(user)
const roleCodeSet = new Set(roleCodes)
const profileText = normalizeProfileText(user)
const viewIds = new Set(AI_SIDEBAR_BASE_BUSINESS_VIEW_IDS)
if (isPlatformAdminProfile(user, roleCodes)) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.admin)
}
if (roleCodeSet.has('budget_monitor') || profileText.includes('预算')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.budget)
}
if (roleCodeSet.has('finance') || profileText.includes('财务')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.finance)
}
if (roleCodeSet.has('manager') || profileText.includes('经理') || profileText.includes('主管')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.manager)
}
if (roleCodeSet.has('executive') || profileText.includes('高管') || profileText.includes('总监')) {
addViewIds(viewIds, ROLE_VIEW_ADDITIONS.executive)
}
return Array.from(viewIds)
}

View File

@@ -0,0 +1,155 @@
const STORAGE_KEY_PREFIX = 'x-financial:workbench-ai-conversations'
const MAX_CONVERSATION_HISTORY = 30
const MAX_STORED_MESSAGES = 80
function safeString(value) {
return String(value || '').trim()
}
function resolveUserStorageKey(user = {}) {
const identity = safeString(user.username || user.email || user.name || 'anonymous')
return `${STORAGE_KEY_PREFIX}:${identity || 'anonymous'}`
}
function canUseStorage() {
return typeof window !== 'undefined' && Boolean(window.localStorage)
}
function readStoredList(user = {}) {
if (!canUseStorage()) {
return []
}
try {
const payload = JSON.parse(window.localStorage.getItem(resolveUserStorageKey(user)) || '[]')
return Array.isArray(payload) ? payload : []
} catch {
return []
}
}
function writeStoredList(user = {}, conversations = []) {
if (!canUseStorage()) {
return
}
const normalized = conversations
.map((item) => normalizeConversation(item))
.filter((item) => item.id)
.sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
.slice(0, MAX_CONVERSATION_HISTORY)
window.localStorage.setItem(resolveUserStorageKey(user), JSON.stringify(normalized))
}
function normalizeMessage(message = {}) {
return {
id: safeString(message.id) || `${Date.now()}-${Math.random().toString(16).slice(2)}`,
role: safeString(message.role) || 'assistant',
content: safeString(message.content),
pending: false,
feedback: safeString(message.feedback),
stewardPlan: message.stewardPlan && typeof message.stewardPlan === 'object'
? {
...message.stewardPlan,
streamStatus: safeString(message.stewardPlan.streamStatus) || 'completed'
}
: null,
suggestedActions: Array.isArray(message.suggestedActions) ? message.suggestedActions : []
}
}
function normalizeConversation(conversation = {}) {
const updatedAt = Number(conversation.updatedAt || conversation.updated_at || Date.now())
const messages = Array.isArray(conversation.messages)
? conversation.messages.slice(-MAX_STORED_MESSAGES).map((message) => normalizeMessage(message))
: []
const title = safeString(conversation.title) || buildConversationTitle(messages)
const desc = safeString(conversation.desc || conversation.description) || buildConversationDescription(messages)
return {
id: safeString(conversation.id || conversation.conversationId),
title,
desc,
time: formatConversationTime(updatedAt),
prompt: safeString(conversation.prompt) || resolveFirstUserPrompt(messages),
source: safeString(conversation.source) || 'workbench',
sessionType: safeString(conversation.sessionType) || 'steward',
conversationId: safeString(conversation.conversationId || conversation.id),
stewardState: conversation.stewardState && typeof conversation.stewardState === 'object'
? conversation.stewardState
: null,
messages,
updatedAt
}
}
function resolveStoredConversationId(conversation = {}) {
return safeString(conversation.id || conversation.conversationId)
}
function buildConversationTitle(messages = []) {
const firstUserMessage = messages.find((message) => message.role === 'user' && message.content)
return safeString(firstUserMessage?.content).slice(0, 18) || '新对话'
}
function buildConversationDescription(messages = []) {
const lastMessage = [...messages].reverse().find((message) => safeString(message.content))
return safeString(lastMessage?.content).replace(/\s+/g, ' ').slice(0, 32) || '小财管家对话'
}
function resolveFirstUserPrompt(messages = []) {
const firstUserMessage = messages.find((message) => message.role === 'user' && message.content)
return safeString(firstUserMessage?.content)
}
function formatConversationTime(timestamp) {
const date = new Date(Number(timestamp || Date.now()))
if (Number.isNaN(date.getTime())) {
return ''
}
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000
const timeText = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
const value = date.getTime()
if (value >= startOfToday) {
return `今天 ${timeText}`
}
if (value >= startOfYesterday) {
return '昨天'
}
return `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
}
export function loadAiWorkbenchConversationHistory(user = {}) {
return readStoredList(user)
.map((item) => normalizeConversation(item))
.filter((item) => item.id)
.sort((left, right) => Number(right.updatedAt || 0) - Number(left.updatedAt || 0))
}
export function saveAiWorkbenchConversation(user = {}, conversation = {}) {
const normalized = normalizeConversation({
...conversation,
updatedAt: conversation.updatedAt || Date.now()
})
if (!normalized.id || !normalized.messages.length) {
return loadAiWorkbenchConversationHistory(user)
}
const nextList = [
normalized,
...readStoredList(user).filter((item) => resolveStoredConversationId(item) !== normalized.id)
]
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}
export function deleteAiWorkbenchConversation(user = {}, conversationId = '') {
const normalizedId = safeString(conversationId)
const nextList = readStoredList(user).filter((item) => resolveStoredConversationId(item) !== normalizedId)
writeStoredList(user, nextList)
return loadAiWorkbenchConversationHistory(user)
}

View File

@@ -1,14 +1,14 @@
const EXPENSE_SCENE_SELECTION_OPTIONS = [ const EXPENSE_SCENE_SELECTION_OPTIONS = [
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' }, { key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline', requires_application_before_reimbursement: true, next_session_type: 'application' },
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' }, { key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' }, { key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink' }, { key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink', requires_application_before_reimbursement: true, next_session_type: 'application' },
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline' }, { key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' }, { key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline' }, { key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message' }, { key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline' }, { key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' },
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' } { key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline', requires_application_before_reimbursement: false, next_session_type: 'expense' }
] ]
const EXPENSE_INTENT_CONFIRMATION_ACTION = { const EXPENSE_INTENT_CONFIRMATION_ACTION = {
@@ -28,7 +28,9 @@ export function buildExpenseSceneSelectionActions(rawText) {
payload: { payload: {
expense_type: option.key, expense_type: option.key,
expense_type_label: option.label, expense_type_label: option.label,
original_message: originalMessage original_message: originalMessage,
requires_application_before_reimbursement: option.requires_application_before_reimbursement,
next_session_type: option.next_session_type
} }
})) }))
} }

View File

@@ -89,6 +89,22 @@ function padDatePart(value) {
return String(value).padStart(2, '0') return String(value).padStart(2, '0')
} }
function formatMonthKey(date) {
return `${date.getFullYear()}-${padDatePart(date.getMonth() + 1)}`
}
function formatMonthLabel(date) {
return `${date.getMonth() + 1}`
}
function shiftMonth(date, offset) {
return new Date(date.getFullYear(), date.getMonth() + offset, 1)
}
function resolveMonthStart(date) {
return new Date(date.getFullYear(), date.getMonth(), 1)
}
function formatDateTimeLabel(value) { function formatDateTimeLabel(value) {
if (value instanceof Date) { if (value instanceof Date) {
return [ return [
@@ -558,6 +574,55 @@ function buildExpenseOperationRows(todoItems, notifications, progressItems) {
.slice(0, 8) .slice(0, 8)
} }
function buildMonthlyAmountMap(ownedRequests) {
const rows = new Map()
for (const request of ownedRequests) {
const date = toDate(resolveClaimDate(request))
if (!date) {
continue
}
const key = formatMonthKey(date)
rows.set(key, (rows.get(key) || 0) + parseNumber(request?.amount))
}
return rows
}
function resolveTrendAnchorDate(ownedRequests) {
const dates = ownedRequests
.map((request) => toDate(resolveClaimDate(request)))
.filter(Boolean)
.sort((left, right) => right.getTime() - left.getTime())
return resolveMonthStart(dates[0] || new Date())
}
function buildReimbursementTrendRows(ownedRequests) {
const monthlyAmountMap = buildMonthlyAmountMap(ownedRequests)
const anchor = resolveTrendAnchorDate(ownedRequests)
return Array.from({ length: 6 }, (_, index) => {
const month = shiftMonth(anchor, index - 5)
const previousMonth = shiftMonth(month, -12)
const key = formatMonthKey(month)
const previousKey = formatMonthKey(previousMonth)
const amount = monthlyAmountMap.get(key) || 0
const previousAmount = monthlyAmountMap.get(previousKey) || 0
return {
key,
label: formatMonthLabel(month),
amount,
amountLabel: formatCurrency(amount),
previousKey,
previousAmount,
previousAmountLabel: formatCurrency(previousAmount)
}
})
}
export function buildWorkbenchSummary(requests, currentUser) { export function buildWorkbenchSummary(requests, currentUser) {
const allRequests = Array.isArray(requests) const allRequests = Array.isArray(requests)
? requests ? requests
@@ -602,6 +667,7 @@ export function buildWorkbenchSummary(requests, currentUser) {
highRiskCount, highRiskCount,
todoItems, todoItems,
progressItems, progressItems,
reimbursementTrendRows: buildReimbursementTrendRows(ownedRequests),
notifications, notifications,
expenseStatsDetail, expenseStatsDetail,
unreadNotificationCount: notifications.filter((item) => item.unread).length unreadNotificationCount: notifications.filter((item) => item.unread).length

View File

@@ -3,6 +3,7 @@
class="app" class="app"
:class="{ :class="{
'sidebar-collapsed': sidebarCollapsed, 'sidebar-collapsed': sidebarCollapsed,
'workbench-ai-sidebar-active': isAiShellMode,
'mobile-sidebar-open': mobileSidebarOpen, 'mobile-sidebar-open': mobileSidebarOpen,
'login-entry-active': loginEntryAnimating 'login-entry-active': loginEntryAnimating
}" }"
@@ -29,18 +30,39 @@
</div> </div>
</Transition> </Transition>
<div class="app-sidebar"> <div class="app-sidebar">
<SidebarRail <Transition name="sidebar-mode-fade" mode="out-in">
:nav-items="filteredNavItems" <AiSidebarRail
:active-view="activeView" v-if="isAiShellMode"
:company-name="PRODUCT_DISPLAY_NAME" key="ai-sidebar"
:company-logo="companyProfile.logo" :nav-items="filteredNavItems"
:current-user="currentUser" :active-view="activeView"
:collapsed="sidebarCollapsed" :active-conversation-id="aiActiveConversationId"
@open-chat="openSmartEntry" :conversation-history="aiConversationHistory"
@logout="handleLogout" :current-user="currentUser"
@toggle-collapse="toggleSidebarCollapsed" :brand-name="PRODUCT_DISPLAY_NAME"
@navigate="handleNavigateWithMobileClose" :brand-logo="companyProfile.logo"
/> :collapsed="sidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
@new-chat="openAiSidebarNewChat"
@open-recent="openAiSidebarRecent"
@rename-conversation="handleAiConversationRename"
@logout="handleLogout"
/>
<SidebarRail
v-else
key="standard-sidebar"
:nav-items="filteredNavItems"
:active-view="activeView"
:company-name="PRODUCT_DISPLAY_NAME"
:company-logo="companyProfile.logo"
:current-user="currentUser"
:collapsed="sidebarCollapsed"
@open-chat="openSmartEntry"
@logout="handleLogout"
@toggle-collapse="toggleSidebarCollapsed"
@navigate="handleNavigateWithMobileClose"
/>
</Transition>
</div> </div>
<main <main
@@ -72,6 +94,7 @@
:request-summary="requestSummary" :request-summary="requestSummary"
:document-summary="documentSummary" :document-summary="documentSummary"
:workbench-summary="workbenchSummary" :workbench-summary="workbenchSummary"
:workbench-mode="workbenchMode"
:digital-employee-summary="digitalEmployeeSummary" :digital-employee-summary="digitalEmployeeSummary"
:company-name="ENTERPRISE_DISPLAY_NAME" :company-name="ENTERPRISE_DISPLAY_NAME"
:detail-mode="resolvedDetailMode" :detail-mode="resolvedDetailMode"
@@ -87,6 +110,7 @@
@new-application="openExpenseApplicationCreate" @new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument" @open-document="openWorkbenchDocument"
@navigate="handleNavigate" @navigate="handleNavigate"
@toggle-workbench-mode="toggleWorkbenchMode"
/> />
<FilterBar <FilterBar
@@ -104,6 +128,7 @@
'documents-workarea': activeView === 'documents', 'documents-workarea': activeView === 'documents',
'receipt-folder-workarea': activeView === 'receiptFolder', 'receipt-folder-workarea': activeView === 'receiptFolder',
'workbench-workarea': activeView === 'workbench', 'workbench-workarea': activeView === 'workbench',
'workbench-workarea-ai-mode': isWorkbenchAiMode,
'budget-workarea': activeView === 'budget', 'budget-workarea': activeView === 'budget',
'policies-workarea': activeView === 'policies', 'policies-workarea': activeView === 'policies',
'audit-workarea': activeView === 'audit', 'audit-workarea': activeView === 'audit',
@@ -126,6 +151,10 @@
v-else-if="activeView === 'workbench'" v-else-if="activeView === 'workbench'"
:assistant-modal-open="smartEntryOpen" :assistant-modal-open="smartEntryOpen"
:workbench-summary="workbenchSummary" :workbench-summary="workbenchSummary"
:workbench-mode="workbenchMode"
:ai-sidebar-command="aiSidebarCommand"
@ai-conversation-change="handleAiConversationChange"
@ai-conversation-history-change="handleAiConversationHistoryChange"
@open-assistant="openSmartEntry" @open-assistant="openSmartEntry"
@open-document="openWorkbenchDocument" @open-document="openWorkbenchDocument"
/> />
@@ -207,8 +236,9 @@
</template> </template>
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AiSidebarRail from '../components/layout/AiSidebarRail.vue'
import SidebarRail from '../components/layout/SidebarRail.vue' import SidebarRail from '../components/layout/SidebarRail.vue'
import TopBar from '../components/layout/TopBar.vue' import TopBar from '../components/layout/TopBar.vue'
import FilterBar from '../components/layout/FilterBar.vue' import FilterBar from '../components/layout/FilterBar.vue'
@@ -229,6 +259,7 @@ import TravelRequestDetailView from './TravelRequestDetailView.vue'
import { useAppShell } from '../composables/useAppShell.js' import { useAppShell } from '../composables/useAppShell.js'
import { useSystemState } from '../composables/useSystemState.js' import { useSystemState } from '../composables/useSystemState.js'
import { filterNavItemsByAccess } from '../utils/accessControl.js' import { filterNavItemsByAccess } from '../utils/accessControl.js'
import { loadAiWorkbenchConversationHistory, saveAiWorkbenchConversation } from '../utils/aiWorkbenchConversationStore.js'
import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js' import { consumeLoginEntryTransition } from '../utils/loginEntryTransition.js'
const employeeSummary = ref(null) const employeeSummary = ref(null)
@@ -241,9 +272,15 @@ const digitalEmployeeDetailOpen = ref(false)
const receiptFolderDetailOpen = ref(false) const receiptFolderDetailOpen = ref(false)
const budgetDetailOpen = ref(false) const budgetDetailOpen = ref(false)
const loginEntryAnimating = ref(false) const loginEntryAnimating = ref(false)
const sidebarCollapsed = ref(true) const sidebarCollapsed = ref(false)
const sidebarCollapsedBeforeAiMode = ref(false)
const mobileSidebarOpen = ref(false) const mobileSidebarOpen = ref(false)
const overviewDashboard = ref('finance') const overviewDashboard = ref('finance')
const workbenchMode = ref('traditional')
const aiSidebarCommandSeq = ref(0)
const aiSidebarCommand = ref({ seq: 0, type: '', payload: null })
const aiActiveConversationId = ref('')
const aiConversationHistory = ref([])
let loginEntryTimer = null let loginEntryTimer = null
function stopLoginEntryAnimation() { function stopLoginEntryAnimation() {
@@ -269,10 +306,23 @@ function toggleSidebarCollapsed() {
} }
function handleNavigateWithMobileClose(viewId) { function handleNavigateWithMobileClose(viewId) {
handleNavigate(viewId) void handleNavigate(viewId)
mobileSidebarOpen.value = false mobileSidebarOpen.value = false
} }
function toggleWorkbenchMode() {
const nextMode = workbenchMode.value === 'ai' ? 'traditional' : 'ai'
if (nextMode === 'ai') {
sidebarCollapsedBeforeAiMode.value = sidebarCollapsed.value
workbenchMode.value = nextMode
sidebarCollapsed.value = false
return
}
workbenchMode.value = nextMode
sidebarCollapsed.value = sidebarCollapsedBeforeAiMode.value
}
const { const {
activeRange, activeRange,
activeView, activeView,
@@ -319,6 +369,8 @@ const { companyProfile, currentUser, logout } = useSystemState()
const PRODUCT_DISPLAY_NAME = '易财费控' const PRODUCT_DISPLAY_NAME = '易财费控'
const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司' const ENTERPRISE_DISPLAY_NAME = '远光软件股份有限公司'
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value)) const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
const isAiShellMode = computed(() => workbenchMode.value === 'ai')
const isWorkbenchAiMode = computed(() => activeView.value === 'workbench' && workbenchMode.value === 'ai')
const DETAIL_TOPBAR_FALLBACKS = { const DETAIL_TOPBAR_FALLBACKS = {
audit: { audit: {
title: '规则中心详情', title: '规则中心详情',
@@ -382,10 +434,82 @@ function openWorkbenchDocument(payload = {}) {
openRequestDetail(request || payload, { returnTo }) openRequestDetail(request || payload, { returnTo })
} }
function dispatchAiSidebarCommand(type, payload = null) {
aiSidebarCommandSeq.value += 1
aiSidebarCommand.value = {
seq: aiSidebarCommandSeq.value,
type,
payload
}
}
async function openAiConversationWorkspace(type, payload = null) {
if (activeView.value !== 'workbench') {
const navigation = handleNavigate('workbench')
if (navigation && typeof navigation.then === 'function') {
await navigation
}
await nextTick()
}
dispatchAiSidebarCommand(type, payload)
}
function openAiSidebarNewChat() {
aiActiveConversationId.value = ''
void openAiConversationWorkspace('new-chat')
}
function openAiSidebarRecent(item = {}) {
aiActiveConversationId.value = String(item.id || '').trim()
void openAiConversationWorkspace('open-recent', item)
}
function handleAiConversationChange(payload = {}) {
aiActiveConversationId.value = String(payload.id || '').trim()
}
function handleAiConversationHistoryChange(payload = []) {
aiConversationHistory.value = Array.isArray(payload) ? payload : []
}
function handleAiConversationRename(payload = {}) {
const conversationId = String(payload.id || '').trim()
const title = String(payload.title || '').trim()
if (!conversationId || !title) {
return
}
const target = aiConversationHistory.value.find((item) => String(item.id || '').trim() === conversationId)
if (!target) {
return
}
aiConversationHistory.value = saveAiWorkbenchConversation(currentUser.value || {}, {
...target,
title
})
if (aiActiveConversationId.value === conversationId) {
dispatchAiSidebarCommand('open-recent', {
...target,
title
})
}
}
function handleLogout() { function handleLogout() {
logout('manual') logout('manual')
} }
watch(
() => currentUser.value,
(user) => {
aiConversationHistory.value = loadAiWorkbenchConversationHistory(user || {})
},
{ immediate: true }
)
onMounted(() => { onMounted(() => {
playLoginEntryAnimation() playLoginEntryAnimation()
}) })
@@ -393,10 +517,4 @@ onMounted(() => {
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopLoginEntryAnimation() stopLoginEntryAnimation()
}) })
watch(activeView, (newView) => {
if (newView === 'workbench') {
sidebarCollapsed.value = true
}
}, { immediate: true })
</script> </script>

View File

@@ -1,20 +1,36 @@
<template> <template>
<PersonalWorkbench <Transition name="workbench-mode-fade" mode="out-in" appear>
:show-header="false" <PersonalWorkbenchAiMode
:assistant-modal-open="assistantModalOpen" v-if="workbenchMode === 'ai'"
:workbench-summary="workbenchSummary" key="ai"
@open-assistant="emit('open-assistant', $event)" :sidebar-command="aiSidebarCommand"
@open-document="emit('open-document', $event)" @conversation-change="emit('ai-conversation-change', $event)"
/> @conversation-history-change="emit('ai-conversation-history-change', $event)"
/>
<PersonalWorkbench
v-else
key="traditional"
:show-header="false"
:assistant-modal-open="assistantModalOpen"
:workbench-summary="workbenchSummary"
@open-assistant="emit('open-assistant', $event)"
@open-document="emit('open-document', $event)"
/>
</Transition>
</template> </template>
<script setup> <script setup>
import PersonalWorkbenchAiMode from '../components/business/PersonalWorkbenchAiMode.vue'
import PersonalWorkbench from '../components/business/PersonalWorkbench.vue' import PersonalWorkbench from '../components/business/PersonalWorkbench.vue'
defineProps({ defineProps({
assistantModalOpen: { type: Boolean, default: false }, assistantModalOpen: { type: Boolean, default: false },
workbenchSummary: { type: Object, default: () => ({}) } workbenchSummary: { type: Object, default: () => ({}) },
workbenchMode: { type: String, default: 'traditional' },
aiSidebarCommand: { type: Object, default: () => ({ seq: 0, type: '', payload: null }) }
}) })
const emit = defineEmits(['open-assistant', 'open-document']) const emit = defineEmits(['open-assistant', 'open-document', 'ai-conversation-change', 'ai-conversation-history-change'])
</script> </script>
<style scoped src="../assets/styles/views/personal-workbench-view.css"></style>

View File

@@ -24,7 +24,8 @@ import {
import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js' import { useApplicationPreviewEditor } from './useApplicationPreviewEditor.js'
import { import {
buildStewardFieldCompletionContinuation, buildStewardFieldCompletionContinuation,
buildStewardFieldCompletionRawText buildStewardFieldCompletionRawText,
resolveStewardRuntimeFieldCompletion
} from './stewardFieldCompletionModel.js' } from './stewardFieldCompletionModel.js'
import { import {
buildOperationFeedbackPayload, buildOperationFeedbackPayload,
@@ -169,8 +170,6 @@ import {
buildFileIdentity, buildFileIdentity,
buildFilePreviews, buildFilePreviews,
buildOcrDocumentsFromReviewPayload, buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments, buildOcrSummaryFromDocuments,
buildReviewFilePreviewsFromReviewPayload, buildReviewFilePreviewsFromReviewPayload,
extractReviewAttachmentNames, extractReviewAttachmentNames,
@@ -179,7 +178,6 @@ import {
mergeFilesWithLimit, mergeFilesWithLimit,
mergeUploadAttachmentNames, mergeUploadAttachmentNames,
mergeUploadOcrDocuments, mergeUploadOcrDocuments,
normalizeOcrDocuments,
resolveAttachmentPreviewKind, resolveAttachmentPreviewKind,
resolveDocumentPreview resolveDocumentPreview
} from './travelReimbursementAttachmentModel.js' } from './travelReimbursementAttachmentModel.js'
@@ -1121,8 +1119,6 @@ export default {
buildExpenseSceneSelectionMessage, buildExpenseSceneSelectionMessage,
buildMessageMeta, buildMessageMeta,
buildOcrDocumentsFromReviewPayload, buildOcrDocumentsFromReviewPayload,
buildOcrFilePreviews,
buildOcrSummary,
buildOcrSummaryFromDocuments, buildOcrSummaryFromDocuments,
buildReviewFormContextFromPayload, buildReviewFormContextFromPayload,
clearAttachedFiles, clearAttachedFiles,
@@ -1155,7 +1151,6 @@ export default {
messages, messages,
nextTick, nextTick,
normalizeExpenseQueryPayload, normalizeExpenseQueryPayload,
normalizeOcrDocuments,
persistSessionState, persistSessionState,
props, props,
recognizeOcrFiles, recognizeOcrFiles,
@@ -1904,6 +1899,10 @@ export default {
}) })
return return
} }
if (targetSessionType === SESSION_TYPE_EXPENSE && carryText === '我要报销') {
pushExpenseSceneSelectionPrompt(carryText)
return
}
if (String(actionPayload.steward_plan_id || '').trim()) { if (String(actionPayload.steward_plan_id || '').trim()) {
const confirmedByText = Boolean(action.confirmedByText) const confirmedByText = Boolean(action.confirmedByText)
delete action.confirmedByText delete action.confirmedByText
@@ -2141,6 +2140,9 @@ export default {
} }
function buildMessageBubbleClass(message) { function buildMessageBubbleClass(message) {
if (message?.role === 'assistant' && message?.assistantVariant === 'compact_guidance') {
return 'message-bubble-compact-guidance'
}
if (message?.role === 'assistant' && message?.budgetReport) { if (message?.role === 'assistant' && message?.budgetReport) {
return 'message-bubble-budget-report' return 'message-bubble-budget-report'
} }
@@ -2965,6 +2967,10 @@ export default {
: '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。' : '当前申请还有信息需要先补充。请先回复系统刚刚追问的内容,我再继续生成核对结果。'
} }
} }
const fieldCompletionDecision = resolveStewardRuntimeFieldCompletion(normalizedText, runtimeState)
if (fieldCompletionDecision) {
return fieldCompletionDecision
}
} }
return null return null
} }
@@ -3082,6 +3088,39 @@ export default {
}) })
return true return true
} }
if (nextAction === 'fill_current_application_field') {
const targetMessageId = String(decision?.target_message_id || decision?.targetMessageId || '').trim()
const targetMessage = targetMessageId
? messages.value.find((message) => String(message.id || '') === targetMessageId)
: findLatestApplicationPreviewMessage()
if (!targetMessage?.applicationPreview) {
return false
}
const fieldKey = String(decision?.field_key || decision?.fieldKey || '').trim()
const fieldLabel = String(decision?.field_label || decision?.fieldLabel || '').trim()
const fieldValue = String(decision?.field_value || decision?.fieldValue || rawText).trim()
if (!fieldKey || !fieldValue) {
return false
}
await continueStewardApplicationFieldCompletion({
targetMessage,
action: {
label: fieldValue,
suppressUserEcho: userMessageAlreadyAdded,
payload: {
steward_delegated_field_completion: true,
field_key: fieldKey,
field_label: fieldLabel,
value: fieldValue
}
},
sourcePreview: targetMessage.applicationPreview,
fieldKey,
fieldLabel,
value: fieldValue
})
return true
}
if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') { if (nextAction === 'ask_user' || nextAction === 'cancel_current_action' || nextAction === 'no_op') {
pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded }) pushStewardRuntimeResponse(rawText, decision, { userMessageAlreadyAdded })
return true return true

View File

@@ -1751,12 +1751,12 @@ export default {
const aiAdviceTitle = computed(() => { const aiAdviceTitle = computed(() => {
if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) { if (!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value) {
return '报销风险提示' return '风险提示'
} }
if (isEditableRequest.value && isApplicationDocument.value) { if (isEditableRequest.value && isApplicationDocument.value) {
return '表单自查提示' return '表单自查提示'
} }
return isEditableRequest.value ? 'AI建议' : 'AI提示' return isEditableRequest.value ? 'AI建议' : '风险提示'
}) })
const aiAdviceHint = computed(() => ( const aiAdviceHint = computed(() => (
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value !isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value

View File

@@ -24,6 +24,35 @@ const APPLICATION_PREVIEW_FIELD_LABEL_MAP = {
grade: '职级' grade: '职级'
} }
const STEWARD_RUNTIME_FIELD_COMPLETION_RULES = [
{ fieldKey: 'reason', fieldLabel: '事由', pattern: /事由|申请事由|出差事由|原因|用途/ },
{ fieldKey: 'transportMode', fieldLabel: '出行方式', pattern: /出行方式|交通方式|交通工具|出行工具/ },
{ fieldKey: 'time', fieldLabel: '申请时间', pattern: /申请时间|发生时间|业务发生时间|出发时间|返回时间|时间/ },
{ fieldKey: 'location', fieldLabel: '地点', pattern: /地点|业务地点|发生地点|目的地/ },
{ fieldKey: 'days', fieldLabel: '天数', pattern: /天数|出差天数|申请天数/ },
{ fieldKey: 'amount', fieldLabel: '系统预估费用', pattern: /系统预估费用|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额/ }
]
const APPLICATION_TYPE_DISPLAY_MAP = {
travel: '差旅费用申请',
travel_application: '差旅费用申请',
expense_application: '费用申请',
application: '费用申请',
transportation: '交通费用申请',
traffic: '交通费用申请',
transport: '交通费用申请',
accommodation: '住宿费用申请',
hotel: '住宿费用申请',
meeting: '会务费用申请',
conference: '会务费用申请',
purchase: '采购费用申请',
procurement: '采购费用申请',
training: '培训费用申请',
business_entertainment: '业务招待申请',
entertainment: '业务招待申请',
office: '办公费用申请'
}
function compactValue(value = '') { function compactValue(value = '') {
return String(value || '').trim() return String(value || '').trim()
} }
@@ -48,6 +77,22 @@ function resolveFieldValue(...candidates) {
return '' return ''
} }
function resolveApplicationTypeDisplay(value = '') {
const rawValue = compactValue(value)
if (!rawValue) return ''
const normalizedKey = rawValue.toLowerCase()
if (APPLICATION_TYPE_DISPLAY_MAP[normalizedKey]) {
return APPLICATION_TYPE_DISPLAY_MAP[normalizedKey]
}
if (/^(?:差旅费|差旅|出差)$/.test(rawValue)) return '差旅费用申请'
if (/^(?:交通费|交通)$/.test(rawValue)) return '交通费用申请'
if (/^(?:住宿费|住宿|酒店)$/.test(rawValue)) return '住宿费用申请'
if (/^(?:会务|会议|会务费)$/.test(rawValue)) return '会务费用申请'
if (/^(?:采购|采购费|办公用品)$/.test(rawValue)) return '采购费用申请'
return rawValue
}
function buildUpdatedTask(task = null, fieldKey = '', value = '') { function buildUpdatedTask(task = null, fieldKey = '', value = '') {
if (!task || typeof task !== 'object') { if (!task || typeof task !== 'object') {
return null return null
@@ -75,6 +120,29 @@ function buildUpdatedTask(task = null, fieldKey = '', value = '') {
} }
} }
function buildFieldCompletionScopeHints(fieldKey = '', selectedValue = '') {
const hints = [
'本轮是对当前申请单字段的补充/更新,不是新建申请或切换任务。'
]
if (fieldKey === 'reason') {
hints.push(
`请将“${compactValue(selectedValue)}”作为当前出差申请的事由继续处理,不要把它改判为新的 IT 部署申请。`
)
}
return hints
}
function resolveFieldRuleByKey(fieldKey = '') {
const normalizedKey = compactValue(fieldKey)
return STEWARD_RUNTIME_FIELD_COMPLETION_RULES.find((rule) => rule.fieldKey === normalizedKey) || null
}
function resolveFieldRuleByLabel(label = '') {
const normalizedLabel = compactValue(label)
if (!normalizedLabel) return null
return STEWARD_RUNTIME_FIELD_COMPLETION_RULES.find((rule) => rule.pattern.test(normalizedLabel)) || null
}
export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') { export function buildStewardFieldCompletionContinuation(continuation = null, fieldKey = '', value = '') {
const source = continuation && typeof continuation === 'object' ? continuation : {} const source = continuation && typeof continuation === 'object' ? continuation : {}
const currentTask = resolveStewardCurrentTask(source) const currentTask = resolveStewardCurrentTask(source)
@@ -89,6 +157,50 @@ export function buildStewardFieldCompletionContinuation(continuation = null, fie
} }
} }
export function resolveStewardRuntimeFieldCompletion(rawText = '', runtimeState = {}) {
const value = compactValue(rawText)
if (!value || compactValue(runtimeState?.waiting_for) !== 'application_field_completion') {
return null
}
const slotAction = runtimeState?.pending_slot_action || runtimeState?.pendingSlotAction || null
const slotPayload = slotAction?.payload && typeof slotAction.payload === 'object' ? slotAction.payload : {}
const slotFieldKey = compactValue(slotPayload.field_key || slotPayload.fieldKey || slotAction?.field_key || slotAction?.fieldKey)
const slotRule = resolveFieldRuleByKey(slotFieldKey)
if (slotRule) {
return {
next_action: 'fill_current_application_field',
target_message_id: compactValue(slotAction?.message_id || slotAction?.messageId),
field_key: slotRule.fieldKey,
field_label: slotRule.fieldLabel,
field_value: value
}
}
const pendingApplication = runtimeState?.pending_application || runtimeState?.pendingApplication || null
const missingFields = Array.isArray(pendingApplication?.missing_fields)
? pendingApplication.missing_fields
: Array.isArray(pendingApplication?.missingFields)
? pendingApplication.missingFields
: []
if (missingFields.length !== 1) {
return null
}
const rule = resolveFieldRuleByLabel(missingFields[0])
if (!rule) {
return null
}
return {
next_action: 'fill_current_application_field',
target_message_id: compactValue(pendingApplication?.message_id || pendingApplication?.messageId),
field_key: rule.fieldKey,
field_label: rule.fieldLabel,
field_value: value
}
}
export function buildStewardFieldCompletionRawText({ export function buildStewardFieldCompletionRawText({
preview = {}, preview = {},
fieldKey = '', fieldKey = '',
@@ -107,7 +219,12 @@ export function buildStewardFieldCompletionRawText({
: resolveFieldValue(fields.transportMode, ontologyFields.transport_mode) : resolveFieldValue(fields.transportMode, ontologyFields.transport_mode)
const knownLines = [ const knownLines = [
['申请类型', resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')], [
'申请类型',
resolveApplicationTypeDisplay(
resolveFieldValue(fields.applicationType, ontologyFields.expense_type, '差旅费用申请')
)
],
['时间', resolveFieldValue(fields.time, ontologyFields.time_range)], ['时间', resolveFieldValue(fields.time, ontologyFields.time_range)],
['地点', resolveFieldValue(fields.location, ontologyFields.location)], ['地点', resolveFieldValue(fields.location, ontologyFields.location)],
['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)], ['事由', resolveFieldValue(fields.reason, ontologyFields.reason, currentTask?.summary)],
@@ -120,6 +237,7 @@ export function buildStewardFieldCompletionRawText({
return [ return [
'小财管家继续执行申请单字段补齐。', '小财管家继续执行申请单字段补齐。',
`用户已补充:${selectedLabel}${selectedValue}`, `用户已补充:${selectedLabel}${selectedValue}`,
...buildFieldCompletionScopeHints(fieldKey, selectedValue),
currentTask?.summary ? `任务摘要:${currentTask.summary}` : '', currentTask?.summary ? `任务摘要:${currentTask.summary}` : '',
'', '',
'已识别信息:', '已识别信息:',

View File

@@ -99,6 +99,10 @@ const FIELD_VALUE_DISPLAY_CONFIG = {
} }
} }
const FLOW_EXPENSE_TYPE_LABELS = {
travel: '差旅费'
}
export function buildStewardPlanRequest({ export function buildStewardPlanRequest({
rawText = '', rawText = '',
files = [], files = [],
@@ -216,6 +220,10 @@ export function buildStewardPlanMessageText(plan) {
if (isPendingFlowConfirmationPlan(normalized)) { if (isPendingFlowConfirmationPlan(normalized)) {
return buildPendingFlowConfirmationMessageText(normalized) return buildPendingFlowConfirmationMessageText(normalized)
} }
const genericReimbursementTask = normalized.tasks.find((task) => isGenericReimbursementTask(task))
if (genericReimbursementTask && normalized.tasks.length === 1) {
return buildGenericReimbursementIntentMessageText(genericReimbursementTask)
}
const nextContext = resolveNextActionContext(normalized) const nextContext = resolveNextActionContext(normalized)
const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task) const orderedTasks = buildOrderedStewardTasks(normalized, nextContext?.task)
const taskLines = orderedTasks.map((task, index) => const taskLines = orderedTasks.map((task, index) =>
@@ -289,6 +297,42 @@ export function formatStewardOntologyFields(fields = {}, taskType = '') {
.join('') .join('')
} }
function buildStewardOntologyFieldRows(fields = {}, taskType = '') {
return Object.entries(fields || {})
.filter(([, value]) => String(value || '').trim())
.map(([key, value]) => {
const field = resolveFieldDisplay(key, taskType)
return {
label: field.label,
value: formatStewardFieldDisplayValue(field.key, value)
}
})
}
function escapeMarkdownTableCell(value) {
return String(value || '').replace(/\|/g, '\\|').replace(/\n+/g, ' ').trim()
}
function formatStewardOntologyFieldsTable(fields = {}, taskType = '') {
const rows = buildStewardOntologyFieldRows(fields, taskType)
if (!rows.length) {
return ''
}
return [
'| 字段 | 内容 |',
'| --- | --- |',
...rows.map((row) => `| ${escapeMarkdownTableCell(row.label)} | ${escapeMarkdownTableCell(row.value)} |`)
].join('\n')
}
function resolveCandidateFlowExpenseType(flow = {}) {
const rawType = String(flow?.ontologyFields?.expense_type || flow?.ontologyFields?.expenseType || '').trim()
if (rawType === '差旅' || rawType === 'travel') {
return 'travel'
}
return rawType
}
export function buildStewardSuggestedActions(plan) { export function buildStewardSuggestedActions(plan) {
const normalized = normalizeStewardPlan(plan) const normalized = normalizeStewardPlan(plan)
if (isOffTopicPlan(normalized)) { if (isOffTopicPlan(normalized)) {
@@ -304,26 +348,32 @@ export function buildStewardSuggestedActions(plan) {
})) }))
} }
if (isPendingFlowConfirmationPlan(normalized)) { if (isPendingFlowConfirmationPlan(normalized)) {
return normalized.candidateFlows.map((flow) => ({ return normalized.candidateFlows.map((flow) => {
label: flow.label, const expenseType = resolveCandidateFlowExpenseType(flow)
description: flow.reason || '选择后小财管家会继续整理对应流程材料。', return {
icon: flow.flowId === 'travel_application' label: flow.label,
? 'mdi mdi-file-plus-outline' description: flow.reason || '选择后小财管家会继续整理对应流程材料。',
: 'mdi mdi-receipt-text-plus-outline', icon: flow.flowId === 'travel_application'
action_type: ASSISTANT_SCOPE_ACTION_SWITCH, ? 'mdi mdi-file-plus-outline'
payload: { : 'mdi mdi-receipt-text-plus-outline',
steward_confirm_flow: true, action_type: ASSISTANT_SCOPE_ACTION_SWITCH,
steward_plan_id: normalized.planId, payload: {
flow_id: flow.flowId, steward_confirm_flow: true,
session_type: flow.flowId === 'travel_application' steward_plan_id: normalized.planId,
? SESSION_TYPE_APPLICATION flow_id: flow.flowId,
: SESSION_TYPE_EXPENSE, session_type: flow.flowId === 'travel_application'
selected_flow_label: flow.label, ? SESSION_TYPE_APPLICATION
carry_text: flow.label, : SESSION_TYPE_EXPENSE,
auto_submit: true, selected_flow_label: flow.label,
steward_state: normalized.stewardState || null expense_type: expenseType,
expense_type_label: FLOW_EXPENSE_TYPE_LABELS[expenseType] || '',
requires_application_before_reimbursement: flow.flowId === 'travel_reimbursement' && expenseType === 'travel',
carry_text: flow.flowId === 'travel_reimbursement' && expenseType === 'travel' ? '我要报销' : flow.label,
auto_submit: true,
steward_state: normalized.stewardState || null
}
} }
})) })
} }
const nextContext = resolveNextActionContext(normalized) const nextContext = resolveNextActionContext(normalized)
if (!nextContext) { if (!nextContext) {
@@ -335,7 +385,7 @@ export function buildStewardSuggestedActions(plan) {
: SESSION_TYPE_EXPENSE : SESSION_TYPE_EXPENSE
return [ return [
{ {
label: buildNextActionLabel(actionType), label: buildNextActionLabel(actionType, task),
description: buildNextActionDescription(actionType, normalized, task, group), description: buildNextActionDescription(actionType, normalized, task, group),
icon: actionType === 'confirm_create_application' icon: actionType === 'confirm_create_application'
? 'mdi mdi-file-plus-outline' ? 'mdi mdi-file-plus-outline'
@@ -411,40 +461,58 @@ export function isOffTopicStewardPlan(rawPlan) {
} }
function buildOffTopicMessageText(normalized) { function buildOffTopicMessageText(normalized) {
// off_topic 计划的引导文案完全由后端生成(含 ### 标题 + 正文 + 引导句),
// 前端透传 summary 即可,避免重复拼接导致与后端表达不一致。
const summary = String(normalized?.summary || '').trim() const summary = String(normalized?.summary || '').trim()
const summaryLine = summary && summary !== '这看起来跟财务任务没什么关系...' if (summary) {
? summary return summary
: '这看起来跟财务任务没什么关系,我目前只能帮你处理**费用申请**和**费用报销**两类事项。' }
return [ return (
'### 小财管家没看懂这件事', '### 这句话我暂时没识别到财务事项\n\n' +
'', '很抱歉主人,目前小财管家只能帮您整理**费用申请**和**费用报销**这两类事项。\n\n' +
summaryLine, '要不您换种说法告诉我:'
'', )
'你可以试试下面这些方式告诉我:'
].join('\n')
} }
function buildPendingFlowConfirmationMessageText(normalized) { function buildPendingFlowConfirmationMessageText(normalized) {
const fields = normalized.candidateFlows[0]?.ontologyFields || {} const fields = normalized.candidateFlows[0]?.ontologyFields || {}
const knownParts = formatStewardOntologyFields(fields, 'expense_application') const knownTable = formatStewardOntologyFieldsTable(fields, 'expense_application')
const candidateLines = normalized.candidateFlows.map((flow, index) => const candidateLines = normalized.candidateFlows.map((flow, index) =>
`${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}` `${index + 1}. **${flow.label}**${flow.reason ? `\n - ${flow.reason}` : ''}`
) )
const singleCandidate = normalized.candidateFlows.length === 1
return [ return [
'### 需要先确认流程方向', '### 需要先确认流程方向',
'', '',
knownParts knownTable
? `我识别到这是一项财务事项,已提取到:**${knownParts}**。` ? ['我识别到这是一项财务事项,已提取到:', '', knownTable].join('\n')
: '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。', : '我识别到这是一项财务事项,但还需要确认你要进入哪个流程。',
'', '',
normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。', normalized.pendingFlowConfirmation.reason || normalized.summary || '当前还不能确定你要补办申请还是发起报销。',
'', '',
...candidateLines, ...candidateLines,
'', '',
'请先选择一个方向,我会继续整理对应材料。' singleCandidate
? `请先点击下方 **${normalized.candidateFlows[0].label}**,我会继续整理对应材料。`
: '请先选择一个方向,我会继续整理对应材料。'
].filter((line, index, lines) => line || lines[index - 1]).join('\n') ].filter((line, index, lines) => line || lines[index - 1]).join('\n')
} }
function buildGenericReimbursementIntentMessageText() {
return [
'### 我来带你发起报销',
'',
'你现在只说了要报销,还没告诉我具体是哪类费用。先不用一次性补全所有信息,我会按报销流程一步步带你填。',
'',
'1. **先选报销场景**',
' - 例如差旅费、交通费、住宿费、业务招待费或办公用品费,不同场景需要的材料不一样。',
'2. **再补关键材料**',
' - 我会继续追问事由、发生时间、金额和票据附件;如果是差旅或招待,还会先帮你核对是否需要关联事前申请。',
'',
'点击下面的 **确定,选择报销场景**,我会进入报销助手继续引导。'
].join('\n')
}
function resolveNextActionContext(normalized) { function resolveNextActionContext(normalized) {
const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application') const applicationTask = normalized.tasks.find((task) => task.taskType === 'expense_application')
const applicationAction = applicationTask const applicationAction = applicationTask
@@ -566,6 +634,9 @@ function buildTaskOrderActionDescription(task) {
return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。` return `我会请${agent}先把申请单草稿整理出来,方便你核对关键信息,再决定是否继续。`
} }
if (task.taskType === 'reimbursement') { if (task.taskType === 'reimbursement') {
if (isGenericReimbursementTask(task)) {
return `我会请${agent}先带你选择报销场景,再逐步补齐事由、时间、金额和票据。`
}
return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。` return `我会请${agent}把票据、金额和制度口径先核清楚,前一步确认后再继续往下走。`
} }
return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。` return `我会请${agent}先整理可核对的结果,真正执行前仍会让你确认。`
@@ -603,13 +674,16 @@ function buildNextTaskLead(task) {
return `处理“${task.title || task.taskTypeLabel}` return `处理“${task.title || task.taskTypeLabel}`
} }
function buildNextActionLabel(actionType) { function buildNextActionLabel(actionType, task = null) {
if (actionType === 'confirm_create_application') { if (actionType === 'confirm_create_application') {
return '确定,先创建申请单' return '确定,先创建申请单'
} }
if (actionType === 'confirm_attachment_group') { if (actionType === 'confirm_attachment_group') {
return '确定,确认附件归集' return '确定,确认附件归集'
} }
if (isGenericReimbursementTask(task)) {
return '确定,选择报销场景'
}
return '确定,继续填写报销单' return '确定,继续填写报销单'
} }
@@ -627,7 +701,29 @@ function buildNextActionDescription(actionType, normalized, task, group) {
} }
return group?.attachmentNames?.length return group?.attachmentNames?.length
? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。` ? `报销助手会带入 ${group.attachmentNames.length} 份相关附件生成核对结果。`
: '报销助手会根据当前任务生成报销核对结果。' : isGenericReimbursementTask(task)
? '先进入报销助手选择具体费用类型,再按场景补齐事由、时间、金额和票据。'
: '报销助手会根据当前任务生成报销核对结果。'
}
function isGenericReimbursementTask(task) {
if (!task || task.taskType !== 'reimbursement') {
return false
}
const fields = task.ontologyFields || {}
const expenseType = String(fields.expense_type || '').trim()
const hasSpecificField = ['time_range', 'location', 'amount', 'attachments', 'transport_mode']
.some((key) => String(fields[key] || '').trim())
|| isSpecificReimbursementReason(fields.reason)
return !hasSpecificField && (!expenseType || expenseType === 'other')
}
function isSpecificReimbursementReason(value) {
const text = String(value || '').trim().replace(/\s+/g, '')
if (!text) {
return false
}
return !/^(?:我想要|我想|我要|还需要|需要|请帮我|帮我)?报销(?:费用|报销单|报销流程)?$/.test(text)
} }
function buildStewardCarryText(actionType, task, group, normalized = null) { function buildStewardCarryText(actionType, task, group, normalized = null) {
@@ -644,6 +740,9 @@ function buildStewardCarryText(actionType, task, group, normalized = null) {
if (!task) { if (!task) {
return '我确认继续处理这项财务任务,请按现有流程核对信息。' return '我确认继续处理这项财务任务,请按现有流程核对信息。'
} }
if (actionType === 'confirm_create_reimbursement_draft' && isGenericReimbursementTask(task)) {
return '我要报销'
}
const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType) const fields = formatStewardOntologyFields(task.ontologyFields || {}, task.taskType)
const missingFields = formatStewardMissingFieldList( const missingFields = formatStewardMissingFieldList(

View File

@@ -74,6 +74,10 @@ export function normalizeOcrDocuments(payload) {
preview_kind: String(item.preview_kind || '').trim(), preview_kind: String(item.preview_kind || '').trim(),
preview_data_url: String(item.preview_data_url || '').trim(), preview_data_url: String(item.preview_data_url || '').trim(),
preview_url: String(item.preview_url || '').trim(), preview_url: String(item.preview_url || '').trim(),
receipt_id: String(item.receipt_id || item.receiptId || '').trim(),
receipt_status: String(item.receipt_status || item.receiptStatus || '').trim(),
receipt_preview_url: String(item.receipt_preview_url || item.receiptPreviewUrl || '').trim(),
receipt_source_url: String(item.receipt_source_url || item.receiptSourceUrl || '').trim(),
document_fields: Array.isArray(item.document_fields) document_fields: Array.isArray(item.document_fields)
? item.document_fields ? item.document_fields
.map((field) => ({ .map((field) => ({
@@ -87,6 +91,87 @@ export function normalizeOcrDocuments(payload) {
})) }))
} }
function defineFileReceiptId(file, receiptId) {
const normalizedReceiptId = String(receiptId || '').trim()
if (!file || !normalizedReceiptId) {
return false
}
try {
Object.defineProperty(file, 'receiptId', {
value: normalizedReceiptId,
enumerable: false,
configurable: true
})
return true
} catch {
try {
file.receiptId = normalizedReceiptId
return String(file.receiptId || '').trim() === normalizedReceiptId
} catch {
return false
}
}
}
export function attachReceiptFolderIdsToFiles(files = [], payload = null) {
const safeFiles = Array.isArray(files) ? files : []
const documents = Array.isArray(payload?.documents) ? payload.documents : []
let attachedCount = 0
safeFiles.slice(0, documents.length).forEach((file, index) => {
const document = documents[index] || {}
const receiptId = String(document.receipt_id || document.receiptId || '').trim()
if (receiptId && defineFileReceiptId(file, receiptId)) {
attachedCount += 1
}
})
return attachedCount
}
export async function collectReceiptFiles({
files = [],
recognizedAttachmentData = null,
recognizeOcrFiles,
timeoutMs = 90000,
timeoutMessage = '票据 OCR 识别超时,已继续使用附件名称处理。'
} = {}) {
const safeFiles = Array.isArray(files) ? files : []
const reusedData = recognizedAttachmentData && typeof recognizedAttachmentData === 'object'
? recognizedAttachmentData
: null
if (reusedData) {
const ocrDocuments = Array.isArray(reusedData.ocrDocuments) ? [...reusedData.ocrDocuments] : []
const ocrPayload = reusedData.ocrPayload || { documents: ocrDocuments }
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: String(reusedData.ocrSummary || '').trim() || buildOcrSummaryFromDocuments(ocrDocuments),
ocrDocuments,
ocrFilePreviews: Array.isArray(reusedData.ocrFilePreviews) ? [...reusedData.ocrFilePreviews] : []
}
}
if (typeof recognizeOcrFiles !== 'function') {
throw new Error('票据采集服务未配置。')
}
const ocrPayload = await recognizeOcrFiles(safeFiles, {
timeoutMs,
timeoutMessage
})
attachReceiptFolderIdsToFiles(safeFiles, ocrPayload)
return {
ocrPayload,
ocrSummary: buildOcrSummary(ocrPayload),
ocrDocuments: normalizeOcrDocuments(ocrPayload),
ocrFilePreviews: buildOcrFilePreviews(ocrPayload)
}
}
export function buildOcrSummary(payload) { export function buildOcrSummary(payload) {
return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload)) return buildOcrSummaryFromDocuments(normalizeOcrDocuments(payload))
} }

Some files were not shown because too many files have changed in this diff Show More