Compare commits
10 Commits
43432534d8
...
3131112952
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3131112952 | ||
|
|
a2f67af13e | ||
|
|
0cde1f8990 | ||
|
|
a6674a1e76 | ||
|
|
127d603e7d | ||
|
|
3f17619e0c | ||
|
|
59ba76c74a | ||
|
|
35372c6661 | ||
|
|
38653fa365 | ||
|
|
c28e99b714 |
2
.gitignore
vendored
@@ -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
@@ -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 check:LLM 输出后用规则引擎反向校验
|
||||||
|
- 对话回复中的具体数字、日期强制引用证据片段(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 条规则分布:
|
||||||
|
- 差旅(travel):13 条
|
||||||
|
- 预算(budget):13 条
|
||||||
|
- 申请(application):5 条
|
||||||
|
- 报销(reimbursement):7 条
|
||||||
|
- 标准(standard):5 条
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
- **招待费、市场推广、培训费、福利费、软件服务费几乎没有专门规则**
|
||||||
|
- 缺少供应商关联方交易、连号发票重复报销、跨年度重复报销检测
|
||||||
|
- 这些恰是真实费控场景最易出问题的领域
|
||||||
|
|
||||||
|
**改进方向**:
|
||||||
|
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 |
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,6 +29,10 @@ from app.services.hermes_sync import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_ADMIN_ACCOUNT = "admin"
|
||||||
|
DEFAULT_ADMIN_PASSWORD = "admin"
|
||||||
|
LEGACY_DEFAULT_ADMIN_ACCOUNTS = {"", "superadmin"}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ModelSlotConfig:
|
class ModelSlotConfig:
|
||||||
@@ -106,14 +111,27 @@ class OnlyOfficeRuntimeConfig:
|
|||||||
|
|
||||||
|
|
||||||
class SettingsService:
|
class SettingsService:
|
||||||
|
_schema_ready_lock = threading.Lock()
|
||||||
|
_schema_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 = SettingsRepository(db)
|
self.repository = SettingsRepository(db)
|
||||||
self.runtime_settings = get_settings()
|
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()
|
||||||
@@ -133,9 +151,14 @@ class SettingsService:
|
|||||||
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,
|
||||||
|
|||||||
157
server/src/app/services/steward_off_topic_agent.py
Normal 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"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.4 MiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 150 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 1.5 MiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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.退房时间为中午12:00,超时退房将按酒店规定收取相关费用。\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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 150 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 134 KiB |
@@ -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 == []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1115,6 +1115,21 @@ def test_parse_ontology_endpoint_returns_eight_fields_and_writes_trace() -> None
|
|||||||
assert run_payload["semantic_parse"]["intent"] == "risk_check"
|
assert run_payload["semantic_parse"]["intent"] == "risk_check"
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ontology_endpoint_blocks_non_business_input() -> None:
|
||||||
|
client, _ = build_client()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/ontology/parse",
|
||||||
|
json={
|
||||||
|
"query": "你好",
|
||||||
|
"user_id": "pytest",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "财务业务相关问题" in response.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
def test_parse_ontology_endpoint_returns_forbidden_for_unprivileged_payment_request() -> None:
|
def test_parse_ontology_endpoint_returns_forbidden_for_unprivileged_payment_request() -> None:
|
||||||
client, _ = build_client()
|
client, _ = build_client()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
70
server/tests/test_startup_bootstrap.py
Normal 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]
|
||||||
@@ -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="我要报销昨天的交通费",
|
||||||
|
|||||||
@@ -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
|
After Width: | Height: | Size: 1.6 MiB |
@@ -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;
|
||||||
|
|||||||
676
web/src/assets/styles/components/ai-sidebar-rail.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1454
web/src/assets/styles/components/personal-workbench-ai-mode.css
Normal 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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
40
web/src/assets/styles/views/personal-workbench-view.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
web/src/assets/workbench-ai-mode-orb-icon.gif
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
BIN
web/src/assets/workbench-ai-mode-orb-icon.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
BIN
web/src/assets/workbench-ai-mode-robot-bg.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
@@ -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>
|
||||||
|
|||||||
1666
web/src/components/business/PersonalWorkbenchAiMode.vue
Normal 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>
|
||||||
|
|||||||
321
web/src/components/layout/AiSidebarRail.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -282,6 +283,17 @@
|
|||||||
<span>{{ displayCompanyName }}</span>
|
<span>{{ displayCompanyName }}</span>
|
||||||
<i class="mdi mdi-chevron-down"></i>
|
<i class="mdi mdi-chevron-down"></i>
|
||||||
</button>
|
</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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -354,6 +366,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<div v-if="showAiModeUtilityActions" class="topbar-utility-actions" aria-label="AI模式快捷操作">
|
||||||
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
@@ -398,6 +428,10 @@ const props = defineProps({
|
|||||||
type: Object,
|
type: Object,
|
||||||
default: () => null
|
default: () => null
|
||||||
},
|
},
|
||||||
|
workbenchMode: {
|
||||||
|
type: String,
|
||||||
|
default: 'traditional'
|
||||||
|
},
|
||||||
companyName: {
|
companyName: {
|
||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
@@ -433,7 +467,8 @@ const emit = defineEmits([
|
|||||||
'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')
|
||||||
@@ -450,6 +485,10 @@ const eyebrowLabel = computed(() => (
|
|||||||
|| (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,
|
||||||
@@ -581,6 +620,10 @@ const topbarNotificationCount = computed(() => {
|
|||||||
return count > 0 ? Math.min(count, 99) : 0
|
return count > 0 ? Math.min(count, 99) : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function toggleTopbarWorkbenchMode() {
|
||||||
|
emit('toggleWorkbenchMode')
|
||||||
|
}
|
||||||
|
|
||||||
function clearDocumentInboxInitialRefreshTimer() {
|
function clearDocumentInboxInitialRefreshTimer() {
|
||||||
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
|
||||||
window.clearTimeout(documentInboxInitialRefreshTimer)
|
window.clearTimeout(documentInboxInitialRefreshTimer)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
79
web/src/utils/aiApplicationDraftModel.js
Normal 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')
|
||||||
|
}
|
||||||
113
web/src/utils/aiExpenseDraftModel.js
Normal 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')
|
||||||
|
}
|
||||||
81
web/src/utils/aiSidebarBusinessAccess.js
Normal 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)
|
||||||
|
}
|
||||||
155
web/src/utils/aiWorkbenchConversationStore.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}` : '',
|
||||||
'',
|
'',
|
||||||
'已识别信息:',
|
'已识别信息:',
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||