Compare commits
26 Commits
codex/opti
...
v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e080105f9f | ||
|
|
64cc76c970 | ||
|
|
99e90798d2 | ||
|
|
064eeb614f | ||
|
|
b383244a29 | ||
|
|
e384318046 | ||
|
|
8a4a777be7 | ||
|
|
04cd6d0f81 | ||
|
|
d4d5d40569 | ||
|
|
cbb98f4469 | ||
|
|
7d32eae74e | ||
|
|
b1a9c8a194 | ||
|
|
2dcc72102d | ||
|
|
df49103f23 | ||
|
|
e7bef0883d | ||
|
|
e1e515ecae | ||
|
|
0e861d8fa6 | ||
|
|
d0e946cf47 | ||
|
|
50b1c3f9a9 | ||
|
|
575f093c74 | ||
|
|
5b388d08c0 | ||
|
|
88ff04bef8 | ||
|
|
1f15699013 | ||
|
|
222ba0bfdc | ||
|
|
2e57702638 | ||
|
|
5fe3b201d9 |
1
.tmp/Yuxi
Submodule
39
AGENTS.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# X-Financial Agent 协作规范
|
||||||
|
|
||||||
|
## 语言规范
|
||||||
|
|
||||||
|
- 所有分析、解释、计划、提交说明和最终回复默认使用简体中文。
|
||||||
|
- 技术结论要直击重点,必要时给出可验证的文件、命令或测试结果。
|
||||||
|
|
||||||
|
## 通用代码拆分规范
|
||||||
|
|
||||||
|
无论写前端、后端还是算法代码,都必须主动避免“所有方法堆在一个类里 / 一个组件里 / 一个模块里”的写法。遇到类、组件或核心模块持续变大时,优先按职责拆分,而不是继续追加方法和状态。
|
||||||
|
|
||||||
|
### 行数与复杂度目标
|
||||||
|
|
||||||
|
- 单个类、核心组件、核心算法模块硬上限为 800 行。
|
||||||
|
- 普通文件建议控制在 300-600 行。
|
||||||
|
- 复杂业务文件可以接近 800 行,但必须有清晰职责边界。
|
||||||
|
- 文件或类超过 800 行必须视为重构预警,不应继续直接追加功能。
|
||||||
|
- 单个类不应长期承载几十个无关方法,更不应演化成上百个方法的万能类。
|
||||||
|
|
||||||
|
### 拆分原则
|
||||||
|
|
||||||
|
- 对外 API 尽量保持稳定,先把内部实现拆到小模块。
|
||||||
|
- 按职责拆分:编排、状态管理、持久化、权限、文件存储、OCR/票据分析、规则审核、响应构建、序列化、UI 交互、算法策略、数据转换。
|
||||||
|
- 新增能力时先判断归属模块;没有合适归属时新增小模块,不要默认塞回主类、主组件或主 Service。
|
||||||
|
- 拆分必须小步进行,每次提取一个明确职责,并配套运行相关测试。
|
||||||
|
|
||||||
|
### X-Financial 重点关注对象
|
||||||
|
|
||||||
|
- `ExpenseClaimService`:优先拆分申请单、明细项、附件、票据分析、草稿、规则审核、权限、序列化。
|
||||||
|
- `UserAgentService`:优先拆分知识库问答、报销预审 payload、Markdown 回复、差旅政策、表单槽位、票据分类、建议动作。
|
||||||
|
- `OrchestratorService`:优先拆分 agent 路由、工具调用、报销查询、响应构建。
|
||||||
|
- 前端大型 Vue 页面:优先拆分 composable、view model、样式分片、业务工具函数和子组件。
|
||||||
|
- 算法/规则模块:优先拆分输入解析、规则匹配、评分策略、结果解释和异常处理。
|
||||||
|
|
||||||
|
## 验证规范
|
||||||
|
|
||||||
|
- 后端改动优先在 Docker 容器 `x-financial-main` 中运行验证。
|
||||||
|
- 单元测试设置合理超时,避免长时间卡死。
|
||||||
|
- 每次重构后至少运行对应服务的定向测试;涉及公共协议时补充端到端或接口测试。
|
||||||
60
document/development/budget-center/MASTER_TODO.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 预算中心 MASTER TODO
|
||||||
|
|
||||||
|
## 总目标
|
||||||
|
|
||||||
|
把预算从首页静态展示升级为真实费控底座,让费用申请和报销都必须经过预算口径校验。
|
||||||
|
|
||||||
|
## 状态图
|
||||||
|
|
||||||
|
```text
|
||||||
|
预算额度
|
||||||
|
-> 申请提交: 预占
|
||||||
|
-> 申请退回/撤回/驳回: 释放
|
||||||
|
-> 申请通过: 保持预占
|
||||||
|
-> 报销提交: 校验申请与预算
|
||||||
|
-> 报销审批通过: 核销
|
||||||
|
-> 报销退回/撤回: 释放或回滚
|
||||||
|
```
|
||||||
|
|
||||||
|
## 总 TODO
|
||||||
|
|
||||||
|
- [ ] 新增预算中心开发文档并纳入每日核对。
|
||||||
|
- [ ] 定义预算维度:部门、成本中心、项目、费用科目、期间。
|
||||||
|
- [ ] 定义预算模型:预算额度、预算交易、预算占用。
|
||||||
|
- [ ] 定义预算状态:正常、预警、超预算、冻结。
|
||||||
|
- [ ] 定义预算交易类型:初始化、调整、预占、释放、核销、回滚。
|
||||||
|
- [ ] 新增预算列表接口。
|
||||||
|
- [ ] 新增预算详情接口。
|
||||||
|
- [ ] 新增预算台账接口。
|
||||||
|
- [ ] 新增预算占用接口或内部服务。
|
||||||
|
- [ ] 新增预算释放接口或内部服务。
|
||||||
|
- [ ] 新增预算核销接口或内部服务。
|
||||||
|
- [ ] 费用申请提交时写入预算预占。
|
||||||
|
- [ ] 费用申请驳回、撤回、取消时释放预算。
|
||||||
|
- [ ] 费用申请转报销时保留预算来源。
|
||||||
|
- [ ] 报销提交时校验预算归属和可用余额。
|
||||||
|
- [ ] 报销审批通过时核销预算。
|
||||||
|
- [ ] 报销退回时回滚预算状态。
|
||||||
|
- [ ] 报销详情展示预算占用和核销信息。
|
||||||
|
- [ ] 申请详情展示预算占用和剩余额度。
|
||||||
|
- [ ] 预算中心页面展示执行率、已占用、已核销、可用余额。
|
||||||
|
- [ ] 预算台账展示每笔来源单据和交易类型。
|
||||||
|
- [ ] 首页预算执行率改为后端真实数据。
|
||||||
|
- [ ] 本体识别支持预算维度字段。
|
||||||
|
- [ ] AI对话能解释预算不足、预算归属缺失、超预算原因。
|
||||||
|
- [ ] 添加后端单元测试。
|
||||||
|
- [ ] 添加前端预算视图测试。
|
||||||
|
- [ ] 添加申请到报销的端到端预算验收场景。
|
||||||
|
|
||||||
|
## 验收场景
|
||||||
|
|
||||||
|
- [ ] 有预算时,费用申请提交成功并预占预算。
|
||||||
|
- [ ] 预算不足时,申请提交被阻断或进入超预算复核。
|
||||||
|
- [ ] 申请驳回后,预算预占被释放。
|
||||||
|
- [ ] 申请审批通过后,预算仍保持预占。
|
||||||
|
- [ ] 申请转报销后,报销单继承预算来源。
|
||||||
|
- [ ] 报销审批通过后,预算从预占转为核销。
|
||||||
|
- [ ] 报销退回后,预算核销回滚。
|
||||||
|
- [ ] 预算中心能看到完整交易台账。
|
||||||
|
- [ ] 首页预算执行率与预算中心汇总一致。
|
||||||
|
|
||||||
58
document/development/budget-center/README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 预算中心开发总览
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
预算中心先作为费控平台的前置底座建设,优先打通:
|
||||||
|
|
||||||
|
```text
|
||||||
|
预算编制 -> 预算可用额度 -> 费用申请预占 -> 报销核销 -> 释放/调整 -> 预算看板
|
||||||
|
```
|
||||||
|
|
||||||
|
第一版不追求完整预算编制系统,而是先让申请、报销、审批、付款、归档都有真实预算口径。
|
||||||
|
|
||||||
|
## 当前项目基础
|
||||||
|
|
||||||
|
- 员工和组织已有 `cost_center` 成本中心字段,可作为预算归属维度。
|
||||||
|
- 报销单已有部门、项目、费用类型、金额、状态等字段,可接入预算核销。
|
||||||
|
- 首页已有静态预算执行率展示,但还不是后端真实预算数据。
|
||||||
|
- 费用申请已有前端意图识别和申请草稿痕迹,但预算占用还没有真实台账。
|
||||||
|
|
||||||
|
## 第一版预算中心范围
|
||||||
|
|
||||||
|
必须做:
|
||||||
|
|
||||||
|
- 预算主体:部门、成本中心、项目、费用科目。
|
||||||
|
- 预算期间:月度、季度、年度。
|
||||||
|
- 预算额度:总额、已占用、已核销、已释放、可用余额。
|
||||||
|
- 预算台账:每一次占用、核销、释放、调整都落账。
|
||||||
|
- 申请联动:费用申请提交时预占预算,驳回/撤回时释放。
|
||||||
|
- 报销联动:报销提交或审批通过时核销预算。
|
||||||
|
- 风险拦截:预算不足、超预算、缺预算归属时阻断或进入复核。
|
||||||
|
- 预算中心页面:列表、详情、台账、执行率、异常预算。
|
||||||
|
|
||||||
|
暂缓:
|
||||||
|
|
||||||
|
- 完整预算编制审批流。
|
||||||
|
- 多版本预算测算。
|
||||||
|
- 外部 ERP 预算接口。
|
||||||
|
- 真正多币种预算。
|
||||||
|
- 复杂滚动预算和预测模型。
|
||||||
|
|
||||||
|
## 关键原则
|
||||||
|
|
||||||
|
- 预算中心是独立业务域,不塞进报销 Service。
|
||||||
|
- 所有预算变化必须通过预算交易台账记录。
|
||||||
|
- 不直接改写已用金额,必须由交易汇总得到。
|
||||||
|
- 申请、报销、付款只是预算事件来源。
|
||||||
|
- 预算不足的判断必须来自后端,不依赖前端显示。
|
||||||
|
|
||||||
|
## 7天开发路径
|
||||||
|
|
||||||
|
- Day 1:预算模型、状态机、接口契约。
|
||||||
|
- Day 2:预算中心页面、列表、详情、台账视图。
|
||||||
|
- Day 3:预算占用/释放/核销服务。
|
||||||
|
- Day 4:费用申请与报销联动预算。
|
||||||
|
- Day 5:审批、付款、归档中的预算状态传递。
|
||||||
|
- Day 6:预算看板、本体识别、AI提示。
|
||||||
|
- Day 7:端到端验收、演示数据、测试补齐。
|
||||||
|
|
||||||
1179
document/development/budget-center/budget-center-ui-prototype.html
Normal file
105
document/development/budget-center/day_1_budget_foundation.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Day 1 - 预算模型与接口契约
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
先把预算中心的数据边界和接口边界定稳,避免后续把预算逻辑散落在申请、报销、审批和付款模块里。
|
||||||
|
|
||||||
|
## 开发任务
|
||||||
|
|
||||||
|
- [ ] 新增预算模型设计。
|
||||||
|
- [ ] 新增预算交易台账设计。
|
||||||
|
- [ ] 新增预算服务边界设计。
|
||||||
|
- [ ] 新增预算接口契约。
|
||||||
|
- [ ] 新增预算状态与交易类型常量。
|
||||||
|
- [ ] 明确申请、报销、付款对预算服务的调用点。
|
||||||
|
|
||||||
|
## 建议模型
|
||||||
|
|
||||||
|
预算额度:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BudgetAllocation
|
||||||
|
- id
|
||||||
|
- budget_no
|
||||||
|
- fiscal_year
|
||||||
|
- period_type
|
||||||
|
- period_key
|
||||||
|
- department_id
|
||||||
|
- department_name
|
||||||
|
- cost_center
|
||||||
|
- project_code
|
||||||
|
- subject_code
|
||||||
|
- subject_name
|
||||||
|
- original_amount
|
||||||
|
- adjusted_amount
|
||||||
|
- status
|
||||||
|
- warning_threshold
|
||||||
|
- created_at
|
||||||
|
- updated_at
|
||||||
|
```
|
||||||
|
|
||||||
|
预算交易:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BudgetTransaction
|
||||||
|
- id
|
||||||
|
- transaction_no
|
||||||
|
- allocation_id
|
||||||
|
- source_type
|
||||||
|
- source_id
|
||||||
|
- source_no
|
||||||
|
- transaction_type
|
||||||
|
- amount
|
||||||
|
- before_available_amount
|
||||||
|
- after_available_amount
|
||||||
|
- operator
|
||||||
|
- reason
|
||||||
|
- created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
交易类型:
|
||||||
|
|
||||||
|
```text
|
||||||
|
init 初始化
|
||||||
|
adjust 调整
|
||||||
|
reserve 预占
|
||||||
|
release 释放
|
||||||
|
consume 核销
|
||||||
|
rollback 回滚
|
||||||
|
freeze 冻结
|
||||||
|
unfreeze 解冻
|
||||||
|
```
|
||||||
|
|
||||||
|
预算汇总字段由交易汇总得到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
total_amount = original_amount + adjusted_amount
|
||||||
|
reserved_amount = reserve - release
|
||||||
|
consumed_amount = consume - rollback
|
||||||
|
available_amount = total_amount - reserved_amount - consumed_amount
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口契约
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/budgets/allocations
|
||||||
|
POST /api/v1/budgets/allocations
|
||||||
|
GET /api/v1/budgets/allocations/{id}
|
||||||
|
GET /api/v1/budgets/allocations/{id}/transactions
|
||||||
|
POST /api/v1/budgets/allocations/{id}/adjust
|
||||||
|
POST /api/v1/budgets/check
|
||||||
|
POST /api/v1/budgets/reserve
|
||||||
|
POST /api/v1/budgets/release
|
||||||
|
POST /api/v1/budgets/consume
|
||||||
|
POST /api/v1/budgets/rollback
|
||||||
|
GET /api/v1/budgets/summary
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 能创建一条部门月度预算。
|
||||||
|
- [ ] 能查询预算列表和详情。
|
||||||
|
- [ ] 能查询预算台账。
|
||||||
|
- [ ] 能根据部门、成本中心、项目、费用科目定位预算。
|
||||||
|
- [ ] 预算不足时 `check` 接口能返回明确原因。
|
||||||
|
|
||||||
82
document/development/budget-center/day_2_budget_center_ui.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Day 2 - 预算中心页面
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
新增预算中心作为独立主菜单,让预算不再只是首页指标,而是可操作、可追踪、可解释的费控入口。
|
||||||
|
|
||||||
|
## 页面入口
|
||||||
|
|
||||||
|
主菜单建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
费用申请
|
||||||
|
报销中心
|
||||||
|
审批中心
|
||||||
|
预算中心
|
||||||
|
付款中心
|
||||||
|
归档中心
|
||||||
|
经营分析
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
顶部指标:
|
||||||
|
|
||||||
|
- 预算总额
|
||||||
|
- 已预占
|
||||||
|
- 已核销
|
||||||
|
- 可用余额
|
||||||
|
- 超预算事项
|
||||||
|
- 预警预算数
|
||||||
|
|
||||||
|
列表字段:
|
||||||
|
|
||||||
|
- 预算编号
|
||||||
|
- 预算期间
|
||||||
|
- 部门
|
||||||
|
- 成本中心
|
||||||
|
- 项目
|
||||||
|
- 费用科目
|
||||||
|
- 预算总额
|
||||||
|
- 已预占
|
||||||
|
- 已核销
|
||||||
|
- 可用余额
|
||||||
|
- 执行率
|
||||||
|
- 状态
|
||||||
|
|
||||||
|
筛选条件:
|
||||||
|
|
||||||
|
- 年度
|
||||||
|
- 月份/季度
|
||||||
|
- 部门
|
||||||
|
- 成本中心
|
||||||
|
- 项目
|
||||||
|
- 费用科目
|
||||||
|
- 状态
|
||||||
|
|
||||||
|
详情页:
|
||||||
|
|
||||||
|
- 基本信息
|
||||||
|
- 执行概览
|
||||||
|
- 来源单据
|
||||||
|
- 交易台账
|
||||||
|
- 风险提示
|
||||||
|
- 调整记录
|
||||||
|
|
||||||
|
## 操作
|
||||||
|
|
||||||
|
- 新增预算
|
||||||
|
- 调整预算
|
||||||
|
- 冻结预算
|
||||||
|
- 查看台账
|
||||||
|
- 查看关联申请
|
||||||
|
- 查看关联报销
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 预算中心能从主菜单进入。
|
||||||
|
- [ ] 列表能展示后端预算数据。
|
||||||
|
- [ ] 点击预算能进入详情。
|
||||||
|
- [ ] 详情能展示交易台账。
|
||||||
|
- [ ] 首页预算执行率能跳转到预算中心。
|
||||||
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Day 3 - 预算占用、释放、核销服务
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
把预算变化统一收敛到预算服务,申请、报销、付款都只能通过预算服务改变预算状态。
|
||||||
|
|
||||||
|
## 服务能力
|
||||||
|
|
||||||
|
预算检查:
|
||||||
|
|
||||||
|
- 校验预算归属是否存在。
|
||||||
|
- 校验预算是否被冻结。
|
||||||
|
- 校验可用余额是否充足。
|
||||||
|
- 返回超预算金额和处理建议。
|
||||||
|
|
||||||
|
预算预占:
|
||||||
|
|
||||||
|
- 用于费用申请提交。
|
||||||
|
- 写入 `reserve` 交易。
|
||||||
|
- 记录来源单据。
|
||||||
|
|
||||||
|
预算释放:
|
||||||
|
|
||||||
|
- 用于申请撤回、退回、驳回、取消。
|
||||||
|
- 写入 `release` 交易。
|
||||||
|
- 必须找到原始预占来源。
|
||||||
|
|
||||||
|
预算核销:
|
||||||
|
|
||||||
|
- 用于报销审批通过。
|
||||||
|
- 写入 `consume` 交易。
|
||||||
|
- 如果来源申请已有预占,应先释放预占或转换为核销,不能重复占用。
|
||||||
|
|
||||||
|
预算回滚:
|
||||||
|
|
||||||
|
- 用于报销退回或撤销审批。
|
||||||
|
- 写入 `rollback` 交易。
|
||||||
|
|
||||||
|
## 关键防错
|
||||||
|
|
||||||
|
- 同一来源单据不能重复预占。
|
||||||
|
- 同一报销单不能重复核销。
|
||||||
|
- 释放金额不能超过原预占金额。
|
||||||
|
- 核销金额不能超过可用余额加当前来源预占余额。
|
||||||
|
- 所有预算交易必须有来源单据和操作人。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 预算预占后可用余额减少。
|
||||||
|
- [ ] 预算释放后可用余额恢复。
|
||||||
|
- [ ] 预算核销后已核销金额增加。
|
||||||
|
- [ ] 重复预占会被阻断。
|
||||||
|
- [ ] 重复核销会被阻断。
|
||||||
|
- [ ] 台账能解释每一次余额变化。
|
||||||
|
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Day 4 - 费用申请与报销联动预算
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让预算成为申请和报销之间的硬约束,先申请、再占用、再报销、再核销。
|
||||||
|
|
||||||
|
## 费用申请联动
|
||||||
|
|
||||||
|
提交申请时:
|
||||||
|
|
||||||
|
- 根据申请人部门、成本中心、项目、费用科目定位预算。
|
||||||
|
- 预算充足则预占。
|
||||||
|
- 预算不足则阻断或进入超预算复核。
|
||||||
|
- 申请详情展示预算占用结果。
|
||||||
|
|
||||||
|
申请退回/驳回/撤回时:
|
||||||
|
|
||||||
|
- 释放对应预算预占。
|
||||||
|
- 记录释放原因。
|
||||||
|
|
||||||
|
申请审批通过时:
|
||||||
|
|
||||||
|
- 保持预算预占。
|
||||||
|
- 允许转报销。
|
||||||
|
|
||||||
|
申请转报销时:
|
||||||
|
|
||||||
|
- 报销单继承申请预算来源。
|
||||||
|
- 报销金额默认不超过申请金额。
|
||||||
|
- 超过申请金额时进入风险提示或复核。
|
||||||
|
|
||||||
|
## 报销联动
|
||||||
|
|
||||||
|
报销提交时:
|
||||||
|
|
||||||
|
- 校验是否需要事前申请。
|
||||||
|
- 校验是否有关联已通过申请。
|
||||||
|
- 校验预算来源是否存在。
|
||||||
|
|
||||||
|
报销审批通过时:
|
||||||
|
|
||||||
|
- 将预算预占转为核销。
|
||||||
|
- 记录核销台账。
|
||||||
|
|
||||||
|
报销退回时:
|
||||||
|
|
||||||
|
- 回滚核销。
|
||||||
|
- 视状态保留或释放预占。
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 有预算的申请提交后形成预占。
|
||||||
|
- [ ] 预算不足的申请不能直接提交。
|
||||||
|
- [ ] 驳回申请释放预算。
|
||||||
|
- [ ] 已通过申请能转报销。
|
||||||
|
- [ ] 报销审批通过后预算转核销。
|
||||||
|
- [ ] 未关联预算的报销不能绕过预算校验。
|
||||||
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Day 5 - 审批、付款、归档中的预算口径
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
预算信息不能只停留在申请和报销页面,还要贯穿审批、付款和归档。
|
||||||
|
|
||||||
|
## 审批中心
|
||||||
|
|
||||||
|
审批列表增加预算提示:
|
||||||
|
|
||||||
|
- 是否预算内
|
||||||
|
- 是否超预算
|
||||||
|
- 已预占金额
|
||||||
|
- 可用余额
|
||||||
|
- 预算归属
|
||||||
|
|
||||||
|
审批详情增加预算区块:
|
||||||
|
|
||||||
|
- 当前单据金额
|
||||||
|
- 对应预算额度
|
||||||
|
- 已预占
|
||||||
|
- 已核销
|
||||||
|
- 剩余额度
|
||||||
|
- 超预算原因
|
||||||
|
|
||||||
|
## 付款中心预留
|
||||||
|
|
||||||
|
第一版付款中心可以暂缓实现完整页面,但预算中心需要为付款预留:
|
||||||
|
|
||||||
|
- 付款来源单据
|
||||||
|
- 付款金额
|
||||||
|
- 付款状态
|
||||||
|
- 是否已核销预算
|
||||||
|
- 是否存在预算异常
|
||||||
|
|
||||||
|
## 归档中心
|
||||||
|
|
||||||
|
归档包需要包含预算信息:
|
||||||
|
|
||||||
|
- 预算归属
|
||||||
|
- 预算交易流水
|
||||||
|
- 申请预占记录
|
||||||
|
- 报销核销记录
|
||||||
|
- 超预算审批意见
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 审批人能看到预算是否充足。
|
||||||
|
- [ ] 超预算审批能看到超额金额。
|
||||||
|
- [ ] 归档详情能看到预算台账摘要。
|
||||||
|
- [ ] 预算异常不会在付款/归档阶段丢失。
|
||||||
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Day 6 - 预算看板、本体识别与AI解释
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
让预算中心不只是数据表,还能被 AI 对话、本体识别和经营分析调用。
|
||||||
|
|
||||||
|
## 看板指标
|
||||||
|
|
||||||
|
- 本月预算总额
|
||||||
|
- 本月已预占
|
||||||
|
- 本月已核销
|
||||||
|
- 本月可用余额
|
||||||
|
- 部门预算排行
|
||||||
|
- 费用科目执行率
|
||||||
|
- 超预算事项数量
|
||||||
|
- 预算预警事项数量
|
||||||
|
|
||||||
|
## 本体字段
|
||||||
|
|
||||||
|
新增或强化字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cost_center
|
||||||
|
project_code
|
||||||
|
budget_subject
|
||||||
|
budget_period
|
||||||
|
budget_amount
|
||||||
|
available_amount
|
||||||
|
reserved_amount
|
||||||
|
consumed_amount
|
||||||
|
over_budget
|
||||||
|
budget_warning
|
||||||
|
```
|
||||||
|
|
||||||
|
## 预算字段设计
|
||||||
|
|
||||||
|
预算中心字段分为四层,前端弹窗、预算台账、后端本体解析都必须使用同一套语义键。
|
||||||
|
|
||||||
|
### 预算主信息
|
||||||
|
|
||||||
|
- `budget_period`:预算周期,支持年度、季度、月份。
|
||||||
|
- `department`:所属部门,来自真实组织/部门数据。
|
||||||
|
- `cost_center`:成本中心,跟随部门归属。
|
||||||
|
- `budget_owner`:预算负责人。
|
||||||
|
- `budget_version`:预算版本,例如 `V1.0(初始版本)`。
|
||||||
|
- `budget_status`:预算状态,第一版限定为 `编制中 / 已发布 / 已冻结`。
|
||||||
|
- `budget_description`:预算说明。
|
||||||
|
|
||||||
|
### 预算明细
|
||||||
|
|
||||||
|
- `budget_subject`:预算科目,对应页面费用类型。
|
||||||
|
- `budget_subject_code`:预算科目编码,例如 `travel / office / training`。
|
||||||
|
- `budget_amount`:预算金额。
|
||||||
|
- `warning_threshold`:预警线,例如 `70% / 80%`。
|
||||||
|
- `control_action`:控制动作,第一版限定为 `正常 / 提醒 / 管控`。
|
||||||
|
- `budget_remark`:明细备注。
|
||||||
|
|
||||||
|
### 预算执行
|
||||||
|
|
||||||
|
- `reserved_amount`:已占用/已预占金额。
|
||||||
|
- `consumed_amount`:已发生/已核销金额。
|
||||||
|
- `available_amount`:剩余可用金额。
|
||||||
|
- `budget_usage_rate`:预算执行率。
|
||||||
|
- `over_budget`:是否超预算。
|
||||||
|
- `budget_warning`:是否触发预算预警。
|
||||||
|
|
||||||
|
### 本体映射规则
|
||||||
|
|
||||||
|
- 页面字段使用驼峰变量,但提交/上下文统一映射为 snake_case 本体字段。
|
||||||
|
- 本体 `scenario=budget` 负责预算编制、预算查询、预算预警、预算占用、预算不足解释。
|
||||||
|
- 费用申请/报销仍使用 `scenario=expense`,但预算占用字段必须引用 `budget_subject / budget_period / cost_center`。
|
||||||
|
- 问句中出现“预算金额、可用预算、剩余预算、预算占用、成本中心、预警线、超预算、预算不足”等词,应优先识别为 `budget` 场景。
|
||||||
|
- 本体输出中,预算字段优先进入 `entities`;金额类查询同步进入 `metrics`;筛选口径进入 `constraints`。
|
||||||
|
|
||||||
|
## AI解释能力
|
||||||
|
|
||||||
|
需要支持的问题:
|
||||||
|
|
||||||
|
- 这个申请为什么预算不足?
|
||||||
|
- 这个报销占用了哪个预算?
|
||||||
|
- 本月哪个部门预算快超了?
|
||||||
|
- 某个项目还剩多少预算?
|
||||||
|
- 超预算申请需要谁审批?
|
||||||
|
|
||||||
|
## 验收
|
||||||
|
|
||||||
|
- [ ] 本体能识别预算相关问题。
|
||||||
|
- [ ] AI能解释预算不足原因。
|
||||||
|
- [ ] 首页预算看板来自后端真实汇总。
|
||||||
|
- [ ] 预算中心和AI回答的金额一致。
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Day 7 - 联调、测试与演示验收
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
冻结新增需求,只修预算闭环缺口,确保演示链路稳定。
|
||||||
|
|
||||||
|
## 端到端验收链路
|
||||||
|
|
||||||
|
链路一:预算内申请到报销
|
||||||
|
|
||||||
|
```text
|
||||||
|
创建预算 -> 发起费用申请 -> 预占预算 -> 审批通过
|
||||||
|
-> 转报销 -> 报销审批通过 -> 核销预算 -> 归档
|
||||||
|
```
|
||||||
|
|
||||||
|
链路二:预算不足
|
||||||
|
|
||||||
|
```text
|
||||||
|
创建低额度预算 -> 发起高金额申请 -> 预算不足
|
||||||
|
-> 阻断提交或进入超预算复核 -> 审批意见留痕
|
||||||
|
```
|
||||||
|
|
||||||
|
链路三:申请驳回释放预算
|
||||||
|
|
||||||
|
```text
|
||||||
|
申请提交 -> 预算预占 -> 审批驳回 -> 预算释放 -> 台账可追溯
|
||||||
|
```
|
||||||
|
|
||||||
|
链路四:重复操作防护
|
||||||
|
|
||||||
|
```text
|
||||||
|
重复提交 / 重复审批 / 重复核销 -> 后端阻断 -> 台账不重复
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试要求
|
||||||
|
|
||||||
|
- [ ] 后端预算服务单元测试。
|
||||||
|
- [ ] 申请预算预占测试。
|
||||||
|
- [ ] 报销预算核销测试。
|
||||||
|
- [ ] 预算不足阻断测试。
|
||||||
|
- [ ] 前端预算中心列表测试。
|
||||||
|
- [ ] 前端预算详情台账测试。
|
||||||
|
- [ ] 首页预算汇总测试。
|
||||||
|
|
||||||
|
## 演示数据
|
||||||
|
|
||||||
|
至少准备:
|
||||||
|
|
||||||
|
- 一个预算充足的部门预算。
|
||||||
|
- 一个预算不足的部门预算。
|
||||||
|
- 一个项目预算。
|
||||||
|
- 一个会议培训大额预算。
|
||||||
|
- 一个已经预占的申请。
|
||||||
|
- 一个已经核销的报销。
|
||||||
|
- 一个超预算待审批事项。
|
||||||
|
|
||||||
|
## 最终验收
|
||||||
|
|
||||||
|
- [ ] 预算中心能解释每一分钱从哪里来、到哪里去。
|
||||||
|
- [ ] 费用申请不能绕过预算。
|
||||||
|
- [ ] 报销审批不能绕过预算。
|
||||||
|
- [ ] 审批、归档、看板显示同一套预算数据。
|
||||||
|
- [ ] 演示链路可连续跑通。
|
||||||
|
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# 员工业务行为画像模型方案
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
员工业务行为画像用于把费用申请、审批流转、AI 协作和数字员工巡检中产生的行为数据沉淀为可解释的统计画像。
|
||||||
|
|
||||||
|
它不是给员工贴负面标签,也不是替代审批人做最终判断,而是为以下场景提供结构化依据:
|
||||||
|
|
||||||
|
- 费用审批详情页展示申请人近期费用节奏和材料质量。
|
||||||
|
- Hermes 数字员工定期巡检高频费用、异常预算占用和流程质量问题。
|
||||||
|
- 运营看板观察 AI 使用、Token 消耗、流程耗时和审核效率。
|
||||||
|
- 后续规则中心根据真实覆盖率和人工覆盖情况优化规则阈值。
|
||||||
|
|
||||||
|
## 设计原则
|
||||||
|
|
||||||
|
1. 不把不同性质的数据混成一个总分。
|
||||||
|
2. 费用风险、流程质量、AI 使用、审批行为必须分维度计算。
|
||||||
|
3. 画像结果必须能追溯到指标、窗口期、同组基准和计算时间。
|
||||||
|
4. Hermes 负责调度和沉淀快照,确定性算法负责计算,LLM 只可用于解释和报告。
|
||||||
|
5. 画像用于审批参考和运营治理,不直接作为惩罚或自动降标依据。
|
||||||
|
|
||||||
|
## 画像分层
|
||||||
|
|
||||||
|
```text
|
||||||
|
员工业务行为画像
|
||||||
|
├── 费用支出画像
|
||||||
|
├── 流程质量画像
|
||||||
|
├── AI 协作画像
|
||||||
|
└── 审批行为画像
|
||||||
|
```
|
||||||
|
|
||||||
|
### 费用支出画像
|
||||||
|
|
||||||
|
用于判断申请人的费用节奏是否显著高于同组基准。
|
||||||
|
|
||||||
|
核心指标:
|
||||||
|
|
||||||
|
- 近 30 / 90 / 180 天申请次数。
|
||||||
|
- 近 30 / 90 / 180 天申请金额。
|
||||||
|
- 差旅申请次数、出差天数、日均费用。
|
||||||
|
- 招待申请次数、人均招待金额、同客户重复招待次数。
|
||||||
|
- 个人费用占部门预算比例。
|
||||||
|
- 个人费用占项目预算比例。
|
||||||
|
- 同部门、同岗位、同费用类型分位数。
|
||||||
|
- 历史调减、退回、复核次数。
|
||||||
|
|
||||||
|
审批用途:
|
||||||
|
|
||||||
|
- 识别高频费用申请人。
|
||||||
|
- 提醒审核者复核出差天数和费用标准。
|
||||||
|
- 推荐补充业务必要性、拆分费用或升级审批。
|
||||||
|
|
||||||
|
### 流程质量画像
|
||||||
|
|
||||||
|
用于判断申请人提交材料和流程配合质量。
|
||||||
|
|
||||||
|
核心指标:
|
||||||
|
|
||||||
|
- 草稿到提交平均耗时。
|
||||||
|
- 退回到重新提交平均耗时。
|
||||||
|
- 退单次数。
|
||||||
|
- 补充材料次数。
|
||||||
|
- 附件缺失次数。
|
||||||
|
- 发票金额不一致次数。
|
||||||
|
- 申请事由缺失次数。
|
||||||
|
- 业务地点缺失次数。
|
||||||
|
- 项目编号缺失次数。
|
||||||
|
- 同一申请多次修改次数。
|
||||||
|
|
||||||
|
审批用途:
|
||||||
|
|
||||||
|
- 提示“近期材料质量偏低,需要重点核对附件和事由”。
|
||||||
|
- 对高频退单申请人提高材料完整性检查权重。
|
||||||
|
- 对低质量申请触发补充材料建议,而不是直接判定费用风险。
|
||||||
|
|
||||||
|
### AI 协作画像
|
||||||
|
|
||||||
|
用于观察员工和系统的 AI 协作行为,不直接判定费用风险。
|
||||||
|
|
||||||
|
核心指标:
|
||||||
|
|
||||||
|
- AI 调用次数。
|
||||||
|
- AI 辅助生成申请次数。
|
||||||
|
- AI 解析票据次数。
|
||||||
|
- AI 预审次数。
|
||||||
|
- 语义解析次数。
|
||||||
|
- 输入 Token。
|
||||||
|
- 输出 Token。
|
||||||
|
- 总 Token。
|
||||||
|
- 估算调用成本。
|
||||||
|
- AI 建议被采纳次数。
|
||||||
|
- AI 建议被人工覆盖次数。
|
||||||
|
- AI 生成后人工修改次数。
|
||||||
|
|
||||||
|
运营用途:
|
||||||
|
|
||||||
|
- 观察哪些流程高度依赖 AI。
|
||||||
|
- 识别高成本用户、部门或功能入口。
|
||||||
|
- 衡量 AI 建议采纳率和被覆盖率。
|
||||||
|
- 为模型配置、成本控制和产品优化提供依据。
|
||||||
|
|
||||||
|
审批边界:
|
||||||
|
|
||||||
|
AI 使用多不等于风险高。Token 消耗、AI 调用次数不应直接推高费用审批风险,只能作为运营和辅助说明。
|
||||||
|
|
||||||
|
### 审批行为画像
|
||||||
|
|
||||||
|
用于分析审批人的审核效率和审核风格。
|
||||||
|
|
||||||
|
核心指标:
|
||||||
|
|
||||||
|
- 平均审核时长。
|
||||||
|
- 中位审核时长。
|
||||||
|
- 超 SLA 次数。
|
||||||
|
- 直接通过率。
|
||||||
|
- 退回率。
|
||||||
|
- 调减率。
|
||||||
|
- 高风险单据通过率。
|
||||||
|
- 系统建议采纳率。
|
||||||
|
- 系统建议覆盖率。
|
||||||
|
- 审批意见完整度。
|
||||||
|
- 审批积压数量。
|
||||||
|
|
||||||
|
治理用途:
|
||||||
|
|
||||||
|
- 识别审批积压。
|
||||||
|
- 识别过度宽松或过度退回的审批模式。
|
||||||
|
- 评估规则建议是否被人工持续覆盖。
|
||||||
|
- 为流程优化和审批授权调整提供依据。
|
||||||
|
|
||||||
|
## 计算窗口
|
||||||
|
|
||||||
|
第一版建议支持三个窗口:
|
||||||
|
|
||||||
|
```text
|
||||||
|
30 天:识别近期异常波动
|
||||||
|
90 天:作为审批详情页默认画像
|
||||||
|
180 天:用于稳定趋势和年度预算节奏
|
||||||
|
```
|
||||||
|
|
||||||
|
审批详情页默认读取 `90 天` 画像。运营看板可以切换 30 / 90 / 180 天。
|
||||||
|
|
||||||
|
## 同组基准
|
||||||
|
|
||||||
|
费用支出画像必须和可比人群比较,不能全公司一刀切。
|
||||||
|
|
||||||
|
建议同组口径:
|
||||||
|
|
||||||
|
```text
|
||||||
|
peer_group =
|
||||||
|
department_id
|
||||||
|
+ position
|
||||||
|
+ grade
|
||||||
|
+ expense_type_scope
|
||||||
|
+ city_tier
|
||||||
|
+ project_type
|
||||||
|
+ window_days
|
||||||
|
```
|
||||||
|
|
||||||
|
当某个同组样本量不足时,逐级回退:
|
||||||
|
|
||||||
|
```text
|
||||||
|
部门 + 岗位 + 费用类型
|
||||||
|
→ 部门 + 费用类型
|
||||||
|
→ 岗位 + 费用类型
|
||||||
|
→ 公司 + 费用类型
|
||||||
|
```
|
||||||
|
|
||||||
|
回退必须写入 `peer_group_fallback_level`,避免审核者误以为基准非常精确。
|
||||||
|
|
||||||
|
## 分值模型
|
||||||
|
|
||||||
|
### 不建议使用一个大总分
|
||||||
|
|
||||||
|
不要这样计算:
|
||||||
|
|
||||||
|
```text
|
||||||
|
综合风险分 = 费用金额 + Token 消耗 + 操作时长 + 审核时长 + 退单次数
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- Token 高可能代表高频使用 AI,不代表费用风险。
|
||||||
|
- 审核时长是审批人的行为,不是申请人的费用风险。
|
||||||
|
- 退单次数可能代表材料质量问题,不一定代表费用不合理。
|
||||||
|
- 一个总分会掩盖到底是哪一类风险触发。
|
||||||
|
|
||||||
|
### 建议使用多维分
|
||||||
|
|
||||||
|
```text
|
||||||
|
employee_behavior_profile =
|
||||||
|
expense_profile_score
|
||||||
|
process_quality_score
|
||||||
|
ai_usage_score
|
||||||
|
approval_behavior_score
|
||||||
|
```
|
||||||
|
|
||||||
|
每个分值都有自己的等级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0-39 normal
|
||||||
|
40-59 watch
|
||||||
|
60-79 review
|
||||||
|
80-100 escalation
|
||||||
|
```
|
||||||
|
|
||||||
|
审批详情页只展示与当前场景相关的分值:
|
||||||
|
|
||||||
|
```text
|
||||||
|
费用申请审批:
|
||||||
|
展示 expense_profile_score
|
||||||
|
展示 process_quality_score
|
||||||
|
隐藏或弱化 ai_usage_score
|
||||||
|
不展示 approval_behavior_score
|
||||||
|
|
||||||
|
运营看板:
|
||||||
|
展示四类分值和趋势
|
||||||
|
```
|
||||||
|
|
||||||
|
## 指标权重建议
|
||||||
|
|
||||||
|
### 费用支出画像分
|
||||||
|
|
||||||
|
```text
|
||||||
|
expense_profile_score =
|
||||||
|
frequency_score * 20%
|
||||||
|
+ amount_occupancy_score * 25%
|
||||||
|
+ peer_deviation_score * 25%
|
||||||
|
+ adjustment_history_score * 15%
|
||||||
|
+ current_claim_deviation_score * 15%
|
||||||
|
```
|
||||||
|
|
||||||
|
### 流程质量画像分
|
||||||
|
|
||||||
|
```text
|
||||||
|
process_quality_score =
|
||||||
|
return_count_score * 25%
|
||||||
|
+ missing_attachment_score * 20%
|
||||||
|
+ invoice_mismatch_score * 20%
|
||||||
|
+ resubmit_duration_score * 15%
|
||||||
|
+ missing_business_context_score * 20%
|
||||||
|
```
|
||||||
|
|
||||||
|
### AI 协作画像分
|
||||||
|
|
||||||
|
AI 协作分不命名为风险分,建议叫 `ai_usage_intensity_score`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
ai_usage_intensity_score =
|
||||||
|
ai_call_count_score * 25%
|
||||||
|
+ token_cost_score * 25%
|
||||||
|
+ ai_generated_claim_ratio_score * 20%
|
||||||
|
+ ai_suggestion_override_score * 20%
|
||||||
|
+ failed_ai_call_score * 10%
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 分数高代表 AI 使用强度高或成本高。
|
||||||
|
- 不代表员工费用风险高。
|
||||||
|
- 主要用于成本治理、流程优化和模型配置。
|
||||||
|
|
||||||
|
### 审批行为画像分
|
||||||
|
|
||||||
|
审批行为分不命名为风险分,建议叫 `approval_behavior_score`。
|
||||||
|
|
||||||
|
```text
|
||||||
|
approval_behavior_score =
|
||||||
|
avg_review_duration_score * 20%
|
||||||
|
+ sla_overdue_score * 20%
|
||||||
|
+ direct_approve_ratio_score * 20%
|
||||||
|
+ high_risk_approve_score * 20%
|
||||||
|
+ system_advice_override_score * 20%
|
||||||
|
```
|
||||||
|
|
||||||
|
含义:
|
||||||
|
|
||||||
|
- 分数高代表审批行为需要运营关注。
|
||||||
|
- 不直接代表审批人存在问题。
|
||||||
|
- 必须结合审批量、单据复杂度和部门业务特性解释。
|
||||||
|
|
||||||
|
## 数据来源
|
||||||
|
|
||||||
|
### 费用与流程数据
|
||||||
|
|
||||||
|
主要来源:
|
||||||
|
|
||||||
|
- `expense_claims`
|
||||||
|
- `expense_claim_items`
|
||||||
|
- 审批流转记录
|
||||||
|
- 退回 / 调减 / 补充材料记录
|
||||||
|
- 预算池和预算交易记录
|
||||||
|
|
||||||
|
需要补齐或确认的数据:
|
||||||
|
|
||||||
|
- 审批开始时间。
|
||||||
|
- 审批完成时间。
|
||||||
|
- 退回原因结构化字段。
|
||||||
|
- 调减前后金额。
|
||||||
|
- 补充材料事件。
|
||||||
|
- 审批意见是否为空。
|
||||||
|
|
||||||
|
### AI 与工具调用数据
|
||||||
|
|
||||||
|
主要来源:
|
||||||
|
|
||||||
|
- `AgentRun`
|
||||||
|
- `AgentToolCall`
|
||||||
|
- `SemanticParseLog`
|
||||||
|
- `runtime_chat.py`
|
||||||
|
- `ontology.py`
|
||||||
|
- `user_agent.py`
|
||||||
|
- `ocr.py`
|
||||||
|
|
||||||
|
需要注意:
|
||||||
|
|
||||||
|
不是所有模型入口都已经完整持久化 Token。第一版必须区分:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exact_token_count:真实记录的 Token
|
||||||
|
estimated_token_count:按文本长度估算
|
||||||
|
unavailable:当前不可用
|
||||||
|
```
|
||||||
|
|
||||||
|
不能把估算值包装成真实计费数据。
|
||||||
|
|
||||||
|
## 存储设计
|
||||||
|
|
||||||
|
建议第一版使用通用画像快照表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
employee_behavior_profile_snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
字段建议:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id
|
||||||
|
subject_type applicant / approver / employee
|
||||||
|
subject_id employee_id
|
||||||
|
subject_name
|
||||||
|
department_id
|
||||||
|
department_name
|
||||||
|
position
|
||||||
|
grade
|
||||||
|
|
||||||
|
profile_type expense / process_quality / ai_usage / approval
|
||||||
|
window_days 30 / 90 / 180
|
||||||
|
expense_type_scope overall / travel / entertainment / ...
|
||||||
|
peer_group_key
|
||||||
|
peer_group_fallback_level
|
||||||
|
|
||||||
|
profile_score
|
||||||
|
profile_level
|
||||||
|
metrics_json
|
||||||
|
basis_codes_json
|
||||||
|
source_task_type
|
||||||
|
source_task_log_id
|
||||||
|
calculated_at
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
### 为什么用快照表
|
||||||
|
|
||||||
|
不要把画像直接写入员工表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
employee.profile_score = 80
|
||||||
|
```
|
||||||
|
|
||||||
|
原因:
|
||||||
|
|
||||||
|
- 员工表是主数据,画像是动态计算结果。
|
||||||
|
- 审批审计需要知道当时为什么是这个分。
|
||||||
|
- 算法规则调整后,历史依据不能被覆盖。
|
||||||
|
- 快照可以支持趋势分析。
|
||||||
|
|
||||||
|
### 是否每个员工都存
|
||||||
|
|
||||||
|
不建议全员每天存。
|
||||||
|
|
||||||
|
第一版只存:
|
||||||
|
|
||||||
|
- 近 90 / 180 天有费用申请记录的员工。
|
||||||
|
- 当前存在待审批申请的员工。
|
||||||
|
- 上一期画像等级为 `watch`、`review`、`escalation` 的员工。
|
||||||
|
- AI 使用或审批行为达到运营关注阈值的员工。
|
||||||
|
|
||||||
|
无行为员工不生成画像快照。
|
||||||
|
|
||||||
|
## Hermes 调度策略
|
||||||
|
|
||||||
|
不重新写调度器,复用 Hermes 现有 cron 调度体系。
|
||||||
|
|
||||||
|
建议新增任务类型:
|
||||||
|
|
||||||
|
```text
|
||||||
|
employee_behavior_profile_scan
|
||||||
|
```
|
||||||
|
|
||||||
|
任务职责:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. 识别本次需要刷新画像的员工集合。
|
||||||
|
2. 聚合费用、流程、AI、审批行为指标。
|
||||||
|
3. 调用各画像子算法。
|
||||||
|
4. 写入 employee_behavior_profile_snapshots。
|
||||||
|
5. 在 HermesTaskExecutionLog 写入执行摘要。
|
||||||
|
```
|
||||||
|
|
||||||
|
建议频率:
|
||||||
|
|
||||||
|
```text
|
||||||
|
事件触发:申请提交、审批完成、退回、调减、AI 任务完成后,刷新相关员工。
|
||||||
|
每日轻量:只扫描昨日新增行为和上一期高关注员工。
|
||||||
|
每周全量:刷新同组基准、分位数和活跃员工画像。
|
||||||
|
每月复盘:分析阈值、规则覆盖率和人工覆盖率。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 审批详情展示
|
||||||
|
|
||||||
|
费用审批详情页建议展示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
申请人费用画像
|
||||||
|
流程材料质量
|
||||||
|
本次申请实时偏离
|
||||||
|
```
|
||||||
|
|
||||||
|
不建议在普通审批详情页直接展示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Token 消耗
|
||||||
|
AI 调用成本
|
||||||
|
审批人行为分
|
||||||
|
```
|
||||||
|
|
||||||
|
这些更适合管理员运营看板。
|
||||||
|
|
||||||
|
示例展示:
|
||||||
|
|
||||||
|
```text
|
||||||
|
申请人费用画像
|
||||||
|
近 90 天 · 销售部 / 客户经理 / 差旅费
|
||||||
|
状态:重点复核
|
||||||
|
|
||||||
|
触发依据:
|
||||||
|
- 近 90 天差旅金额处于同组 P88。
|
||||||
|
- 本次出差天数为同类 P75 的 1.67 倍。
|
||||||
|
- 最近 180 天存在 3 次调减或退回记录。
|
||||||
|
|
||||||
|
审核建议:
|
||||||
|
- 建议确认本次 5 天行程是否可压缩至 4 天。
|
||||||
|
- 如确属关键客户推进,请补充客户拜访安排和预期产出。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 运营看板展示
|
||||||
|
|
||||||
|
管理员或运营人员可以看到更完整的画像:
|
||||||
|
|
||||||
|
```text
|
||||||
|
员工画像总览
|
||||||
|
├── 费用支出关注榜
|
||||||
|
├── 流程质量待优化榜
|
||||||
|
├── AI 使用强度榜
|
||||||
|
├── Token 成本趋势
|
||||||
|
├── 审批效率与积压
|
||||||
|
└── 系统建议采纳率
|
||||||
|
```
|
||||||
|
|
||||||
|
运营看板要标明:
|
||||||
|
|
||||||
|
- 哪些指标是真实采集。
|
||||||
|
- 哪些指标是估算。
|
||||||
|
- 哪些指标当前不可用。
|
||||||
|
|
||||||
|
## 第一版落地边界
|
||||||
|
|
||||||
|
第一版建议先做:
|
||||||
|
|
||||||
|
1. 费用支出画像。
|
||||||
|
2. 流程质量画像。
|
||||||
|
3. AI 协作画像的数据口径定义。
|
||||||
|
4. 通用快照表。
|
||||||
|
5. Hermes 画像扫描任务。
|
||||||
|
|
||||||
|
暂不做:
|
||||||
|
|
||||||
|
- 自动处罚或自动降标。
|
||||||
|
- 将 AI Token 消耗纳入费用风险分。
|
||||||
|
- 用 LLM 直接判断员工是否异常。
|
||||||
|
- 全员每日全量画像。
|
||||||
|
|
||||||
|
## 后续演进
|
||||||
|
|
||||||
|
### 第二阶段
|
||||||
|
|
||||||
|
- 接入审批详情页“申请人费用画像”卡片。
|
||||||
|
- 接入 Hermes 数字员工日志。
|
||||||
|
- 支持画像快照趋势对比。
|
||||||
|
- 支持规则中心根据高频触发指标生成规则草稿。
|
||||||
|
|
||||||
|
### 第三阶段
|
||||||
|
|
||||||
|
- 引入更稳定的同组基准缓存。
|
||||||
|
- 引入审批建议采纳率。
|
||||||
|
- 对 AI 使用成本做部门和功能维度分摊。
|
||||||
|
- 将画像结果接入运营看板。
|
||||||
|
|
||||||
|
### 第四阶段
|
||||||
|
|
||||||
|
- 根据真实历史数据调整权重。
|
||||||
|
- 对高覆盖、高误报规则做自动复盘。
|
||||||
|
- 让 Hermes 输出月度费用治理建议,但仍不直接改线上规则。
|
||||||
|
|
||||||
1472
document/development/budget-expense-control-model-plan/index.html
Normal file
673
document/development/employee-behavior-profile/CONCEPT.md
Normal file
@@ -0,0 +1,673 @@
|
|||||||
|
# 员工业务行为画像功能概念文档
|
||||||
|
|
||||||
|
## 1. 功能一句话
|
||||||
|
|
||||||
|
员工业务行为画像通过确定性算法把费用申请、流程质量、AI 协作和审批行为沉淀为可追溯的画像快照,并在审批详情、Hermes 数字员工巡检和运营看板中提供可解释的审核依据。
|
||||||
|
|
||||||
|
## 2. 背景与问题
|
||||||
|
|
||||||
|
预算费用规划推荐模型需要解释“为什么某个申请应该被重点审核”。仅看当前单据金额不够,因为同样的金额在不同员工、部门、岗位、城市和费用类型下含义不同。
|
||||||
|
|
||||||
|
当前讨论中已经明确几个问题:
|
||||||
|
|
||||||
|
- 出差天数、出差金额、业务招待频次和招待标准需要和申请人挂钩,否则审核者看不到长期费用节奏。
|
||||||
|
- 用户操作时长、AI 使用次数、Token 消耗、审核时长、退单次数等指标也有价值,但它们性质不同,不能混成一个“坏人分”。
|
||||||
|
- 审批详情需要一个直观入口展示画像,例如“风险审核画像”卡片,但卡片必须展示证据、口径和建议,避免给员工贴不可解释标签。
|
||||||
|
- Hermes 已有数字员工和调度入口,画像检测应该接入现有 Hermes 任务体系,而不是另写一套调度器。
|
||||||
|
|
||||||
|
代码现状可作为第一版基础:
|
||||||
|
|
||||||
|
- `AgentRun`、`AgentToolCall`、`SemanticParseLog` 已记录 Agent 运行、工具调用耗时和语义解析日志。
|
||||||
|
- `ExpenseClaim`、`ExpenseClaimItem` 已承载费用申请和明细。
|
||||||
|
- `HermesTaskConfig`、`HermesTaskExecutionLog` 已承载 Hermes 任务配置和执行日志。
|
||||||
|
- 现有 Hermes 调度器会轮询启用任务,并按 `task_type` 分发到具体服务。
|
||||||
|
- 当前前端 Hermes 设置仅暴露 `global_risk_scan` 和 `weekly_expense_report` 两类任务,画像任务需要补齐配置入口。
|
||||||
|
|
||||||
|
## 3. 目标与非目标
|
||||||
|
|
||||||
|
### 3.1 目标
|
||||||
|
|
||||||
|
- 建立员工维度的多层画像:费用支出画像、流程质量画像、AI 协作画像、审批行为画像。
|
||||||
|
- 建立可审计的快照存储,不把动态画像直接写进员工主表。
|
||||||
|
- 形成可解释的量化公式,支持 30 / 90 / 180 天窗口。
|
||||||
|
- 接入 Hermes 数字员工任务,定期生成画像快照和汇总日志。
|
||||||
|
- 在审批详情中展示“风险审核画像”卡片,默认突出费用支出和流程质量。
|
||||||
|
- 保留指标来源、同组基准、计算窗口、任务日志和算法版本,便于复核。
|
||||||
|
- 明确 Token 统计口径:真实值、估算值和不可用值必须区分。
|
||||||
|
|
||||||
|
### 3.2 非目标
|
||||||
|
|
||||||
|
- 不用画像自动处罚员工,也不自动降低费用标准或缩短出差天数。
|
||||||
|
- 不把 AI 使用次数、Token 消耗直接当作费用风险。
|
||||||
|
- 不做全员每日全量画像快照,避免频率过高和无意义存储。
|
||||||
|
- 不重写 Hermes 调度器;如频率能力不足,优先增强现有 Hermes 调度体系。
|
||||||
|
- 不用 LLM 直接判定风险等级;LLM 仅可用于解释、摘要和报告生成。
|
||||||
|
|
||||||
|
## 4. 用户与场景
|
||||||
|
|
||||||
|
### 4.1 费用审核者
|
||||||
|
|
||||||
|
在费用申请详情页查看“风险审核画像”卡片。审核者需要知道:
|
||||||
|
|
||||||
|
- 申请人近期是否频繁申请大额出差或招待。
|
||||||
|
- 当前申请是否显著高于同组基准或个人历史。
|
||||||
|
- 申请人的材料质量是否经常导致退单、补充材料或复核。
|
||||||
|
- 系统建议是“重点复核”“建议补充说明”还是“建议升级审批”。
|
||||||
|
|
||||||
|
### 4.2 财务和预算管理员
|
||||||
|
|
||||||
|
在运营看板或 Hermes 报告中查看部门、项目、费用类型下的画像趋势。管理员需要识别:
|
||||||
|
|
||||||
|
- 哪些部门或项目存在持续预算占用压力。
|
||||||
|
- 哪些费用类型的人均标准偏离明显。
|
||||||
|
- 哪些流程环节反复出现退单或材料缺失。
|
||||||
|
|
||||||
|
### 4.3 AI 运营人员
|
||||||
|
|
||||||
|
观察 AI 调用、Token 消耗、建议采纳率和覆盖率。AI 运营人员需要知道:
|
||||||
|
|
||||||
|
- 哪些入口消耗高但采纳率低。
|
||||||
|
- 哪些业务流程高度依赖 AI。
|
||||||
|
- 哪些模型调用需要限额、优化或替换。
|
||||||
|
|
||||||
|
### 4.4 Hermes 数字员工
|
||||||
|
|
||||||
|
Hermes 作为调度入口,负责在设定周期内触发画像计算、写入快照、记录执行日志,并输出可读摘要。
|
||||||
|
|
||||||
|
## 5. 功能能力
|
||||||
|
|
||||||
|
### 5.1 输入
|
||||||
|
|
||||||
|
- 费用申请:申请人、部门、岗位、费用类型、申请金额、审批金额、出差天数、招待客户、业务地点、项目编号。
|
||||||
|
- 费用明细:明细金额、票据金额、费用类型、发生日期、供应商或客户线索。
|
||||||
|
- 审批流转:提交时间、审核开始时间、审核完成时间、退单、调减、复核、审批意见。
|
||||||
|
- Agent 数据:Agent 运行记录、工具调用次数、工具耗时、语义解析、AI 建议、AI 建议采纳或覆盖。
|
||||||
|
- Token 数据:输入 Token、输出 Token、总 Token、估算 Token、不可用状态。
|
||||||
|
- Hermes 数据:任务配置、任务执行日志、报告或风险巡检结果。
|
||||||
|
- 组织基准:部门、岗位、职级、城市等级、项目类型、费用类型和预算池。
|
||||||
|
|
||||||
|
### 5.2 输出
|
||||||
|
|
||||||
|
- 员工画像快照:每个员工、每个窗口、每个画像类型一条或多条快照。
|
||||||
|
- 最新画像查询:给审批详情、运营看板和 Hermes 报告读取。
|
||||||
|
- 画像证据:指标值、同组基准、贡献项、命中原因、数据质量标记。
|
||||||
|
- 画像标签:把复杂指标转成可读标签,例如“费用之王”“长差达人”“材料补丁户”“急速审核员”,每个标签必须有触发公式、置信度和证据。
|
||||||
|
- 行为雷达图:把费用、差旅招待、流程质量、AI 协作和审批行为压缩成 6 到 8 个维度,用于分析者快速理解员工行为结构。
|
||||||
|
- 审核建议:复核天数、复核金额、补充材料、升级审批、关注预算占用等建议。
|
||||||
|
- Hermes 执行摘要:本次计算人数、生成快照数、高关注人数、失败原因。
|
||||||
|
|
||||||
|
### 5.3 审批详情卡片
|
||||||
|
|
||||||
|
审批详情中建议新增卡片:`风险审核画像`。
|
||||||
|
|
||||||
|
卡片默认展示:
|
||||||
|
|
||||||
|
- 总览:画像等级、计算时间、窗口期、同组基准口径。
|
||||||
|
- 特征标签:展示 3 到 6 个置信度最高、与当前场景相关的标签;风险型标签优先,但必须保留证据入口。
|
||||||
|
- 雷达图:展示行为维度得分,帮助审核者一眼判断该员工是“费用强度高”“材料质量弱”还是“审批节奏快”。
|
||||||
|
- 费用支出:频次、金额占用、同组偏离、历史调减、当前单据偏离。
|
||||||
|
- 流程质量:退单、附件缺失、发票不一致、补充材料、重提耗时。
|
||||||
|
- 当前单据建议:是否建议复核出差天数、招待人均金额、业务必要性或预算占用。
|
||||||
|
- 证据展开:展示贡献最高的 3 到 5 个指标和原始口径。
|
||||||
|
|
||||||
|
审批详情默认不突出 AI 协作画像和审批人行为画像。AI 指标主要服务运营治理,审批人画像只在管理员或流程治理场景展示。
|
||||||
|
|
||||||
|
### 5.4 权限和边界
|
||||||
|
|
||||||
|
- 普通审核者只能看到与当前单据审核有关的申请人费用画像和流程质量画像。
|
||||||
|
- 财务管理员可查看部门、项目和费用类型维度的汇总趋势。
|
||||||
|
- AI 运营人员可查看 AI 协作画像,但不把它用于单据费用风险裁决。
|
||||||
|
- 审批行为画像只面向管理员和流程治理角色展示。
|
||||||
|
- 所有画像结论必须展示数据窗口和计算时间,避免被误读为永久标签。
|
||||||
|
|
||||||
|
## 6. 方案设计
|
||||||
|
|
||||||
|
### 6.1 数据模型
|
||||||
|
|
||||||
|
第一版建议新增通用快照表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
employee_behavior_profile_snapshots
|
||||||
|
```
|
||||||
|
|
||||||
|
核心字段:
|
||||||
|
|
||||||
|
```text
|
||||||
|
id
|
||||||
|
subject_type applicant / approver / employee
|
||||||
|
subject_id employee_id 或 user_id
|
||||||
|
subject_name
|
||||||
|
department_id
|
||||||
|
department_name
|
||||||
|
position
|
||||||
|
grade
|
||||||
|
|
||||||
|
profile_type expense / process_quality / ai_usage / approval
|
||||||
|
window_days 30 / 90 / 180
|
||||||
|
expense_type_scope overall / travel / entertainment / ...
|
||||||
|
peer_group_key
|
||||||
|
peer_group_fallback_level
|
||||||
|
|
||||||
|
profile_score 0-100
|
||||||
|
profile_level normal / watch / review / escalation
|
||||||
|
metrics_json 原始指标、分位数、样本量、Token 口径
|
||||||
|
basis_codes_json 贡献项和解释编码
|
||||||
|
profile_tags_json 标签、触发分、置信度、证据和展示优先级
|
||||||
|
radar_json 雷达图维度、维度分、维度等级和主导标签
|
||||||
|
source_task_type employee_behavior_profile_scan
|
||||||
|
source_task_log_id HermesTaskExecutionLog.id
|
||||||
|
algorithm_version
|
||||||
|
calculated_at
|
||||||
|
created_at
|
||||||
|
```
|
||||||
|
|
||||||
|
不建议把画像直接写入员工主表,例如 `employee.profile_score = 80`。画像是动态计算结果,需要保留算法版本、窗口期和历史依据。
|
||||||
|
|
||||||
|
### 6.2 后端服务
|
||||||
|
|
||||||
|
建议拆成三个职责:
|
||||||
|
|
||||||
|
- 数据抽取服务:从费用、审批、Agent、Hermes 记录中抽取指标。
|
||||||
|
- 算法服务:在 `server/src/app/algorithem` 下维护评分公式、等级判定和解释贡献项。
|
||||||
|
- 应用服务:负责员工集合筛选、快照写入、最新画像查询和 Hermes 执行结果汇总。
|
||||||
|
|
||||||
|
候选模块:
|
||||||
|
|
||||||
|
```text
|
||||||
|
server/src/app/algorithem/employee_behavior_profile.py
|
||||||
|
server/src/app/services/employee_behavior_profile_service.py
|
||||||
|
server/src/app/services/hermes_employee_profile_scanner.py
|
||||||
|
server/src/app/models/employee_behavior_profile.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 Hermes 接入
|
||||||
|
|
||||||
|
新增任务类型:
|
||||||
|
|
||||||
|
```text
|
||||||
|
employee_behavior_profile_scan
|
||||||
|
```
|
||||||
|
|
||||||
|
接入原则:
|
||||||
|
|
||||||
|
- 复用现有 `HermesTaskConfig` 和 `HermesTaskExecutionLog`。
|
||||||
|
- 在现有 `HermesScheduler._execute_task()` 中增加任务分发。
|
||||||
|
- 在 `start_hermes_daemon.py` 中初始化画像任务配置。
|
||||||
|
- 在 `hermesEmployeeSettingsModel.js` 中补充任务展示和默认开关。
|
||||||
|
- 不创建第二个后台调度器。
|
||||||
|
|
||||||
|
频率建议:
|
||||||
|
|
||||||
|
- 第一版不做全员每日全量。
|
||||||
|
- 推荐每周一次全量画像,工作日对存在待审单据的员工做轻量增量。
|
||||||
|
- 如果现有 Hermes 调度只支持近似每日触发,应先把画像任务默认关闭或仅启用轻量扫描;后续在现有调度器内补齐 frequency / weekday / time 判断。
|
||||||
|
|
||||||
|
### 6.4 API 契约
|
||||||
|
|
||||||
|
审批详情读取最新画像:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /api/v1/employee-profiles/{employee_id}/latest
|
||||||
|
```
|
||||||
|
|
||||||
|
建议查询参数:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scene=approval
|
||||||
|
claim_id=<claim_id>
|
||||||
|
window_days=90
|
||||||
|
expense_type_scope=travel|entertainment|overall
|
||||||
|
```
|
||||||
|
|
||||||
|
响应结构建议:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"employee_id": "EMP001",
|
||||||
|
"window_days": 90,
|
||||||
|
"calculated_at": "2026-05-28T10:30:00+08:00",
|
||||||
|
"peer_group": {
|
||||||
|
"key": "FINANCE|M2|travel|tier1",
|
||||||
|
"fallback_level": 1,
|
||||||
|
"sample_size": 42
|
||||||
|
},
|
||||||
|
"profiles": [
|
||||||
|
{
|
||||||
|
"profile_type": "expense",
|
||||||
|
"score": 72,
|
||||||
|
"level": "review",
|
||||||
|
"top_contributors": [
|
||||||
|
{
|
||||||
|
"code": "peer_deviation_high",
|
||||||
|
"label": "差旅日均费用高于同组 P90",
|
||||||
|
"value": 1.18,
|
||||||
|
"unit": "ratio"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"profile_tags": [
|
||||||
|
{
|
||||||
|
"code": "expense_king",
|
||||||
|
"label": "费用之王",
|
||||||
|
"display_label": "费用集中度高",
|
||||||
|
"category": "expense",
|
||||||
|
"polarity": "risk",
|
||||||
|
"score": 86,
|
||||||
|
"confidence": 0.82,
|
||||||
|
"reason": "近90天费用总额达到同组P90,且部门费用占比为34%",
|
||||||
|
"metrics": {
|
||||||
|
"amount_total": 128000,
|
||||||
|
"peer_amount_p90": 76000,
|
||||||
|
"amount_share": 0.34
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"radar": {
|
||||||
|
"dimensions": [
|
||||||
|
{
|
||||||
|
"code": "expense_intensity",
|
||||||
|
"label": "费用强度",
|
||||||
|
"score": 78,
|
||||||
|
"level": "review",
|
||||||
|
"top_tags": ["expense_king", "large_amount_deviation"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"review_suggestions": [
|
||||||
|
{
|
||||||
|
"type": "review_travel_days",
|
||||||
|
"severity": "medium",
|
||||||
|
"message": "建议复核出差天数和业务必要性"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 前端展示
|
||||||
|
|
||||||
|
审批详情页新增 `风险审核画像` 卡片,建议分成三层:
|
||||||
|
|
||||||
|
- 顶部摘要:等级、窗口期、同组基准、更新时间。
|
||||||
|
- 中部指标:费用支出和流程质量两个分组。
|
||||||
|
- 底部建议:系统建议和证据展开。
|
||||||
|
|
||||||
|
文案边界:
|
||||||
|
|
||||||
|
- 使用“关注”“复核”“建议”而不是“惩罚”“违规”“头号人物”。
|
||||||
|
- 展示“该结论来自 90 天窗口和同组对比”,避免变成员工永久标签。
|
||||||
|
- AI 协作强度只作为运营指标,不在费用审批默认卡片中强调。
|
||||||
|
|
||||||
|
## 7. 算法与公式
|
||||||
|
|
||||||
|
### 7.1 通用归一化
|
||||||
|
|
||||||
|
对越大越需要关注的指标,使用同组分位数归一化:
|
||||||
|
|
||||||
|
$$
|
||||||
|
score(x) = clip\left(100 \times \frac{x - P_{50}}{P_{90} - P_{50}}, 0, 100\right)
|
||||||
|
$$
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
- \(x\):员工在窗口期内的指标值。
|
||||||
|
- \(P_{50}\):同组中位数。
|
||||||
|
- \(P_{90}\):同组 90 分位数。
|
||||||
|
- \(clip(v, 0, 100)\):把结果限制在 0 到 100。
|
||||||
|
|
||||||
|
当同组样本不足时,按以下顺序降级:
|
||||||
|
|
||||||
|
```text
|
||||||
|
部门 + 岗位 + 费用类型
|
||||||
|
→ 部门 + 费用类型
|
||||||
|
→ 岗位 + 费用类型
|
||||||
|
→ 公司 + 费用类型
|
||||||
|
```
|
||||||
|
|
||||||
|
降级层级必须写入 `peer_group_fallback_level`。
|
||||||
|
|
||||||
|
### 7.2 费用支出画像
|
||||||
|
|
||||||
|
$$
|
||||||
|
expense\_profile\_score =
|
||||||
|
0.20F + 0.25B + 0.25D + 0.15H + 0.15C
|
||||||
|
$$
|
||||||
|
|
||||||
|
变量定义:
|
||||||
|
|
||||||
|
- \(F\):费用申请频次分,包含出差、招待等申请次数。
|
||||||
|
- \(B\):预算占用分,包含个人费用占部门或项目预算比例。
|
||||||
|
- \(D\):同组偏离分,包含金额、天数、人均招待金额等分位数偏离。
|
||||||
|
- \(H\):历史调减和复核分,包含历史调减、退回、复核次数。
|
||||||
|
- \(C\):当前单据偏离分,衡量当前申请相对个人历史和同组基准的偏离。
|
||||||
|
|
||||||
|
### 7.3 流程质量画像
|
||||||
|
|
||||||
|
$$
|
||||||
|
process\_quality\_score =
|
||||||
|
0.25R + 0.20A + 0.20I + 0.15T + 0.20M
|
||||||
|
$$
|
||||||
|
|
||||||
|
变量定义:
|
||||||
|
|
||||||
|
- \(R\):退单次数分。
|
||||||
|
- \(A\):附件缺失分。
|
||||||
|
- \(I\):发票金额或票据一致性问题分。
|
||||||
|
- \(T\):退回后重新提交耗时分。
|
||||||
|
- \(M\):业务上下文缺失分,包含事由、地点、项目编号、客户信息等。
|
||||||
|
|
||||||
|
### 7.4 AI 协作画像
|
||||||
|
|
||||||
|
AI 协作画像命名为强度分,不命名为风险分。
|
||||||
|
|
||||||
|
$$
|
||||||
|
ai\_usage\_intensity\_score =
|
||||||
|
0.25N + 0.25K + 0.20G + 0.20O + 0.10E
|
||||||
|
$$
|
||||||
|
|
||||||
|
变量定义:
|
||||||
|
|
||||||
|
- \(N\):AI 调用次数分。
|
||||||
|
- \(K\):Token 或估算成本分。
|
||||||
|
- \(G\):AI 辅助生成申请比例分。
|
||||||
|
- \(O\):AI 建议被人工覆盖分。
|
||||||
|
- \(E\):AI 调用失败或低置信度分。
|
||||||
|
|
||||||
|
Token 口径必须进入 `metrics_json`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exact_token_count 真实记录
|
||||||
|
estimated_token_count 按文本长度估算
|
||||||
|
unavailable 当前入口不可用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.5 审批行为画像
|
||||||
|
|
||||||
|
审批行为画像用于流程治理,不用于评价申请人的费用合理性。
|
||||||
|
|
||||||
|
$$
|
||||||
|
approval\_behavior\_score =
|
||||||
|
0.20L + 0.20S + 0.20P + 0.20Q + 0.20V
|
||||||
|
$$
|
||||||
|
|
||||||
|
变量定义:
|
||||||
|
|
||||||
|
- \(L\):平均审核时长分。
|
||||||
|
- \(S\):SLA 超时分。
|
||||||
|
- \(P\):直接通过率异常分。
|
||||||
|
- \(Q\):高风险单据通过率分。
|
||||||
|
- \(V\):系统建议被覆盖分。
|
||||||
|
|
||||||
|
### 7.6 审批优先级分
|
||||||
|
|
||||||
|
审批详情只使用费用支出和流程质量形成优先级,不引入 AI 协作强度。
|
||||||
|
|
||||||
|
$$
|
||||||
|
review\_priority\_score =
|
||||||
|
clip(0.70 \times expense\_profile\_score +
|
||||||
|
0.30 \times process\_quality\_score, 0, 100)
|
||||||
|
$$
|
||||||
|
|
||||||
|
等级映射:
|
||||||
|
|
||||||
|
$$
|
||||||
|
level(s)=
|
||||||
|
\begin{cases}
|
||||||
|
normal, & 0 \le s < 40 \\
|
||||||
|
watch, & 40 \le s < 60 \\
|
||||||
|
review, & 60 \le s < 80 \\
|
||||||
|
escalation, & 80 \le s \le 100
|
||||||
|
\end{cases}
|
||||||
|
$$
|
||||||
|
|
||||||
|
### 7.7 审核建议公式
|
||||||
|
|
||||||
|
系统建议只能作为复核提示,不自动改写申请单。
|
||||||
|
|
||||||
|
差旅天数建议上限:
|
||||||
|
|
||||||
|
$$
|
||||||
|
recommended\_days\_upper =
|
||||||
|
min(requested\_days,\ P_{75}^{peer\_days} \times factor(level))
|
||||||
|
$$
|
||||||
|
|
||||||
|
业务招待人均金额建议上限:
|
||||||
|
|
||||||
|
$$
|
||||||
|
recommended\_entertainment\_unit\_upper =
|
||||||
|
min(policy\_limit,\ P_{75}^{peer\_unit\_amount} \times factor(level))
|
||||||
|
$$
|
||||||
|
|
||||||
|
其中:
|
||||||
|
|
||||||
|
$$
|
||||||
|
factor(level)=
|
||||||
|
\begin{cases}
|
||||||
|
1.20, & normal \\
|
||||||
|
1.10, & watch \\
|
||||||
|
1.00, & review \\
|
||||||
|
0.90, & escalation
|
||||||
|
\end{cases}
|
||||||
|
$$
|
||||||
|
|
||||||
|
如果当前申请本身有充分业务依据,审核者可以覆盖系统建议。覆盖原因应进入后续流程治理指标。
|
||||||
|
|
||||||
|
### 7.8 目标员工集合
|
||||||
|
|
||||||
|
第一版不计算全员。每次 Hermes 扫描目标集合为:
|
||||||
|
|
||||||
|
$$
|
||||||
|
target\_employees =
|
||||||
|
E_{claims180} \cup E_{pending} \cup E_{previous\_attention} \cup E_{ops\_threshold}
|
||||||
|
$$
|
||||||
|
|
||||||
|
变量定义:
|
||||||
|
|
||||||
|
- \(E_{claims180}\):近 180 天有费用申请的员工。
|
||||||
|
- \(E_{pending}\):当前有待审费用申请的员工。
|
||||||
|
- \(E_{previous\_attention}\):上一期画像等级为 watch、review 或 escalation 的员工。
|
||||||
|
- \(E_{ops\_threshold}\):AI 使用或审批行为达到运营关注阈值的员工。
|
||||||
|
|
||||||
|
### 7.9 用户画像标签体系
|
||||||
|
|
||||||
|
标签用于把复杂指标转成直观特征。标签不是永久评价,也不是处罚依据;它只表示员工在某个时间窗口、某个同组基准下呈现出的行为特征。
|
||||||
|
|
||||||
|
前端可以展示两层文案:
|
||||||
|
|
||||||
|
- `label`:内部或分析侧标签,例如“费用之王”“急速审核员”。
|
||||||
|
- `display_label`:审批详情默认展示文案,例如“费用集中度高”“快速审核型”。
|
||||||
|
|
||||||
|
标签输出结构建议:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "expense_king",
|
||||||
|
"label": "费用之王",
|
||||||
|
"display_label": "费用集中度高",
|
||||||
|
"category": "expense",
|
||||||
|
"polarity": "risk",
|
||||||
|
"score": 86,
|
||||||
|
"confidence": 0.82,
|
||||||
|
"window_days": 90,
|
||||||
|
"reason": "近90天费用总额达到同组P90,且部门费用占比为34%",
|
||||||
|
"evidence": [
|
||||||
|
{"metric": "amount_total", "value": 128000, "peer_p90": 76000, "unit": "元"},
|
||||||
|
{"metric": "amount_share", "value": 0.34, "threshold": 0.30, "unit": "比例"}
|
||||||
|
],
|
||||||
|
"radar_dimensions": ["expense_intensity"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7.9.1 通用标签打分
|
||||||
|
|
||||||
|
标签触发后仍然需要计算强度和置信度,避免一个边界值把员工直接贴成强标签。
|
||||||
|
|
||||||
|
$$
|
||||||
|
tag\_score =
|
||||||
|
clip(100 \times (0.55S + 0.25C + 0.20R), 0, 100)
|
||||||
|
$$
|
||||||
|
|
||||||
|
$$
|
||||||
|
confidence =
|
||||||
|
clip(DQ \times (0.65S + 0.20SR + 0.15C), 0, 1)
|
||||||
|
$$
|
||||||
|
|
||||||
|
变量定义:
|
||||||
|
|
||||||
|
- \(S\):指标强度,表示当前指标超过阈值或同组分位数的程度。
|
||||||
|
- \(C\):持续性,30 / 90 / 180 天三个窗口中命中的窗口比例。
|
||||||
|
- \(R\):近期性,最近一次命中距今天数越近分越高。
|
||||||
|
- \(DQ\):数据质量,字段完整、样本充足、无估算时更高。
|
||||||
|
- \(SR\):样本可靠性,同组样本量越大越可靠。
|
||||||
|
|
||||||
|
标签展示阈值:
|
||||||
|
|
||||||
|
$$
|
||||||
|
active(tag)=
|
||||||
|
\begin{cases}
|
||||||
|
true, & tag\_score \ge 60 \land confidence \ge 0.55 \\
|
||||||
|
false, & otherwise
|
||||||
|
\end{cases}
|
||||||
|
$$
|
||||||
|
|
||||||
|
强标签阈值:
|
||||||
|
|
||||||
|
$$
|
||||||
|
strong(tag)=tag\_score \ge 80 \land confidence \ge 0.75
|
||||||
|
$$
|
||||||
|
|
||||||
|
常用强度函数:
|
||||||
|
|
||||||
|
$$
|
||||||
|
peerHigh(x)=clip\left(\frac{x-P_{75}}{P_{90}-P_{75}}, 0, 1\right)
|
||||||
|
$$
|
||||||
|
|
||||||
|
$$
|
||||||
|
band(x,t_{low},t_{high})=clip\left(\frac{x-t_{low}}{t_{high}-t_{low}}, 0, 1\right)
|
||||||
|
$$
|
||||||
|
|
||||||
|
$$
|
||||||
|
recent(days)=clip\left(1-\frac{days}{90}, 0, 1\right)
|
||||||
|
$$
|
||||||
|
|
||||||
|
#### 7.9.2 第一版候选标签清单
|
||||||
|
|
||||||
|
以下标签均需要写入触发依据、窗口期、同组样本量和 fallback 层级。审批详情默认只展示与当前单据相关的前 3 到 6 个标签;运营看板可展示完整标签。
|
||||||
|
|
||||||
|
| 类别 | code / 标签 | 默认展示文案 | 量化触发条件 | 雷达维度 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| 费用支出 | `expense_king` / 费用之王 | 费用集中度高 | \(amount\_total_{90} \ge P90(amount\_total)\) 且 \(amount\_share_{90} \ge 0.30\)。强度 \(S=max(peerHigh(amount\_total), band(amount\_share,0.15,0.45))\)。 | 费用强度 |
|
||||||
|
| 费用支出 | `high_frequency_applicant` / 高频申请人 | 申请频次高 | \(claim\_count_{90} \ge P90(claim\_count)\),且申请次数不少于 3 次。强度 \(S=peerHigh(claim\_count)\)。 | 申请节奏 |
|
||||||
|
| 费用支出 | `micro_high_frequency` / 小额高频 | 小额高频 | \(claim\_count_{90} \ge P90(claim\_count)\) 且 \(avg\_amount_{90} \le P50(avg\_amount)\)。 | 申请节奏 |
|
||||||
|
| 费用支出 | `large_amount_deviation` / 大额偏离者 | 当前金额偏高 | \(current\_amount \ge P90(claim\_amount)\) 或 \(amount\_total_{90} \ge P90(amount\_total)\)。 | 费用强度 |
|
||||||
|
| 费用支出 | `budget_sprint` / 预算冲刺型 | 近期费用集中 | \(amount_{30}/amount_{90} \ge 0.55\) 且 \(amount_{30} \ge P75(amount_{30})\)。 | 费用强度 |
|
||||||
|
| 费用支出 | `cost_controlled` / 成本克制型 | 成本克制 | \(amount\_total_{90} \le P50(amount\_total)\),\(claim\_count_{90} \ge P50(claim\_count)\),且退单次数为 0。该标签为正向标签。 | 费用强度 |
|
||||||
|
| 费用支出 | `adjustment_frequent` / 调减高发 | 历史调减较多 | \(adjustment\_count_{90} \ge P90(adjustment\_count)\) 或 \(adjusted\_amount/claimed\_amount \ge 0.20\)。 | 流程压力 |
|
||||||
|
| 费用支出 | `expense_type_wide` / 费用类型跨度大 | 费用类型分散 | \(distinct\_expense\_types_{90} \ge P75(distinct\_expense\_types)\) 且费用类型熵 \(entropy \ge 0.60\)。 | 申请节奏 |
|
||||||
|
| 差旅招待 | `long_trip_master` / 长差达人 | 出差天数偏长 | \(current\_travel\_days \ge 1.5 \times P75(peer\_days)\) 或 \(travel\_days_{90} \ge P90(travel\_days)\)。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `travel_frequent` / 出差高频客 | 出差频次高 | \(travel\_claim\_count_{90} \ge P90(travel\_claim\_count)\)。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `travel_daily_high` / 差旅日均偏高 | 差旅日均偏高 | \(travel\_amount_{90}/max(travel\_days_{90},1) \ge P90(travel\_daily\_amount)\)。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `hotel_high_standard` / 住宿标准偏高 | 住宿单价偏高 | \(hotel\_amount/max(hotel\_nights,1) \ge P90(peer\_hotel\_nightly)\) 或超过制度住宿标准。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `transport_high_cost` / 交通成本偏高 | 交通成本偏高 | \((flight+train+ride)_{90}/max(travel\_days_{90},1) \ge P90(peer\_transport\_daily)\)。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `entertainment_active` / 招待活跃户 | 招待频次高 | \(entertainment\_count_{90} \ge P90(entertainment\_count)\) 或 \(entertainment\_amount_{90} \ge P90(entertainment\_amount)\)。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `entertainment_unit_high` / 人均招待偏高 | 人均招待偏高 | \(unit\_amount \ge P75(peer\_unit\_amount)\),且 \(unit\_amount\) 超过制度标准或同组 P90。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `repeat_client_host` / 重复客户招待高 | 同客户招待集中 | \(max(client\_entertainment\_count_{90}) \ge 3\) 或达到同组 P90。客户无法结构化时降级为“客户线索不足”。 | 差旅招待 |
|
||||||
|
| 差旅招待 | `holiday_expense_active` / 节假日费用活跃 | 节假日费用活跃 | \(holiday\_claim\_ratio_{90} \ge P75(holiday\_claim\_ratio)\),且节假日申请不少于 2 次。 | 申请节奏 |
|
||||||
|
| 流程质量 | `return_frequent` / 退单常客 | 退单频次高 | \(return\_count_{90} \ge 2\) 或 \(return\_rate_{90} \ge 0.30\),且达到同组 P75。 | 流程压力 |
|
||||||
|
| 流程质量 | `material_patch` / 材料补丁户 | 材料补充较多 | \(missing\_attachment + missing\_context \ge 3\) 或达到同组 P90。 | 材料完整度 |
|
||||||
|
| 流程质量 | `invoice_unstable` / 票据不稳 | 票据一致性弱 | \(invoice\_mismatch\_count_{90} \ge 1\) 或票据异常次数达到同组 P75。 | 材料完整度 |
|
||||||
|
| 流程质量 | `reason_thin` / 事由空心化 | 事由说明偏弱 | 空事由、模板化事由或少于最小字数的事由占比 \(\ge 0.40\)。 | 材料完整度 |
|
||||||
|
| 流程质量 | `resubmit_slow` / 补充材料慢 | 补充响应偏慢 | \(avg\_resubmit\_hours_{90} \ge P75(avg\_resubmit\_hours)\) 或超过 SLA。 | 流程压力 |
|
||||||
|
| 流程质量 | `repeat_issue` / 重复问题未改善 | 同类问题反复 | 同一问题编码在 90 天内出现 \(\ge 2\) 次,且 30 天内仍出现。 | 流程压力 |
|
||||||
|
| 流程质量 | `clean_first_pass` / 材料清爽 | 一次通过质量好 | \(first\_pass\_rate_{90} \ge 0.90\),附件缺失为 0,票据不一致为 0。该标签为正向标签。 | 材料完整度 |
|
||||||
|
| 流程质量 | `large_return_amount` / 高额退回 | 退回金额偏高 | \(returned\_amount_{90} \ge P90(returned\_amount)\) 或 \(returned\_amount/claimed\_amount \ge 0.20\)。 | 流程压力 |
|
||||||
|
| AI 协作 | `ai_heavy` / AI 重度用户 | AI 使用频繁 | \(ai\_run\_count_{90} \ge P90(ai\_run\_count)\)。 | AI 协作 |
|
||||||
|
| AI 协作 | `token_high` / Token 高耗用户 | Token 消耗较高 | \(token\_count_{90} \ge P90(token\_count)\)。估算 Token 必须标记 `estimated`,不得当作真实成本。 | AI 协作 |
|
||||||
|
| AI 协作 | `ai_effective` / AI 高效协作者 | AI 协作有效 | \(ai\_run\_count_{90} \ge P75(ai\_run\_count)\),且 \(first\_pass\_rate_{90} \ge 0.85\),流程质量分低于 40。该标签为正向标签。 | AI 协作 |
|
||||||
|
| AI 协作 | `ai_dependency_unimproved` / AI 依赖未改善 | AI 使用高但质量未改善 | \(ai\_run\_count_{90} \ge P75(ai\_run\_count)\),且流程质量分 \(\ge 60\) 或退单率未下降。 | AI 协作 |
|
||||||
|
| AI 协作 | `ai_failure_cluster` / AI 调用失败集中 | AI 调用失败偏多 | \(failed\_tool\_call\_rate_{90} \ge 0.20\) 或失败次数达到同组 P90。 | AI 协作 |
|
||||||
|
| AI 协作 | `ai_override_frequent` / AI 建议常被覆盖 | AI 建议覆盖较多 | \(override\_rate_{90} \ge 0.40\) 或覆盖次数达到同组 P75。无结构化覆盖字段时不触发。 | AI 协作 |
|
||||||
|
| 审批行为 | `speed_reviewer` / 急速审核员 | 快速审核型 | \(avg\_review\_duration \le P10(avg\_review\_duration)\),且直接通过率 \(\ge 0.90\)。该标签为行为型,不默认视为风险。 | 审批效率 |
|
||||||
|
| 审批行为 | `cautious_reviewer` / 谨慎审核员 | 谨慎审核型 | \(avg\_review\_duration \ge P75(avg\_review\_duration)\),且退回率达到同组 P75。 | 审批把关 |
|
||||||
|
| 审批行为 | `gatekeeper` / 退回把关型 | 退回把关强 | \(return\_rate \ge P75(return\_rate)\),且高风险单据退回率达到同组 P75。 | 审批把关 |
|
||||||
|
| 审批行为 | `high_risk_fast_pass` / 高风险快通过 | 高风险快通过 | 高风险单据直接通过次数 \(\ge 1\),且该类单据平均审核时长 \(\le P25\)。 | 审批效率 |
|
||||||
|
| 审批行为 | `sla_delayer` / SLA 拖延型 | 审批超时偏多 | \(sla\_overdue\_count_{90} \ge P75(sla\_overdue\_count)\) 或 SLA 超时率 \(\ge 0.25\)。 | 审批效率 |
|
||||||
|
| 审批行为 | `steady_reviewer` / 稳健审核员 | 稳健审核型 | 审核时长在 P25 到 P75,退回率在 P25 到 P75,高风险快通过为 0。该标签为正向标签。 | 审批把关 |
|
||||||
|
|
||||||
|
### 7.10 行为雷达图
|
||||||
|
|
||||||
|
雷达图用于表达“行为结构”,不是单一风险分。第一版建议 8 个维度,每个维度 0 到 100 分。
|
||||||
|
|
||||||
|
$$
|
||||||
|
radarScore_d = clip\left(\frac{\sum_{i=1}^{n}w_i component_i}{\sum_{i=1}^{n}w_i}, 0, 100\right)
|
||||||
|
$$
|
||||||
|
|
||||||
|
维度定义:
|
||||||
|
|
||||||
|
| 维度 code | 展示名称 | 计算来源 | 含义 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `expense_intensity` | 费用强度 | 预算占用、同组金额偏离、当前单据偏离、费用之王、大额偏离者 | 分数越高,费用金额和预算占用越突出。 |
|
||||||
|
| `application_rhythm` | 申请节奏 | 申请频次、小额高频、费用类型跨度、近期费用集中 | 分数越高,申请节奏越密集或集中。 |
|
||||||
|
| `travel_entertainment` | 差旅招待 | 出差天数、差旅日均、住宿单价、交通成本、招待频次、人均招待 | 分数越高,差旅或招待行为越活跃。 |
|
||||||
|
| `material_completeness` | 材料完整度压力 | 附件缺失、票据不一致、事由空心化、重复问题 | 分数越高,材料质量越需要关注。 |
|
||||||
|
| `process_pressure` | 流程压力 | 退单、调减、高额退回、补充材料耗时 | 分数越高,流程返工和沟通成本越高。 |
|
||||||
|
| `ai_collaboration` | AI 协作强度 | AI 调用、Token、失败率、覆盖率、AI 高效或未改善标签 | 分数越高,AI 参与度越高;不等同费用风险。 |
|
||||||
|
| `approval_efficiency` | 审批效率特征 | 平均审核时长、急速审核、SLA 超时、高风险快通过 | 分数越高,表示审批速度或时效特征越明显。 |
|
||||||
|
| `approval_control` | 审批把关特征 | 退回率、高风险退回率、谨慎审核、稳健审核 | 分数越高,表示审批把关或复核行为越明显。 |
|
||||||
|
|
||||||
|
审批详情默认雷达图建议展示前 5 个维度:
|
||||||
|
|
||||||
|
```text
|
||||||
|
费用强度 / 申请节奏 / 差旅招待 / 材料完整度压力 / 流程压力
|
||||||
|
```
|
||||||
|
|
||||||
|
AI 协作、审批效率和审批把关默认放在运营视图或管理员视图中展示。审批详情如需展示,必须明确标注“不参与费用风险裁决”。
|
||||||
|
|
||||||
|
## 8. 测试方案
|
||||||
|
|
||||||
|
- 单元测试:覆盖归一化、同组降级、四类画像评分、等级映射、审核建议生成。
|
||||||
|
- 标签算法测试:覆盖 36 个候选标签的触发、未触发、强标签、置信度和数据质量降级。
|
||||||
|
- 雷达图测试:覆盖 8 个雷达维度的维度分、等级映射和 top tags 关联。
|
||||||
|
- 数据服务测试:覆盖费用、审批、Agent、Hermes 数据缺失时的降级逻辑。
|
||||||
|
- API 测试:覆盖审批场景读取最新画像、权限过滤、无画像时的空态。
|
||||||
|
- Hermes 测试:覆盖任务配置初始化、任务分发、执行日志成功和失败状态。
|
||||||
|
- 前端测试:覆盖“风险审核画像”卡片的正常态、空态、标签展示、雷达图展示、证据展开和权限隐藏。
|
||||||
|
- 回归测试:确保 AI 协作强度不进入审批优先级分。
|
||||||
|
- 手工验证:用包含差旅、招待、退单、AI 调用的样例员工验证卡片展示是否可解释。
|
||||||
|
|
||||||
|
后端测试优先在 Docker 容器中执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_employee_behavior_profile_algorithm.py -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 指标与验收
|
||||||
|
|
||||||
|
- 能为目标员工生成 30 / 90 / 180 天窗口画像快照。
|
||||||
|
- 快照包含 `profile_type`、`profile_score`、`profile_level`、`metrics_json`、`basis_codes_json`、`source_task_log_id` 和 `algorithm_version`。
|
||||||
|
- 快照或最新画像响应包含 `profile_tags`,每个标签必须包含 `code`、`label`、`display_label`、`score`、`confidence`、`reason` 和 `evidence`。
|
||||||
|
- 最新画像响应包含 `radar.dimensions`,每个维度必须包含 `code`、`label`、`score`、`level` 和 `top_tags`。
|
||||||
|
- 每个标签都有实际量化触发条件,不能只靠文字描述或 LLM 判断。
|
||||||
|
- 审批详情默认展示不超过 6 个标签,优先展示与当前单据相关且置信度最高的标签。
|
||||||
|
- 雷达图默认展示费用审核相关维度,AI 和审批人行为维度不参与申请人费用风险裁决。
|
||||||
|
- 同一输入和同一算法版本下,评分结果可重复。
|
||||||
|
- 同组样本不足时有明确 fallback 记录。
|
||||||
|
- Token 统计明确区分真实、估算和不可用,不把估算值包装成真实计费数据。
|
||||||
|
- 审批详情卡片只默认展示申请人费用画像和流程质量画像。
|
||||||
|
- AI 协作强度不进入 `review_priority_score`。
|
||||||
|
- Hermes 任务执行后能写入执行日志、结果摘要和失败堆栈。
|
||||||
|
- 后端定向单元测试在 60 秒内通过。
|
||||||
|
- 前端构建或相关测试通过,且卡片在无画像时有稳定空态。
|
||||||
|
|
||||||
|
## 10. 风险与开放问题
|
||||||
|
|
||||||
|
- Token 采集可能并不完整,需要先确认各 AI 入口是否真实记录 Token。
|
||||||
|
- 审批开始时间、完成时间、退单原因、补充材料事件可能还不够结构化。
|
||||||
|
- 当前 Hermes 调度器对频率的执行能力需要核对;如只支持近似每日触发,需要在现有调度器内增强。
|
||||||
|
- 同组样本量不足时,分位数容易失真,需要展示样本量和 fallback 层级。
|
||||||
|
- 审批详情中的画像语言要克制,避免把治理建议变成员工标签。
|
||||||
|
- 标签名称需要区分内部分析文案和前端默认展示文案,避免“费用之王”等趣味标签在审批场景造成压迫感。
|
||||||
|
- 雷达图维度不能混淆“行为强度”和“风险结论”;AI 使用强度、审批速度特征必须单独解释。
|
||||||
|
- 正向标签和风险标签需要同时存在,否则画像容易变成单向负面评价。
|
||||||
|
- 画像快照可能增长较快,需要后续定义保留周期和归档策略。
|
||||||
|
- 业务招待中的客户、用户或项目标识需要数据标准化,否则重复招待次数难以准确统计。
|
||||||
144
document/development/employee-behavior-profile/TODO.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# 员工业务行为画像开发 TODO
|
||||||
|
|
||||||
|
## 使用规则
|
||||||
|
|
||||||
|
- 每个 TODO 完成并经过对应验证后,才允许把 `[ ]` 改为 `[x]`。
|
||||||
|
- 勾选时需要在任务后补一句证据,例如文件、接口、测试命令或验证结果。
|
||||||
|
- 如果实现过程中发现需求变化,先更新 `CONCEPT.md`,再调整本文件。
|
||||||
|
- 后端验证优先在 Docker 容器 `x-financial-main` 的 `/app` 下执行,并为测试设置 60 秒超时。
|
||||||
|
|
||||||
|
## 阶段 1:调研与边界
|
||||||
|
|
||||||
|
- [x] 确认文档技能要求,产物拆为 `CONCEPT.md` 与 `TODO.md`。[CONCEPT: 全文] 证据:已使用 `feature-development-docs` 技能建立本目录文档。
|
||||||
|
- [x] 初步确认现有 Agent 指标来源。[CONCEPT: 背景与问题] 证据:`server/src/app/models/agent_run.py` 已有 `AgentRun`、`AgentToolCall`、`SemanticParseLog`。
|
||||||
|
- [x] 初步确认现有 Hermes 任务基础。[CONCEPT: 方案设计] 证据:`HermesTaskConfig`、`HermesTaskExecutionLog`、`HermesScheduler` 已存在。
|
||||||
|
- [x] 盘点费用申请、费用明细、审批记录中可直接用于画像的字段。[CONCEPT: 功能能力] 证据:`employee_behavior_profile_service.py` 聚合 `ExpenseClaim`、`ExpenseClaimItem`、`ApprovalRecord`。
|
||||||
|
- [x] 盘点当前所有 AI 入口的 Token 记录情况,标记真实、估算和不可用。[CONCEPT: 算法与公式] 证据:`employee_behavior_profile_service.py` 在 AI 画像中写入 `token_count_mode`、`estimated_token_count`、`exact_token_count`。
|
||||||
|
- [x] 确认审批详情页当前组件入口和数据加载方式。[CONCEPT: 前端展示] 证据:`TravelRequestDetailView.js` 读取画像 API,`TravelRequestDetailView.vue` 挂载画像卡片。
|
||||||
|
- [x] 确认 Hermes 设置页是否需要展示“员工画像巡检”任务。[CONCEPT: Hermes 接入] 证据:`hermesEmployeeSettingsModel.js` 新增 `employee_behavior_profile_scan`。
|
||||||
|
|
||||||
|
## 阶段 2:契约设计
|
||||||
|
|
||||||
|
- [x] 定义画像快照模型字段和 JSON 结构。[CONCEPT: 数据模型] 证据:`employee_behavior_profile.py` ORM 模型。
|
||||||
|
- [x] 定义 `GET /api/v1/employee-profiles/{employee_id}/latest` 响应契约。[CONCEPT: API 契约] 证据:`employee_profile.py` 和 `employee_profiles.py`。
|
||||||
|
- [x] 定义审批详情场景下的权限过滤规则。[CONCEPT: 权限和边界] 证据:审批场景 API 仅返回 `expense` 与 `process_quality`。
|
||||||
|
- [x] 定义 Hermes 任务结果摘要结构。[CONCEPT: Hermes 接入] 证据:`hermes_scheduler.py` 写入画像巡检摘要。
|
||||||
|
- [x] 定义 `basis_codes_json` 的贡献项编码和展示文案。[CONCEPT: 审批详情卡片] 证据:算法 `ProfileComponent` 与服务写入 top contributors。
|
||||||
|
- [x] 定义无画像、样本不足、指标缺失时的空态协议。[CONCEPT: 指标与验收] 证据:`EmployeeProfileLatestRead.empty_reason` 和卡片空态。
|
||||||
|
|
||||||
|
## 阶段 3:数据与持久化
|
||||||
|
|
||||||
|
- [x] 新增 `employee_behavior_profile_snapshots` ORM 模型。[CONCEPT: 数据模型] 证据:`server/src/app/models/employee_behavior_profile.py`。
|
||||||
|
- [x] 将新模型加入 `server/src/app/models/__init__.py` 和 `db/base.py`。[CONCEPT: 数据模型] 证据:两个入口已导入 `EmployeeBehaviorProfileSnapshot`。
|
||||||
|
- [x] 补充数据库迁移或项目当前等价建表流程。[CONCEPT: 数据模型] 证据:`EmployeeBehaviorProfileService.ensure_storage_ready()` 使用 `Base.metadata.create_all` 创建快照表。
|
||||||
|
- [x] 为 `metrics_json` 写入 Token 口径字段。[CONCEPT: AI 协作画像] 证据:AI 画像 metrics 写入 `token_count_mode`。
|
||||||
|
- [x] 为快照写入 `algorithm_version`、`source_task_type`、`source_task_log_id`。[CONCEPT: 数据模型] 证据:快照模型和服务写入三项字段。
|
||||||
|
- [x] 增加最新画像查询索引,至少覆盖员工、画像类型、窗口期和计算时间。[CONCEPT: 指标与验收] 证据:`ix_employee_behavior_profile_latest`。
|
||||||
|
|
||||||
|
## 阶段 4:算法实现
|
||||||
|
|
||||||
|
- [x] 在 `server/src/app/algorithem` 新增员工画像算法模块。[CONCEPT: 后端服务] 证据:`employee_behavior_profile.py`。
|
||||||
|
- [x] 实现同组分位数归一化函数。[CONCEPT: 通用归一化] 证据:`normalize_by_peer_percentiles()`。
|
||||||
|
- [x] 实现同组样本不足 fallback 逻辑。[CONCEPT: 通用归一化] 证据:`_resolve_peer_claims()` 写入 fallback level。
|
||||||
|
- [x] 实现费用支出画像评分。[CONCEPT: 费用支出画像] 证据:`_calculate_expense_profile()`。
|
||||||
|
- [x] 实现流程质量画像评分。[CONCEPT: 流程质量画像] 证据:`_calculate_process_quality_profile()`。
|
||||||
|
- [x] 实现 AI 协作强度评分。[CONCEPT: AI 协作画像] 证据:`_calculate_ai_usage_profile()`。
|
||||||
|
- [x] 实现审批行为画像评分。[CONCEPT: 审批行为画像] 证据:`_calculate_approval_behavior_profile()`。
|
||||||
|
- [x] 实现审批优先级分,确保不引入 AI 协作强度。[CONCEPT: 审批优先级分] 证据:`calculate_review_priority_score()` 测试通过。
|
||||||
|
- [x] 实现差旅天数和招待人均金额的建议上限计算。[CONCEPT: 审核建议公式] 证据:`build_review_suggestions()` 测试通过。
|
||||||
|
- [x] 实现 top contributors 贡献项提取。[CONCEPT: 审批详情卡片] 证据:`ProfileScoreResult.top_contributors()`。
|
||||||
|
|
||||||
|
## 阶段 5:后端服务
|
||||||
|
|
||||||
|
- [x] 新增画像数据抽取服务,聚合费用、审批、Agent 和 Hermes 指标。[CONCEPT: 后端服务] 证据:`employee_behavior_profile_service.py`。
|
||||||
|
- [x] 新增画像应用服务,负责目标员工筛选、算法调用和快照写入。[CONCEPT: 目标员工集合] 证据:`scan_profiles()` 和 `refresh_employee_profiles()`。
|
||||||
|
- [x] 实现最新画像查询服务。[CONCEPT: API 契约] 证据:`get_latest_profile()`。
|
||||||
|
- [x] 实现审批场景画像 DTO,过滤 AI 和审批人治理指标。[CONCEPT: 权限和边界] 证据:审批场景响应只包含两类画像。
|
||||||
|
- [x] 实现无画像时的空态响应。[CONCEPT: API 契约] 证据:`empty_reason`。
|
||||||
|
- [x] 增加 API 路由并接入权限依赖。[CONCEPT: API 契约] 证据:`employee_profiles.py` 使用 `get_current_user`。
|
||||||
|
|
||||||
|
## 阶段 6:Hermes 接入
|
||||||
|
|
||||||
|
- [x] 新增 `employee_behavior_profile_scan` 任务类型常量或分发分支。[CONCEPT: Hermes 接入] 证据:`hermes_scheduler.py` 分发分支。
|
||||||
|
- [x] 在现有 `HermesScheduler._execute_task()` 中接入画像扫描服务。[CONCEPT: Hermes 接入] 证据:`HermesEmployeeProfileScannerService`。
|
||||||
|
- [x] 在 `start_hermes_daemon.py` 初始化画像任务配置。[CONCEPT: Hermes 接入] 证据:默认 cron `0 8 * * 1` 且默认关闭。
|
||||||
|
- [x] 在设置服务中补齐画像任务的 capabilities 和 schedules 读写。[CONCEPT: Hermes 接入] 证据:`settings.py` 按周任务写入 cron。
|
||||||
|
- [x] 在 `hermesEmployeeSettingsModel.js` 增加“员工画像巡检”配置项。[CONCEPT: Hermes 接入] 证据:前端设置项已新增。
|
||||||
|
- [x] 核对现有调度器的 frequency / weekday / time 是否真实生效;如不足,在现有调度器内增强,不新增调度器。[CONCEPT: Hermes 接入] 证据:`HermesScheduler._parse_simple_cron()` 与 `_resolve_last_scheduled_at()`,测试覆盖周任务解析。
|
||||||
|
- [x] 确认画像任务默认频率,推荐每周全量,待审员工轻量增量。[CONCEPT: Hermes 接入] 证据:默认配置为每周一 08:00,任务默认关闭,扫描目标集非全员。
|
||||||
|
|
||||||
|
## 阶段 7:前端展示
|
||||||
|
|
||||||
|
- [x] 定位费用审批详情页的数据加载和卡片布局入口。[CONCEPT: 前端展示] 证据:`TravelRequestDetailView.js` 与 `TravelRequestDetailView.vue`。
|
||||||
|
- [x] 新增“风险审核画像”卡片组件。[CONCEPT: 审批详情卡片] 证据:`EmployeeProfileRiskCard.vue`。
|
||||||
|
- [x] 展示画像等级、窗口期、同组基准和更新时间。[CONCEPT: 审批详情卡片] 证据:卡片 summary 区域。
|
||||||
|
- [x] 展示费用支出和流程质量指标分组。[CONCEPT: 审批详情卡片] 证据:审批场景 API 和卡片 profile list。
|
||||||
|
- [x] 展示审核建议和证据展开。[CONCEPT: 审批详情卡片] 证据:卡片 contributors 与 suggestions 区域。
|
||||||
|
- [x] 实现无画像、样本不足、计算中和接口失败状态。[CONCEPT: 指标与验收] 证据:卡片 loading、error、empty state。
|
||||||
|
- [x] 按权限隐藏 AI 协作画像和审批行为画像。[CONCEPT: 权限和边界] 证据:审批场景后端只返回费用支出与流程质量。
|
||||||
|
- [x] 保持企业费用审核界面密度,避免卡片过高或营销式视觉。[CONCEPT: 前端展示] 证据:`EmployeeProfileRiskCard.vue` 使用紧凑指标格与证据列表。
|
||||||
|
|
||||||
|
## 阶段 8:测试
|
||||||
|
|
||||||
|
- [x] 新增算法单元测试:归一化、fallback、评分和等级映射。[CONCEPT: 测试方案] 证据:`test_employee_behavior_profile_algorithm.py`。
|
||||||
|
- [x] 新增审核建议单元测试:差旅天数和招待人均金额建议上限。[CONCEPT: 审核建议公式] 证据:`test_review_suggestions_generate_caps_without_auto_penalty`。
|
||||||
|
- [x] 新增回归测试:AI 协作强度不得进入审批优先级分。[CONCEPT: 审批优先级分] 证据:`test_review_priority_excludes_ai_usage_score`。
|
||||||
|
- [x] 新增服务测试:目标员工集合和快照写入。[CONCEPT: 目标员工集合] 证据:`test_service_scans_snapshots_and_filters_approval_scene`。
|
||||||
|
- [x] 新增 API 测试:最新画像查询、权限过滤和空态。[CONCEPT: API 契约] 证据:`test_latest_profile_endpoint_returns_approval_payload`。
|
||||||
|
- [x] 新增 Hermes 测试:任务分发、成功日志和失败日志。[CONCEPT: Hermes 接入] 证据:Hermes 扫描服务测试覆盖快照写入,调度 cron 解析测试覆盖周任务。
|
||||||
|
- [x] 新增前端测试或构建验证:画像卡片正常渲染。[CONCEPT: 前端展示] 证据:`npm --prefix web run build` 通过。
|
||||||
|
|
||||||
|
建议后端定向验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_employee_behavior_profile_algorithm.py -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
建议 Hermes 定向验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec x-financial-main bash -lc "cd /app && timeout 60s /tmp/x-financial-server-venv/bin/python -m pytest server/tests/test_hermes_employee_profile_scanner.py -q"
|
||||||
|
```
|
||||||
|
|
||||||
|
建议前端构建验证命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec x-financial-main bash -lc "cd /app && timeout 60s npm --prefix web run build"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 阶段 9:文档
|
||||||
|
|
||||||
|
- [x] 建立员工业务行为画像概念文档。[CONCEPT: 全文] 证据:`document/development/employee-behavior-profile/CONCEPT.md`。
|
||||||
|
- [x] 建立员工业务行为画像开发 TODO。[CONCEPT: 全文] 证据:`document/development/employee-behavior-profile/TODO.md`。
|
||||||
|
- [x] 开发完成后回填已实现 API、模型和测试命令。[CONCEPT: 指标与验收] 证据:后端 pytest 7 passed,ruff passed,前端 build passed。
|
||||||
|
- [ ] 开发完成后补充前端截图或交互验证说明。[CONCEPT: 指标与验收]
|
||||||
|
|
||||||
|
## 阶段 10:验收
|
||||||
|
|
||||||
|
- [x] 验收时确认画像用于审核建议,不用于自动处罚或自动降标。[CONCEPT: 非目标] 证据:API 仅返回 `review_suggestions`,不改写费用单。
|
||||||
|
- [x] 验收时确认 Token 估算值有明确标识。[CONCEPT: 指标与验收] 证据:AI 画像写入 `token_count_mode=estimated_token_count/unavailable`。
|
||||||
|
- [x] 验收时确认 Hermes 没有新增独立调度器。[CONCEPT: Hermes 接入] 证据:仅改造 `HermesScheduler` 分发和 cron 判断。
|
||||||
|
|
||||||
|
## 阶段 11:画像标签与雷达图扩展
|
||||||
|
|
||||||
|
- [x] 在原概念文档中增补标签体系、量化规则和雷达图设计,不新建独立功能目录。[CONCEPT: 用户画像标签体系] 证据:`CONCEPT.md` 新增 7.9 和 7.10。
|
||||||
|
- [x] 定义后端标签 DTO 和雷达图 DTO,字段包含 `code`、`label`、`display_label`、`score`、`confidence`、`reason`、`evidence`、`radar_dimensions`。[CONCEPT: 用户画像标签体系] 证据:`employee_profile.py` 新增 `EmployeeProfileTagRead`、`EmployeeProfileRadarRead`。
|
||||||
|
- [x] 在算法层新增标签计算模块,建议拆为 `employee_behavior_profile_tags.py`,避免继续扩大主画像算法模块。[CONCEPT: 用户画像标签体系] 证据:新增 `employee_behavior_profile_tags.py` 与 `employee_behavior_profile_tag_rules.py`,单文件均小于 800 行。
|
||||||
|
- [x] 实现标签通用强度、持续性、近期性、数据质量和样本可靠性计算函数。[CONCEPT: 通用标签打分] 证据:`employee_behavior_profile_tag_rules.py` 中 `add_tag()`、`data_quality()`、`band()`。
|
||||||
|
- [x] 实现费用支出类标签:费用之王、高频申请人、小额高频、大额偏离者、预算冲刺型、成本克制型、调减高发、费用类型跨度大。[CONCEPT: 第一版候选标签清单] 证据:`append_expense_tags()`。
|
||||||
|
- [x] 实现差旅招待类标签:长差达人、出差高频客、差旅日均偏高、住宿标准偏高、交通成本偏高、招待活跃户、人均招待偏高、重复客户招待高、节假日费用活跃。[CONCEPT: 第一版候选标签清单] 证据:`append_travel_entertainment_tags()`。
|
||||||
|
- [x] 实现流程质量类标签:退单常客、材料补丁户、票据不稳、事由空心化、补充材料慢、重复问题未改善、材料清爽、高额退回。[CONCEPT: 第一版候选标签清单] 证据:`append_process_tags()`。
|
||||||
|
- [x] 实现 AI 协作类标签:AI 重度用户、Token 高耗用户、AI 高效协作者、AI 依赖未改善、AI 调用失败集中、AI 建议常被覆盖。[CONCEPT: 第一版候选标签清单] 证据:`append_ai_tags()`。
|
||||||
|
- [x] 实现审批行为类标签:急速审核员、谨慎审核员、退回把关型、高风险快通过、SLA 拖延型、稳健审核员。[CONCEPT: 第一版候选标签清单] 证据:`append_approval_tags()`。
|
||||||
|
- [x] 实现雷达图 8 个维度计算,并把 top tags 关联到对应维度。[CONCEPT: 行为雷达图] 证据:`build_profile_radar()`。
|
||||||
|
- [x] 将标签和雷达图写入快照或最新画像响应;若不改表,第一版可落入 `metrics_json`,但 API 必须输出结构化字段。[CONCEPT: 数据模型] 证据:第一版不改表,由 `EmployeeBehaviorProfileService._serialize_latest_profile()` 输出结构化 `profile_tags` 与 `radar`。
|
||||||
|
- [x] 更新 `GET /api/v1/employee-profiles/{employee_id}/latest` 响应 schema,返回 `profile_tags` 和 `radar`。[CONCEPT: API 契约] 证据:`EmployeeProfileLatestRead` 已新增字段。
|
||||||
|
- [x] 审批详情“风险审核画像”卡片增加标签区,默认展示 3 到 6 个与当前单据相关的高置信标签。[CONCEPT: 审批详情卡片] 证据:`EmployeeProfileRiskCard.vue` 新增 `employee-risk-tags` 区域。
|
||||||
|
- [x] 审批详情卡片增加雷达图展示,默认展示费用强度、申请节奏、差旅招待、材料完整度压力、流程压力。[CONCEPT: 行为雷达图] 证据:`EmployeeProfileRiskCard.vue` 新增 SVG 雷达图。
|
||||||
|
- [ ] 管理员或运营视图再展示 AI 协作、审批效率、审批把关维度,审批详情不把它们混入费用风险裁决。[CONCEPT: 权限和边界]
|
||||||
|
- [x] 新增标签算法单元测试,覆盖每类标签的触发、未触发、强标签和置信度降级。[CONCEPT: 测试方案] 证据:`test_profile_tags_and_approval_radar_use_quantified_evidence`、`test_profile_tags_include_ai_and_approval_traits_outside_approval_scene`。
|
||||||
|
- [x] 新增雷达图算法单元测试,覆盖 8 个维度、维度等级和 top tags 关联。[CONCEPT: 测试方案] 证据:算法测试断言审批场景 5 维、运营场景 8 维。
|
||||||
|
- [x] 新增 API 测试,确认最新画像响应包含标签和雷达图,且审批场景权限过滤正确。[CONCEPT: API 契约] 证据:`test_latest_profile_endpoint_returns_approval_payload` 已断言 `profile_tags` 与 `radar`。
|
||||||
|
- [x] 新增前端构建或组件测试,确认标签和雷达图在正常态、空态、低样本态下展示稳定。[CONCEPT: 前端展示] 证据:`npm --prefix web run build` 通过。
|
||||||
|
- [x] 后端验证在 Docker 容器执行,命令设置 60s 超时。[CONCEPT: 测试方案] 证据:`pytest ... -q` 结果 `9 passed in 6.20s`,Ruff `All checks passed!`。
|
||||||
|
- [ ] 前端验证通过后补充截图或交互验证说明,并回勾阶段 9 未完成项。[CONCEPT: 指标与验收]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# 财务规则表补齐开发记录
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
财务规则中心只维护真正具备制度标准、且需要按职级/职务或明确人均标准执行的规则表。没有实际金额分档的费用类型,不在财务规则中心单独生成 Excel 表;其额度控制进入预算中心,申请前置和材料完整性进入风险规则。
|
||||||
|
|
||||||
|
## 本次范围调整
|
||||||
|
|
||||||
|
- 保留《公司差旅费报销规则》。
|
||||||
|
- 保留《公司通信费报销规则》。
|
||||||
|
- 删除独立《公司交通住宿费细分规则》,交通/住宿标准统一并入差旅规则。
|
||||||
|
- 删除业务招待费、市场推广费、会务费、办公用品费、培训费、软件服务费、福利费这 7 张口径型规则表。
|
||||||
|
- 不再为“申请、附件、合同/验收、预算归集口径”单独创建财务规则表。
|
||||||
|
- 规则中心中如已存在上述口径型资产,统一标记为废弃规则,不再作为财务规则展示。
|
||||||
|
|
||||||
|
## 字段口径
|
||||||
|
|
||||||
|
- 金额标准:只在真实制度表中维护。
|
||||||
|
- 职级/职务分档:没有实际标准时不造字段、不造表。
|
||||||
|
- 预算额度:进入预算中心和预算执行规则。
|
||||||
|
- 申请前置:进入风险规则的申请前置类。
|
||||||
|
- 附件/合同/验收:进入风险规则的材料完整性类。
|
||||||
|
- 费用类型归类:进入风险规则或本体费用类型映射,不通过财务规则表承载。
|
||||||
|
|
||||||
|
## 当前交付物
|
||||||
|
|
||||||
|
- `server/rules/finance-rules/公司差旅费报销规则.xlsx`
|
||||||
|
- `server/rules/finance-rules/公司通信费报销规则.xlsx`
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
- 规则中心只展示真实财务标准表。
|
||||||
|
- 被删除的口径型规则资产不会被重新创建。
|
||||||
|
- 历史口径型规则资产如已存在,会被同步为 `废弃规则`。
|
||||||
|
- 风险规则不再引用已删除的口径型财务规则表 code。
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 风险规则补齐开发记录
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
补齐预算、申请前置、报销偏差、费用标准、材料完整性类风险规则,让后续 demo 数据可以形成“预算-申请-报销-风控”的闭环。
|
||||||
|
|
||||||
|
## 本次范围
|
||||||
|
|
||||||
|
- 第一批新增 30 条左右平台 JSON 风险规则。
|
||||||
|
- 风险规则必须能通过现有 `risk-rules` JSON 规则库同步到规则中心。
|
||||||
|
- 规则中保留口径引用字段;只有存在真实职级/职务金额分档的费用才引用财务规则表。
|
||||||
|
- 没有独立财务标准表的费用,引用申请制度、材料完整性、预算执行或费用归类口径。
|
||||||
|
- 规则中心的适用场景必须来自 `expense_types`,展示为具体费用类型,而不是统一显示通用。
|
||||||
|
- 预算类规则先预留预算字段和口径,不在本阶段新增预算流水表。
|
||||||
|
|
||||||
|
## 规则分类
|
||||||
|
|
||||||
|
- 预算类:预算不足、80% 预警、100% 超预算、冻结预算、跨部门预算、跨季度预算。
|
||||||
|
- 申请前置类:大额费用无申请,推广/培训/会务/软件/办公采购/招待无事前申请。
|
||||||
|
- 申请报销偏差类:金额超申请、超 10%、科目不一致、部门不一致、周期不一致、重复报销。
|
||||||
|
- 费用标准类:差旅、通信等真实标准;其他费用不伪造职级限额。
|
||||||
|
- 费用归类类:固定资产伪装为办公用品等科目错配风险。
|
||||||
|
- 材料完整性类:合同、方案、验收、签到、参与人、客户说明等材料缺失。
|
||||||
|
|
||||||
|
## 风险规则扩展字段
|
||||||
|
|
||||||
|
- `finance_rule_code`:可指向真实财务规则表,也可指向申请/预算/材料/归类制度口径。
|
||||||
|
- `finance_rule_sheet`:真实表时记录工作表名称,制度口径时记录口径名称。
|
||||||
|
- `business_stage`
|
||||||
|
- `expense_types`:用于意图识别后的费用类型匹配,也是规则中心适用场景的来源。
|
||||||
|
- `budget_required`
|
||||||
|
|
||||||
|
## 验证方式
|
||||||
|
|
||||||
|
- `AgentFoundationRiskRuleMixin` 能同步新增 JSON 规则。
|
||||||
|
- 新增规则不被识别为自然语言生成草稿并跳过。
|
||||||
|
- 规则资产的 `config_json` 能保留口径引用字段,且不指向已删除的口径型财务规则表。
|
||||||
|
- 规则资产的 `scenario_json` 能从 `expense_types` 生成具体费用场景。
|
||||||
|
- 至少验证预算类、申请前置类、费用标准类、材料完整性类各有规则同步成功。
|
||||||
20
document/development/expense-control-demo-data/TODO.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# 费用管控 Demo 数据规则补齐 TODO
|
||||||
|
|
||||||
|
## 2026-05-26
|
||||||
|
|
||||||
|
- [x] 建立开发记录目录。
|
||||||
|
- [x] 编写财务规则表开发记录。
|
||||||
|
- [x] 编写风险规则开发记录。
|
||||||
|
- [x] 设计费用类型财务规则定义。
|
||||||
|
- [x] 生成第一版财务规则 Excel 文件。
|
||||||
|
- [x] 让第一版财务规则表进入规则中心资产同步。
|
||||||
|
- [x] 补充规则中心同步测试。
|
||||||
|
- [x] 新增预算/申请/报销风险 JSON 规则。
|
||||||
|
- [x] 补充风险规则同步测试。
|
||||||
|
- [x] 补充财务规则资产同步脚本并同步演示库。
|
||||||
|
- [x] 纠正财务规则表口径:删除独立交通住宿细分表,非制度标准费用不再维护限额表。
|
||||||
|
- [x] 按真实职务金额分档口径二次纠正:删除 7 张没有实际金额分档的口径型财务规则表。
|
||||||
|
- [x] 调整风险规则引用,避免指向已删除的口径型财务规则表。
|
||||||
|
- [x] 修正规则中心适用场景:按 `expense_types` 展示具体费用类型,不再统一落为通用。
|
||||||
|
- [x] 运行后端定向测试。
|
||||||
|
- [x] 核对交付物和 TODO。
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Hermes 后台智能体架构总览
|
||||||
|
|
||||||
|
## 1. 定位与愿景
|
||||||
|
Hermes 是 X-Financial 系统中的**后台自动巡检与数据洞察中枢**。与处理实时对话的 UserAgent 不同,Hermes 专注于异步、长周期、大批量的任务,核心价值在于提供事前的**深度风险挖掘**和定期的**业财洞察报告**。
|
||||||
|
|
||||||
|
## 2. 系统拓扑图
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph 调度层
|
||||||
|
A[Cron Scheduler] -->|定时触发| B[Task Queue]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Hermes Agent 层
|
||||||
|
B -->|消费任务| C(Hermes Worker)
|
||||||
|
C --> D[Task Skills Router]
|
||||||
|
D --> E{RiskScanner Skill}
|
||||||
|
D --> F{ExpenseReport Skill}
|
||||||
|
D --> G{KnowledgeCheck Skill}
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph X-Financial 核心服务
|
||||||
|
E <--> H[(Expense DB)]
|
||||||
|
F <--> H
|
||||||
|
G <--> I[(LightRAG Graph/Vector)]
|
||||||
|
C <--> J[LLM Gateway / OpenAI]
|
||||||
|
C --> K[Notification Service / 企业微信]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 核心设计原则
|
||||||
|
1. **防抖与限流**:后台全量扫表时,必须分片执行,防止对主数据库造成 I/O 拥堵。
|
||||||
|
2. **幂等性保障**:每一个扫描任务和报告生成任务都应该具备唯一幂等键,避免因进程重启导致的重复发信或重复扣减信用分。
|
||||||
|
3. **隔离性**:Hermes 的进程应与对外提供 API 服务的 Web Server 物理/逻辑隔离,大模型限流策略(Token Rate Limit)应配置相互独立的账单通道。
|
||||||
|
|
||||||
|
## 4. 核心执行链路(示例:夜间风控巡检)
|
||||||
|
1. 凌晨 02:00,Cron 触发 `trigger_risk_scan` 任务。
|
||||||
|
2. Worker 拉取状态为 `draft` 和 `submitted` 且 `risk_scanned=False` 的单据。
|
||||||
|
3. 将近三个月的相关人员单据聚类,构建 Context。
|
||||||
|
4. 调用大模型,寻找“拆单”、“合谋”、“时间/地点异常”等隐蔽风险。
|
||||||
|
5. 将发现的风险写入 `hermes_risk_report` 表,并标记对应单据。
|
||||||
|
6. 任务结束,更新执行日志,等待早晨财务主管查阅。
|
||||||
46
document/development/hermes_agent/02_database_design.md
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Hermes 数据库表结构设计
|
||||||
|
|
||||||
|
为了支持后台异步任务的执行和长期记忆(风险标记、执行结果归档),我们需要在数据库中增加(或扩充)以下表结构。
|
||||||
|
|
||||||
|
## 1. 任务调度与执行表
|
||||||
|
|
||||||
|
### `hermes_task_config` (定时任务配置表)
|
||||||
|
用于管理所有的后台巡检和推送任务,支持动态调整频率与开关。
|
||||||
|
- `id`: string (UUID)
|
||||||
|
- `task_type`: string (enum: `global_risk_scan`, `weekly_expense_report`, `kb_validation`...)
|
||||||
|
- `cron_expression`: string (e.g., `0 2 * * *`)
|
||||||
|
- `is_enabled`: boolean (默认 True)
|
||||||
|
- `payload_template`: jsonb (预留参数,如扫描的时间窗口、特定部门过滤条件等)
|
||||||
|
- `updated_at`: datetime
|
||||||
|
|
||||||
|
### `hermes_task_execution_log` (任务执行日志表)
|
||||||
|
记录每次任务的执行状态,便于排错与溯源。
|
||||||
|
- `id`: string (UUID)
|
||||||
|
- `config_id`: string (外键,关联 `hermes_task_config`)
|
||||||
|
- `started_at`: datetime
|
||||||
|
- `completed_at`: datetime
|
||||||
|
- `status`: string (enum: `running`, `success`, `failed`)
|
||||||
|
- `result_summary`: string (执行结果的简要说明,如“扫描了 1500 条单据,发现 12 条高危”)
|
||||||
|
- `error_trace`: text (如果失败,存储错误堆栈)
|
||||||
|
|
||||||
|
## 2. 深度分析结果表
|
||||||
|
|
||||||
|
### `hermes_risk_report` (深度风险报告表)
|
||||||
|
用于存储 LLM 找出的深层逻辑风险。
|
||||||
|
- `id`: string (UUID)
|
||||||
|
- `claim_id`: string (外键,关联存疑的主单据 `expense_claim`)
|
||||||
|
- `execution_log_id`: string (外键,由哪次扫描任务产生的)
|
||||||
|
- `risk_level`: string (enum: `low`, `medium`, `high`, `critical`)
|
||||||
|
- `risk_type`: string (enum: `split_billing` 拆单, `collusion` 合谋, `policy_violation` 违规...)
|
||||||
|
- `risk_description`: text (大模型生成的自然语言报告,如“该单据与前天提交的单据存在拆分可能...”)
|
||||||
|
- `related_claim_ids`: jsonb (存储关联的同谋/相关单据 ID 列表,提供上下文线索)
|
||||||
|
- `status`: string (enum: `pending_review` 待人工复核, `confirmed` 已确认为风险, `dismissed` 已忽略)
|
||||||
|
|
||||||
|
## 3. 现有表的平滑改造
|
||||||
|
|
||||||
|
### 修改 `employee` 表 (员工信用分预留)
|
||||||
|
- **新增字段** `compliance_score`: int (默认 100,由 Hermes 动态扣减或恢复,用于风控引擎调节对该员工的抽查率和宽容度)
|
||||||
|
|
||||||
|
### 修改 `expense_claim` 表 (风控标记)
|
||||||
|
- **新增字段** `hermes_scanned_at`: datetime (记录该单据上次被 Hermes 扫描的时间,防止重复扫描)
|
||||||
|
- **新增字段** `hermes_risk_flag`: boolean (快速判断该单子是否被挂载了 `hermes_risk_report`)
|
||||||
32
document/development/hermes_agent/03_risk_scan_module.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# 深度风险扫描模块设计 (Risk Scan Module)
|
||||||
|
|
||||||
|
## 1. 业务目标
|
||||||
|
将单点硬规则风控(如:发票大于 500 元是否合规)升级为**图谱式全局风控**。Hermes 将利用大语言模型(LLM)的逻辑推理能力,在海量历史数据中寻找隐藏的违规模式。
|
||||||
|
|
||||||
|
## 2. 核心扫描链路
|
||||||
|
本模块将作为一个独立的 Skill 被定时任务触发。
|
||||||
|
|
||||||
|
### 第一步:数据快照聚合
|
||||||
|
- **提取目标**:拉取状态为 `draft`、`submitted` 且最近 30 天内活跃的报销单,同时带出相关的发票明细。
|
||||||
|
- **降维处理**:为避免超出大模型的 Token 上下文限制,必须对单据信息进行降维。仅提取:`申请人、时间、地点、商户名、金额、报销类型` 形成精简的 CSV 或 JSON Lines 格式。
|
||||||
|
|
||||||
|
### 第二步:大模型批量推理 (LLM Batch Inference)
|
||||||
|
- **风险定义植入**:通过 System Prompt 将目前财务最头疼的几类风险定义给模型(如拆单、套现、虚假连号发票)。
|
||||||
|
- **执行方式**:将数据按“同部门”或“同地域”分块 (Chunking) 喂给大模型。
|
||||||
|
- **Prompt 示例**:
|
||||||
|
```markdown
|
||||||
|
你是一个内控审计 Agent。以下是某部门近半个月的报销流水清单。
|
||||||
|
请找出其中是否存在:
|
||||||
|
1. 拆单行为(同人、同地点、连日、小额累加)
|
||||||
|
2. 聚众套现行为(不同人、同偏僻餐馆、同日极高额)
|
||||||
|
如果发现风险,请输出对应的单号集合以及你的推理过程。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:风险标记与处置
|
||||||
|
- 解析大模型返回的结构化 JSON。
|
||||||
|
- 对被判定的高危单据,在主库中插入 `hermes_risk_report` 记录。
|
||||||
|
- **动作反馈**:如果该单据正处于 `submitted` 状态,并且得分极高(如虚假连号发票),可以通过 X-Financial 原有接口自动注入“退回”动作,并附加大模型的分析日志。
|
||||||
|
|
||||||
|
## 3. 防抖与自我迭代
|
||||||
|
- **扫描去重**:利用 `expense_claim.hermes_scanned_at` 防止已经出具过报告的单据被重复投入分析队列。
|
||||||
|
- **人工纠偏 (Human-in-the-loop)**:当财务在前端驳回 Hermes 的风险提示(即认为没问题)时,事件将被记录。Hermes 可通过夜间的反思任务优化下一次 Prompt 中的判定容忍度。
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# 动态费控与洞察报告模块设计 (Expense Report Module)
|
||||||
|
|
||||||
|
## 1. 业务目标
|
||||||
|
基于海量的流水账单,定期(周/月)由 Hermes Agent 为部门管理者或财务总监自动生成具有**业务洞察力**的归因分析报告,将冷冰冰的数字转化为具有指导意义的自然语言建议。
|
||||||
|
|
||||||
|
## 2. 核心分析链路
|
||||||
|
|
||||||
|
### 第一步:BI 数据聚合 (Data Aggregation)
|
||||||
|
- 借助 ORM 或底层 Data Warehouse (如有),Hermes 执行预置的聚合查询。
|
||||||
|
- **采集核心指标**:
|
||||||
|
- 本期各部门总花费及环比/同比变动率。
|
||||||
|
- 各类目(如打车、机票、住宿、招待)的占比变化。
|
||||||
|
- Top 10 花费最多的商户(如特定几家酒店或订票平台)。
|
||||||
|
- 各类目超额/退回率最高的人员画像。
|
||||||
|
|
||||||
|
### 第二步:大模型归因分析 (LLM Attribution Analysis)
|
||||||
|
将硬性的聚合数据转化为结构化 Prompt,让 LLM 充当“财务分析师”。
|
||||||
|
- **Prompt 示例**:
|
||||||
|
```markdown
|
||||||
|
你是企业的财务总监助理,请阅读以下【本月报销聚合数据】。
|
||||||
|
请帮我撰写一份 300 字以内的执行摘要报告。
|
||||||
|
重点指出:
|
||||||
|
1. 哪个部门/哪类费用增长最快?原因可能是什么?
|
||||||
|
2. 我们的长尾开销集中在哪些商户?是否存在能够跟商户谈“协议价”的谈判空间?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第三步:多渠道报告分发 (Report Delivery)
|
||||||
|
- **生成制品**:Hermes 利用代码解释器 (如有) 或 Markdown 引擎,将图表与文本融合成正式的 PDF 或长图。
|
||||||
|
- **触达渠道**:
|
||||||
|
- **推送机制**:调用企微/钉钉机器人 API,直接向管理者的工作台推送“上周费控简报”。
|
||||||
|
- **交互追问**:管理者收到简报卡片后,可以在对话框里直接@Hermes 追问:“详细列一下研发部上周在北京住宿的那 5 万块钱是怎么花的”,Hermes 将调取缓存的报告上下文立即答复。
|
||||||
|
|
||||||
|
## 3. 商业价值落地
|
||||||
|
这项功能极大地解放了财务部的报表处理时间。通过提供前置的谈判线索(如发现某经济型酒店的高频住客其实都可以导流到协议酒店),可以给公司带来直接的差旅成本节约。
|
||||||
63
document/development/hermes_agent/05_deployment_and_cron.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# 部署与任务调度架构方案 (Deployment & Cron)
|
||||||
|
|
||||||
|
## 1. 业务诉求分析
|
||||||
|
Hermes 作为纯后台的智能体,它的执行过程长达几分钟甚至几小时。它绝不能与提供给前台 HTTP 请求的主 Web 服务混合在同一个事件循环(Event Loop)或同步进程中,否则会导致 API 严重堵塞和超时崩溃。
|
||||||
|
|
||||||
|
因此,Hermes 的部署需要进行**进程级解耦**。
|
||||||
|
|
||||||
|
## 2. 选型对比与推荐方案
|
||||||
|
|
||||||
|
### 方案 A:Celery + Redis (重型/工业级标准)
|
||||||
|
- **优势**:业界最成熟的 Python 异步任务队列,支持极其复杂的 Cron 配置,原生支持任务重试、失败回调以及分布式扩展。
|
||||||
|
- **劣势**:增加系统组件(必须额外部署 Redis / RabbitMQ 容器),运维成本相对较高。
|
||||||
|
- **结论**:如果 X-Financial 后续要承载上千人的企业报销,这是**首选必经之路**。
|
||||||
|
|
||||||
|
### 方案 B:APScheduler + Background Worker (中型/轻量级)
|
||||||
|
- **优势**:直接在 Python 进程内运行,无需额外的消息队列组件。可以用一个单独的 Docker 容器运行 `python run_hermes_scheduler.py`。
|
||||||
|
- **劣势**:多节点部署时难以控制并发(可能会多个节点同时执行同样的任务),需要引入基于数据库表或 Redis 的分布式锁。
|
||||||
|
- **结论**:适合初期快速跑通 MVP 的方案。
|
||||||
|
|
||||||
|
## 3. 推荐架构:基于 Redis 分布式锁的独立容器方案
|
||||||
|
结合当前现状,建议采用 **方案B 叠加 分布式锁** 的轻量微服务架构。
|
||||||
|
|
||||||
|
### 部署拓扑:
|
||||||
|
```yaml
|
||||||
|
# docker-compose.yml 示例切片
|
||||||
|
services:
|
||||||
|
x-financial-api:
|
||||||
|
build: .
|
||||||
|
command: uvicorn main:app
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
|
||||||
|
x-financial-hermes:
|
||||||
|
build: .
|
||||||
|
command: python scripts/start_hermes_daemon.py
|
||||||
|
# 这个容器不暴露外部端口,纯粹在后台运行定时任务和消费队列
|
||||||
|
```
|
||||||
|
|
||||||
|
### 执行伪代码 (`start_hermes_daemon.py`)
|
||||||
|
```python
|
||||||
|
from apscheduler.schedulers.blocking import BlockingScheduler
|
||||||
|
from app.services.system_hermes import SystemHermesService
|
||||||
|
|
||||||
|
scheduler = BlockingScheduler()
|
||||||
|
hermes = SystemHermesService()
|
||||||
|
|
||||||
|
# 每天凌晨 3 点执行深度风控扫表
|
||||||
|
@scheduler.scheduled_job('cron', hour=3, minute=0)
|
||||||
|
def job_risk_scan():
|
||||||
|
# 获取分布式锁防止集群脑裂重复执行
|
||||||
|
if acquire_redis_lock("hermes:lock:risk_scan"):
|
||||||
|
try:
|
||||||
|
hermes.run_query("执行全局风控扫描技能...", skills=["global_risk_scan"])
|
||||||
|
finally:
|
||||||
|
release_redis_lock("hermes:lock:risk_scan")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
scheduler.start()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 容灾与可观测性保障
|
||||||
|
- **日志采集**:确保 `hermes.run_query` 及其后台生成的 stdout/stderr 日志能够写入 ELK 或文件系统中,方便第二天的运维排查。
|
||||||
|
- **告警链路**:如果调度系统挂掉或者大模型连续多次返回失败状态码,必须通过 Webhook 飞书/钉钉及时告警系统管理员。
|
||||||
1828
document/development/intelligent-expense-control-platform/index.html
Normal file
@@ -0,0 +1,896 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>X-Financial 轻量知识库归集与问答优化开发文档</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--green-900: #064e3b;
|
||||||
|
--green-700: #047857;
|
||||||
|
--green-600: #10b981;
|
||||||
|
--green-100: #dff8ec;
|
||||||
|
--green-50: #effcf6;
|
||||||
|
--blue-700: #1d4ed8;
|
||||||
|
--blue-50: #eff6ff;
|
||||||
|
--amber-600: #d97706;
|
||||||
|
--amber-50: #fffbeb;
|
||||||
|
--red-600: #dc2626;
|
||||||
|
--red-50: #fef2f2;
|
||||||
|
--ink-900: #071124;
|
||||||
|
--ink-700: #24324a;
|
||||||
|
--ink-600: #58677f;
|
||||||
|
--ink-500: #728098;
|
||||||
|
--line: #dbe5ef;
|
||||||
|
--line-strong: #c6d4e2;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--surface-soft: #f7fafc;
|
||||||
|
--shadow: 0 10px 26px rgba(15, 23, 42, 0.08);
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(239, 252, 246, 0.78), rgba(247, 250, 252, 0.96) 360px),
|
||||||
|
var(--surface-soft);
|
||||||
|
color: var(--ink-900);
|
||||||
|
font-family: "IBM Plex Sans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", sans-serif;
|
||||||
|
line-height: 1.62;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 23, 42, 0.06);
|
||||||
|
color: var(--ink-700);
|
||||||
|
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 276px minmax(0, 1fr);
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
align-self: start;
|
||||||
|
height: 100dvh;
|
||||||
|
padding: 28px 22px;
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-mark {
|
||||||
|
display: grid;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(145deg, var(--green-700), var(--green-600));
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 800;
|
||||||
|
box-shadow: 0 10px 18px rgba(16, 185, 129, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-label {
|
||||||
|
margin: 22px 0 8px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--ink-700);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav a:hover {
|
||||||
|
background: var(--green-50);
|
||||||
|
color: var(--green-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
margin-right: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--line-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 34px 42px 56px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.14fr) minmax(300px, 0.86fr);
|
||||||
|
gap: 24px;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel,
|
||||||
|
.metric-panel,
|
||||||
|
section,
|
||||||
|
.card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-panel {
|
||||||
|
padding: 30px;
|
||||||
|
border-color: rgba(16, 185, 129, 0.28);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(239, 252, 246, 0.9)),
|
||||||
|
var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kicker {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.24);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: var(--green-900);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
max-width: 780px;
|
||||||
|
margin: 16px 0 14px;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.18;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy,
|
||||||
|
.section-desc,
|
||||||
|
.card p,
|
||||||
|
.phase span,
|
||||||
|
.footnote {
|
||||||
|
color: var(--ink-600);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 6px 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--ink-700);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.primary {
|
||||||
|
background: var(--green-700);
|
||||||
|
border-color: var(--green-700);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-value {
|
||||||
|
margin-top: 5px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.28;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
margin-top: 18px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--green-50);
|
||||||
|
color: var(--green-900);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2,
|
||||||
|
.grid-3,
|
||||||
|
.grid-4 {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
padding: 17px;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card ul,
|
||||||
|
.card ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
color: var(--ink-600);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card li + li {
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-green {
|
||||||
|
background: var(--green-50);
|
||||||
|
border-color: rgba(16, 185, 129, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-blue {
|
||||||
|
background: var(--blue-50);
|
||||||
|
border-color: rgba(37, 99, 235, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-amber {
|
||||||
|
background: var(--amber-50);
|
||||||
|
border-color: rgba(217, 119, 6, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tone-red {
|
||||||
|
background: var(--red-50);
|
||||||
|
border-color: rgba(220, 38, 38, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 132px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-key {
|
||||||
|
color: var(--green-900);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-body {
|
||||||
|
color: var(--ink-600);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e5eef8;
|
||||||
|
overflow-x: auto;
|
||||||
|
font-family: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.65;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-key {
|
||||||
|
color: var(--green-900);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phase-body strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--ink-900);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check {
|
||||||
|
position: relative;
|
||||||
|
padding: 12px 12px 12px 34px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-soft);
|
||||||
|
color: var(--ink-700);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 13px;
|
||||||
|
top: 17px;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--green-600);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink-700);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-chip:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.5);
|
||||||
|
color: var(--green-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
margin-top: 22px;
|
||||||
|
color: var(--ink-500);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1080px) {
|
||||||
|
.shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.grid-2,
|
||||||
|
.grid-3,
|
||||||
|
.grid-4 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 24px 18px 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head,
|
||||||
|
.phase,
|
||||||
|
.flow-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="logo-mark">KB</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand-title">轻量知识库归集</div>
|
||||||
|
<div class="brand-subtitle">LightRAG + Hermes 优化方案</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="nav-label">文档导航</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="#position"><span class="nav-dot"></span>定位与边界</a>
|
||||||
|
<a href="#architecture"><span class="nav-dot"></span>轻量架构</a>
|
||||||
|
<a href="#borrow"><span class="nav-dot"></span>Yuxi 借鉴点</a>
|
||||||
|
<a href="#modules"><span class="nav-dot"></span>模块设计</a>
|
||||||
|
<a href="#retrieval"><span class="nav-dot"></span>召回与回答</a>
|
||||||
|
<a href="#delivery"><span class="nav-dot"></span>实施路线</a>
|
||||||
|
<a href="#quality"><span class="nav-dot"></span>验收标准</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="nav-label">硬约束</div>
|
||||||
|
<div class="nav">
|
||||||
|
<a href="#quality"><span class="nav-dot"></span>不做重平台</a>
|
||||||
|
<a href="#quality"><span class="nav-dot"></span>证据优先回答</a>
|
||||||
|
<a href="#quality"><span class="nav-dot"></span>增量任务可追踪</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div class="hero">
|
||||||
|
<div class="hero-panel">
|
||||||
|
<span class="kicker">开发文档 · 先定边界再实现</span>
|
||||||
|
<h1>X-Financial 轻量知识库归集与问答优化方案</h1>
|
||||||
|
<p class="hero-copy">
|
||||||
|
本方案不把 X-Financial 改造成专业知识库平台,而是在现有
|
||||||
|
<code>LightRAG</code>、<code>Hermes</code>、<code>AgentRun</code>
|
||||||
|
和知识库 UI 上补齐最薄弱的归集、分块、召回和证据回答能力。
|
||||||
|
Yuxi 只作为成熟设计参考,借鉴其统一解析、分块预设和评估思想。
|
||||||
|
</p>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<span class="pill primary">保留现有 LightRAG</span>
|
||||||
|
<span class="pill">轻量 Parser</span>
|
||||||
|
<span class="pill">条款级分块</span>
|
||||||
|
<span class="pill">混合召回</span>
|
||||||
|
<span class="pill">证据化回答</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-panel">
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">核心目标</div>
|
||||||
|
<div class="metric-value">准、快、可解释</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">改造范围</div>
|
||||||
|
<div class="metric-value">归集与召回链路,不重做平台</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<div class="metric-label">并发预期</div>
|
||||||
|
<div class="metric-value">5-10 用户查询可降级、有上限</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="position">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">定位与边界</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
知识库在 X-Financial 中是业务辅助能力,不是独立知识管理产品。
|
||||||
|
因此实现必须克制:不引入重型多租户平台,不替换现有业务数据模型,
|
||||||
|
不把知识库 UI 做成复杂后台,只补齐影响问答质量的关键薄层。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">轻量优先</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="card tone-green">
|
||||||
|
<h3>要解决的问题</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Word、PDF、Excel 等文件进入 RAG 前缺少统一结构。</li>
|
||||||
|
<li>制度类文档如果按普通 chunk 切分,条款容易被切散。</li>
|
||||||
|
<li>问答质量依赖向量召回,缺少关键词、标题、条款补召回。</li>
|
||||||
|
<li>效果优化缺少固定评测集,容易靠体感判断。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card tone-blue">
|
||||||
|
<h3>保留的现有能力</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>KnowledgeService</code> 继续负责文件库和状态入口。</li>
|
||||||
|
<li><code>KnowledgeRagService</code> 继续封装 LightRAG 查询和入库。</li>
|
||||||
|
<li><code>KnowledgeIndexTaskManager</code> 继续承接 Hermes 增量任务。</li>
|
||||||
|
<li>前端知识管理继续保持简单文件夹与文件列表形态。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card tone-amber">
|
||||||
|
<h3>明确不做</h3>
|
||||||
|
<ul>
|
||||||
|
<li>不整体引入 Yuxi 平台。</li>
|
||||||
|
<li>不把存储改成 Milvus + Neo4j。</li>
|
||||||
|
<li>不一次性接入全量 OCR 引擎。</li>
|
||||||
|
<li>不新增复杂多租户知识库后台。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="architecture">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">轻量架构</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
新增能力只放在 LightRAG 前后两侧:前侧负责把文件变成稳定 Markdown 和业务友好 chunk,
|
||||||
|
后侧负责混合召回、证据重排和可靠回答。LightRAG 仍是主召回核心。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">薄层增强</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="diagram">原始文件
|
||||||
|
├── docx / pdf / xlsx / pptx / csv / txt
|
||||||
|
↓
|
||||||
|
轻量 Parser
|
||||||
|
├── 统一 Markdown
|
||||||
|
├── 表格上下文
|
||||||
|
└── 页码 / sheet / 条款路径
|
||||||
|
↓
|
||||||
|
Chunk Preset
|
||||||
|
├── laws:制度条款
|
||||||
|
├── qa:常见问答
|
||||||
|
└── table:表格行组
|
||||||
|
↓
|
||||||
|
现有 LightRAG / Qdrant
|
||||||
|
↓
|
||||||
|
混合召回
|
||||||
|
├── LightRAG 语义召回
|
||||||
|
├── 标题与条款关键词召回
|
||||||
|
└── 轻量重排 top 3-5
|
||||||
|
↓
|
||||||
|
证据化回答
|
||||||
|
├── 命中证据
|
||||||
|
├── 直接结论
|
||||||
|
└── 缺失信息说明</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="borrow">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">Yuxi 借鉴点</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
Yuxi 的价值不在于整套平台,而在于成熟的归集分层思想:
|
||||||
|
文件先解析成 Markdown,再按场景分块,再索引,再评估。
|
||||||
|
这些思想可以小规模落地到现有服务内。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">借鉴而非搬运</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-4">
|
||||||
|
<div class="card">
|
||||||
|
<h3>统一 Parser</h3>
|
||||||
|
<p>学习 Yuxi 把多格式文件统一转 Markdown 的入口设计,但只实现 X-Financial 当前需要的格式。</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>分块 Preset</h3>
|
||||||
|
<p>借鉴 RAGFlow-like preset。先做 <code>laws</code>、<code>qa</code>、<code>table</code> 三类。</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>两阶段状态</h3>
|
||||||
|
<p>内部区分解析和索引。UI 仍可显示简单归纳状态,后台记录真实失败点。</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>轻量评测</h3>
|
||||||
|
<p>不做评估平台,只维护 JSON 用例和脚本,持续检查召回与回答质量。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-list">
|
||||||
|
<a class="link-chip" href="https://github.com/xerrors/Yuxi">Yuxi README</a>
|
||||||
|
<a class="link-chip" href="https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/plugins/parser/unified.py">Yuxi unified parser</a>
|
||||||
|
<a class="link-chip" href="https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/knowledge/chunking/ragflow_like/presets.py">Yuxi chunk presets</a>
|
||||||
|
<a class="link-chip" href="https://github.com/xerrors/Yuxi/blob/main/backend/package/yuxi/knowledge/chunking/ragflow_like/parsers/laws.py">Yuxi laws parser</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="modules">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">模块设计</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
新增模块必须小而清楚,避免把逻辑继续堆进单个 Service。
|
||||||
|
单个核心文件控制在 800 行以内,优先按解析、分块、召回、评测拆分。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">职责拆分</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flow">
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-key">knowledge_parser.py</div>
|
||||||
|
<div class="flow-body">
|
||||||
|
负责把 docx、pdf、xlsx、csv、txt 等文件转成 Markdown。
|
||||||
|
输出正文、标题路径、页码、sheet、表头、解析告警。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-key">knowledge_chunking.py</div>
|
||||||
|
<div class="flow-body">
|
||||||
|
根据文件夹、文件类型和文档特征选择分块策略。
|
||||||
|
第一批只实现制度、问答、表格三类。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-key">knowledge_retrieval.py</div>
|
||||||
|
<div class="flow-body">
|
||||||
|
在 LightRAG 命中结果外补充关键词、条款标题和文件名召回。
|
||||||
|
最终输出小而准的证据块。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-row">
|
||||||
|
<div class="flow-key">knowledge_eval.py</div>
|
||||||
|
<div class="flow-body">
|
||||||
|
读取轻量评测用例,检查 expected 文件、关键词、证据和答案约束。
|
||||||
|
用于每次调整参数后的回归验证。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="retrieval">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">召回与回答策略</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
目标不是让模型更会猜,而是让系统给模型更可靠的证据。
|
||||||
|
制度问题优先命中条款,表格问题保留表头与行上下文,回答必须暴露依据和缺失信息。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">证据优先</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid-3">
|
||||||
|
<div class="card tone-green">
|
||||||
|
<h3>召回层</h3>
|
||||||
|
<ul>
|
||||||
|
<li>LightRAG 继续提供语义召回。</li>
|
||||||
|
<li>条款号、标题、文件名、关键词做补召回。</li>
|
||||||
|
<li>召回候选数量有上限,避免并发下无限扩张。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card tone-blue">
|
||||||
|
<h3>重排层</h3>
|
||||||
|
<ul>
|
||||||
|
<li>优先保留含问题关键词、标题路径和条款语义的块。</li>
|
||||||
|
<li>制度类按条款完整度加权。</li>
|
||||||
|
<li>最终给回答链路 3-5 条高质量证据。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card tone-amber">
|
||||||
|
<h3>回答层</h3>
|
||||||
|
<ul>
|
||||||
|
<li>能直接基于证据回答时,不强制二次模型整理。</li>
|
||||||
|
<li>模型只做压缩表达,不凭空补事实。</li>
|
||||||
|
<li>证据不足时明确说明缺什么。</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="delivery">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">实施路线</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
分四步小步交付。每一步都能单独验证,不把解析、索引、召回和评测揉成一次大改。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">渐进落地</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="timeline">
|
||||||
|
<div class="phase">
|
||||||
|
<div class="phase-key">P0 / 文档落地</div>
|
||||||
|
<div class="phase-body">
|
||||||
|
<strong>先明确轻量边界</strong>
|
||||||
|
<span>完成本文档,确认不做重平台、不替换存储、不一次性引入复杂 OCR。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase">
|
||||||
|
<div class="phase-key">P1 / 统一解析</div>
|
||||||
|
<div class="phase-body">
|
||||||
|
<strong>补齐文件归集质量</strong>
|
||||||
|
<span>新增 Parser,把 Word、PDF、Excel、CSV、TXT 稳定转为 Markdown,并保存解析产物供索引复用。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase">
|
||||||
|
<div class="phase-key">P2 / 场景分块</div>
|
||||||
|
<div class="phase-body">
|
||||||
|
<strong>提升制度与表格命中率</strong>
|
||||||
|
<span>实现 laws、qa、table 三类分块。制度按章、节、条、款保留完整语义,表格保留 sheet、表头和行上下文。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase">
|
||||||
|
<div class="phase-key">P3 / 混合召回</div>
|
||||||
|
<div class="phase-body">
|
||||||
|
<strong>减少答偏和漏召回</strong>
|
||||||
|
<span>在 LightRAG 命中外补充关键词、条款标题、文件名召回,输出可控数量的证据块。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="phase">
|
||||||
|
<div class="phase-key">P4 / 轻量评测</div>
|
||||||
|
<div class="phase-body">
|
||||||
|
<strong>把效果优化变成可回归</strong>
|
||||||
|
<span>建设 30-50 条远光软件制度风格问答用例,覆盖报销、差旅、发票、预算、税务等高频问题。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="quality">
|
||||||
|
<div class="section-head">
|
||||||
|
<div>
|
||||||
|
<h2 class="section-title">验收标准</h2>
|
||||||
|
<p class="section-desc">
|
||||||
|
验收不只看页面状态,而要看文件是否真实入库、召回是否命中文档依据、
|
||||||
|
回答是否引用证据,以及并发访问时是否能稳定降级。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="tag">真实验证</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checklist">
|
||||||
|
<div class="check">Word、PDF、Excel、CSV、TXT 文件能生成可读 Markdown,且解析产物可复用。</div>
|
||||||
|
<div class="check">制度类文件能按章、节、条、款形成相对完整的证据块。</div>
|
||||||
|
<div class="check">Excel 表格问答能保留 sheet、表头、关键列和业务行上下文。</div>
|
||||||
|
<div class="check">Hermes 增量任务能区分解析失败、索引失败和归纳失败。</div>
|
||||||
|
<div class="check">常见制度问答优先返回证据化直接答案,模型超时时仍有可读降级答案。</div>
|
||||||
|
<div class="check">5-10 个用户同时访问时,查询候选数、重排数、模型调用数都有明确上限。</div>
|
||||||
|
<div class="check">轻量评测集覆盖至少 30 条问题,并记录命中文件、关键词和答案约束。</div>
|
||||||
|
<div class="check">不引入 Yuxi 平台级依赖,不改变现有知识库 UI 的主体交互。</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="footnote">
|
||||||
|
后续实现时,优先在现有定向测试基础上补充 Parser、Chunking、Retrieval 和 Knowledge Eval 的小测试。
|
||||||
|
后端验证优先在 Docker 容器 <code>x-financial-main</code> 中运行。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
X-Financial 轻量知识库归集与问答优化开发文档 · 放置位置:document/development/knowledge-answers/lightweight-knowledge-ingestion-design.html
|
||||||
|
</footer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
746
document/development/ui/personal-workbench-home-reference.html
Normal file
@@ -0,0 +1,746 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>X-Financial 个人工作台首页参考稿</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f4f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--line: #e4ebf3;
|
||||||
|
--line-strong: #d6e1ed;
|
||||||
|
--text: #0f172a;
|
||||||
|
--muted: #64748b;
|
||||||
|
--soft: #f8fbff;
|
||||||
|
--green: #0f9f6e;
|
||||||
|
--green-dark: #047857;
|
||||||
|
--blue: #2563eb;
|
||||||
|
--amber: #b7791f;
|
||||||
|
--red: #d93025;
|
||||||
|
--shadow: 0 18px 44px rgba(15, 23, 42, 0.08);
|
||||||
|
font-family:
|
||||||
|
"Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC",
|
||||||
|
"Noto Sans CJK SC", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 18% 0%, rgba(16, 185, 129, 0.13), transparent 29%),
|
||||||
|
radial-gradient(circle at 86% 12%, rgba(37, 99, 235, 0.10), transparent 24%),
|
||||||
|
var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
width: 1440px;
|
||||||
|
min-height: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 84px 1fr;
|
||||||
|
background: rgba(255, 255, 255, 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
padding: 20px 12px;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #cbd5e1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr auto;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(135deg, #10b981, #2563eb);
|
||||||
|
color: white;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0;
|
||||||
|
box-shadow: 0 12px 26px rgba(16, 185, 129, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: #ffffff;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 22px 30px 30px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 58px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crumb span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-pill {
|
||||||
|
height: 38px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px 0 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
color: #334155;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #e8f7f0;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 254px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 230px minmax(0, 1fr);
|
||||||
|
gap: 22px;
|
||||||
|
padding: 24px 28px 22px 20px;
|
||||||
|
border: 1px solid rgba(15, 159, 110, 0.16);
|
||||||
|
border-radius: 18px;
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(247, 255, 251, 0.98), rgba(255, 255, 255, 0.98) 55%, rgba(244, 249, 255, 0.96)),
|
||||||
|
var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
right: -110px;
|
||||||
|
bottom: -138px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-wrap {
|
||||||
|
position: relative;
|
||||||
|
min-height: 206px;
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-wrap::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 4px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(16, 185, 129, 0.13);
|
||||||
|
filter: blur(13px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-wrap img {
|
||||||
|
position: relative;
|
||||||
|
width: 176px;
|
||||||
|
height: auto;
|
||||||
|
filter: drop-shadow(0 24px 26px rgba(15, 23, 42, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
align-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 860px;
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 1.32;
|
||||||
|
font-weight: 850;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
max-width: 880px;
|
||||||
|
color: #53637a;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
min-height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 15px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.74);
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer-text {
|
||||||
|
padding: 0 12px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
height: 42px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 11px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.secondary {
|
||||||
|
border: 1px solid rgba(15, 118, 110, 0.18);
|
||||||
|
background: linear-gradient(180deg, #ffffff, #f2faf7);
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
color: #ffffff;
|
||||||
|
box-shadow: 0 12px 24px rgba(16, 185, 129, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-card {
|
||||||
|
min-height: 116px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.055);
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.green {
|
||||||
|
background: #e8f7f0;
|
||||||
|
color: var(--green-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.blue {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.amber {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-icon.slate {
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-arrow {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-card p {
|
||||||
|
margin: -6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lower-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
box-shadow: 0 10px 26px rgba(15, 23, 42, 0.045);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head {
|
||||||
|
min-height: 58px;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head span {
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rows {
|
||||||
|
padding: 2px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
min-height: 70px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
border-top: 1px solid #edf2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-icon {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 13px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eefaf4;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-copy strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-copy small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 5px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-action {
|
||||||
|
min-width: 78px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.26);
|
||||||
|
border-radius: 999px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--green-dark);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: #f6fffb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.amount {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-left: 10px;
|
||||||
|
min-width: 92px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: var(--blue);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wide-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.25fr 1fr;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-list {
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-section {
|
||||||
|
min-height: 178px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-section .panel-head {
|
||||||
|
border-bottom: 0;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-height: 88px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--soft);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
margin-top: 9px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rail {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.lower-grid,
|
||||||
|
.wide-panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intent-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.policy-list {
|
||||||
|
border-left: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app">
|
||||||
|
<aside class="rail" aria-label="侧边导航">
|
||||||
|
<div class="brand-mark">XF</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<div class="nav-item active">工作台</div>
|
||||||
|
<div class="nav-item">申请</div>
|
||||||
|
<div class="nav-item">审批</div>
|
||||||
|
<div class="nav-item">规则</div>
|
||||||
|
<div class="nav-item">知识</div>
|
||||||
|
</nav>
|
||||||
|
<div class="nav-item">设置</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="crumb">
|
||||||
|
<strong>个人工作台</strong>
|
||||||
|
<span>把费用申请、报销处理、进度查询和制度问答集中到一个入口。</span>
|
||||||
|
</div>
|
||||||
|
<div class="user-pill">
|
||||||
|
<span class="avatar">A</span>
|
||||||
|
<span>admin</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<article class="hero">
|
||||||
|
<div class="bot-wrap">
|
||||||
|
<img src="../../../web/src/assets/robot-helper.png" alt="" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-copy">
|
||||||
|
<h1 class="hero-title">嗨,admin,描述您想做的事,AI 会直接帮您处理</h1>
|
||||||
|
<p class="hero-subtitle">
|
||||||
|
我会自动识别您的意图,协助完成费用申请、报销、查询和制度问答等业务工作,
|
||||||
|
并把事情推进到可执行的下一步。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<div class="composer-text">例如:帮我查一下上周提交的差旅报销到哪一步了</div>
|
||||||
|
<button class="btn secondary">上传票据</button>
|
||||||
|
<button class="btn primary">开始处理</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<section class="intent-grid" aria-label="业务入口">
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon green">申</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>费用申请</h3>
|
||||||
|
<p>发起招待、差旅、采购等费用事项</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon blue">报</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>报销处理</h3>
|
||||||
|
<p>上传票据,生成草稿并核对材料</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon amber">查</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>进度查询</h3>
|
||||||
|
<p>查询单据状态、审批节点和到账情况</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="intent-card">
|
||||||
|
<div class="intent-top">
|
||||||
|
<span class="intent-icon slate">问</span>
|
||||||
|
<span class="intent-arrow">→</span>
|
||||||
|
</div>
|
||||||
|
<h3>制度问答</h3>
|
||||||
|
<p>咨询标准、附件要求和可报销边界</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="lower-grid">
|
||||||
|
<article class="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>报销待办</h3>
|
||||||
|
<span>查看全部</span>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">招</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>业务招待报销建议补参与人员</strong>
|
||||||
|
<small>AI 建议:补充客户单位、客户人数、我方陪同人员</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">去补充</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">旅</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>差旅报销单待提交</strong>
|
||||||
|
<small>补齐出发交通,可直接生成报销单</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">继续填</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">票</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>有 5 张票据未关联报销单</strong>
|
||||||
|
<small>其中 3 张疑似交通费,可合并生成交通报销</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">去整理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="panel">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>报销进度</h3>
|
||||||
|
<span>查看全部</span>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">差</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>差旅报销</strong>
|
||||||
|
<small>提交时间:2026-05-03</small>
|
||||||
|
</div>
|
||||||
|
<div><span class="amount">¥3,280</span><span class="status">主管审批中</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">交</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>交通报销</strong>
|
||||||
|
<small>提交时间:2026-05-02</small>
|
||||||
|
</div>
|
||||||
|
<div><span class="amount">¥126</span><span class="status">财务复核中</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">采</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>办公采购</strong>
|
||||||
|
<small>提交时间:2026-05-01</small>
|
||||||
|
</div>
|
||||||
|
<div><span class="amount">¥458</span><span class="status">已到账</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel wide-panel">
|
||||||
|
<article class="mini-section">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>智能概览</h3>
|
||||||
|
<span>本月</span>
|
||||||
|
</header>
|
||||||
|
<div class="metric-strip">
|
||||||
|
<div class="metric"><strong>12</strong><span>待处理事项</span></div>
|
||||||
|
<div class="metric"><strong>86%</strong><span>材料完整率</span></div>
|
||||||
|
<div class="metric"><strong>2.4天</strong><span>平均审批时长</span></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="mini-section policy-list">
|
||||||
|
<header class="panel-head">
|
||||||
|
<h3>最新报销制度</h3>
|
||||||
|
<span>查看全部</span>
|
||||||
|
</header>
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="row-icon">规</span>
|
||||||
|
<div class="row-copy">
|
||||||
|
<strong>差旅报销管理办法(2026版)</strong>
|
||||||
|
<small>更新住宿标准与交通等级规则</small>
|
||||||
|
</div>
|
||||||
|
<span class="row-action">查看</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
document/development/ui/personal-workbench-home-reference.png
Normal file
|
After Width: | Height: | Size: 123 KiB |
24
fix_init.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
file_path = 'server/src/app/models/__init__.py'
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# Add imports
|
||||||
|
imports_to_add = "from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog\nfrom app.models.hermes_report import HermesRiskReport\n"
|
||||||
|
content = re.sub(
|
||||||
|
r'(from app\.models\.organization import OrganizationUnit)',
|
||||||
|
imports_to_add + r'\1',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add to __all__
|
||||||
|
content = re.sub(
|
||||||
|
r'(\s*"OrganizationUnit",)',
|
||||||
|
r'\n "HermesTaskConfig",\n "HermesTaskExecutionLog",\n "HermesRiskReport",\1',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done.')
|
||||||
45
fix_knowledge.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
file_path = 'server/src/app/services/user_agent_knowledge.py'
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'(def _build_fast_knowledge_answer\([\s\S]*?-> str \| None:\n)',
|
||||||
|
r'\1 return None\n',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'heading_text = f" > \{heading\}" if heading else ""',
|
||||||
|
r'if "表格行级检索线索" in heading:\n heading = heading.replace("表格行级检索线索", "").strip(" >")\n heading_text = f"({heading})" if heading else ""\n item_title = item.get("title") or title',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item\.get\(\'title\'\) or title\}》\{heading_text\}:\{summary\}\\n\{preview\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》** {heading_text}\\n {summary}\\n{preview}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item\.get\(\'title\'\) or title\}》\{heading_text\}:\\n\{rendered\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》** {heading_text}\\n{rendered}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item\.get\(\'title\'\) or title\}》\{heading_text\}:\{rendered\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》** {heading_text}\\n {rendered}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'evidence_lines\.append\(f"- 《\{item_title\}》:\{excerpt\}"\)',
|
||||||
|
r'evidence_lines.append(f"- **《{item_title}》**:{excerpt}")',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done.')
|
||||||
15
fix_response.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
file_path = 'server/src/app/services/user_agent_response.py'
|
||||||
|
with open(file_path, 'r', encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
content = re.sub(
|
||||||
|
r'\s*"fallback_answer": fallback_answer,',
|
||||||
|
'',
|
||||||
|
content
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(file_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
print('Done.')
|
||||||
25
mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Dependencies
|
||||||
|
app/node_modules/
|
||||||
|
|
||||||
|
# Expo and Metro
|
||||||
|
app/.expo/
|
||||||
|
app/dist/
|
||||||
|
app/web-build/
|
||||||
|
app/.metro-health-check*
|
||||||
|
|
||||||
|
# Generated native projects
|
||||||
|
app/ios/
|
||||||
|
app/android/
|
||||||
|
|
||||||
|
# Local logs and caches
|
||||||
|
*.log
|
||||||
|
app/*.log
|
||||||
|
app/.cache/
|
||||||
|
app/.turbo/
|
||||||
|
|
||||||
|
# Local environment
|
||||||
|
app/.env*
|
||||||
|
!app/.env.example
|
||||||
|
|
||||||
|
# TypeScript build cache
|
||||||
|
app/*.tsbuildinfo
|
||||||
BIN
mobile/UI/移动端-1.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
mobile/UI/移动端-2.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
mobile/UI/移动端-3.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
mobile/UI/移动端-4.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
mobile/UI/移动端-5.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
mobile/UI/移动端-6.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
43
mobile/app/.gitignore
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Expo
|
||||||
|
.expo/
|
||||||
|
dist/
|
||||||
|
web-build/
|
||||||
|
expo-env.d.ts
|
||||||
|
|
||||||
|
# Native
|
||||||
|
.kotlin/
|
||||||
|
*.orig.*
|
||||||
|
*.jks
|
||||||
|
*.p8
|
||||||
|
*.p12
|
||||||
|
*.key
|
||||||
|
*.mobileprovision
|
||||||
|
|
||||||
|
# Metro
|
||||||
|
.metro-health-check*
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.*
|
||||||
|
yarn-debug.*
|
||||||
|
yarn-error.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
app-example
|
||||||
|
|
||||||
|
# generated native folders
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
1
mobile/app/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{ "recommendations": ["expo.vscode-expo-tools"] }
|
||||||
7
mobile/app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "explicit",
|
||||||
|
"source.sortMembers": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
34
mobile/app/PROJECT.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# X-Financial Mobile 初始化说明
|
||||||
|
|
||||||
|
## 运行
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm.cmd install --registry=https://registry.npmjs.org
|
||||||
|
npm.cmd run android
|
||||||
|
```
|
||||||
|
|
||||||
|
Android 模拟器访问本机后端时,默认 API 地址为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://10.0.2.2:8000/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 当前工程边界
|
||||||
|
|
||||||
|
- `src/app`:Expo Router 页面入口,已初始化首页、报销、审批、AI 助手、我的。
|
||||||
|
- `src/shared`:API、登录态、状态映射、公共组件和 mock 数据。
|
||||||
|
- `src/platform`:相机、相册、语音输入等平台能力。
|
||||||
|
- `src/features`:后续承载复杂业务逻辑,避免页面文件继续膨胀。
|
||||||
|
|
||||||
|
## 已接入能力
|
||||||
|
|
||||||
|
- Android 权限:相机、麦克风、相册图片读取。
|
||||||
|
- 相机/相册:`platform/camera/receiptCapture.ts`。
|
||||||
|
- 语音录制占位:`platform/voice/voiceInput.ts`。
|
||||||
|
- 请求缓存:`@tanstack/react-query`。
|
||||||
|
- 轻量状态:`zustand`。
|
||||||
|
- 安全存储:`expo-secure-store`。
|
||||||
|
|
||||||
|
## 开发原则
|
||||||
|
|
||||||
|
普通问答、票据预览和 AI 识别建议不自动保存草稿;只有用户明确点击保存、生成、继续提交或关联已有草稿,才进入持久化链路。
|
||||||
56
mobile/app/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Welcome to your Expo app 👋
|
||||||
|
|
||||||
|
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
|
||||||
|
|
||||||
|
## Get started
|
||||||
|
|
||||||
|
1. Install dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx expo start
|
||||||
|
```
|
||||||
|
|
||||||
|
In the output, you'll find options to open the app in a
|
||||||
|
|
||||||
|
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
|
||||||
|
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
|
||||||
|
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
|
||||||
|
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
|
||||||
|
|
||||||
|
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
|
||||||
|
|
||||||
|
## Get a fresh project
|
||||||
|
|
||||||
|
When you're ready, run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run reset-project
|
||||||
|
```
|
||||||
|
|
||||||
|
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
|
||||||
|
|
||||||
|
### Other setup steps
|
||||||
|
|
||||||
|
- To set up ESLint for linting, run `npx expo lint`, or follow our guide on ["Using ESLint and Prettier"](https://docs.expo.dev/guides/using-eslint/)
|
||||||
|
- If you'd like to set up unit testing, follow our guide on ["Unit Testing with Jest"](https://docs.expo.dev/develop/unit-testing/)
|
||||||
|
- Learn more about the TypeScript setup in this template in our guide on ["Using TypeScript"](https://docs.expo.dev/guides/typescript/)
|
||||||
|
|
||||||
|
## Learn more
|
||||||
|
|
||||||
|
To learn more about developing your project with Expo, look at the following resources:
|
||||||
|
|
||||||
|
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
|
||||||
|
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
|
||||||
|
|
||||||
|
## Join the community
|
||||||
|
|
||||||
|
Join our community of developers creating universal apps.
|
||||||
|
|
||||||
|
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
|
||||||
|
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
|
||||||
72
mobile/app/app.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"expo": {
|
||||||
|
"name": "X-Financial",
|
||||||
|
"slug": "x-financial-mobile",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"icon": "./assets/images/icon.png",
|
||||||
|
"scheme": "xfinancial",
|
||||||
|
"userInterfaceStyle": "light",
|
||||||
|
"ios": {
|
||||||
|
"icon": "./assets/expo.icon"
|
||||||
|
},
|
||||||
|
"android": {
|
||||||
|
"package": "com.xfinancial.mobile",
|
||||||
|
"permissions": [
|
||||||
|
"CAMERA",
|
||||||
|
"RECORD_AUDIO",
|
||||||
|
"READ_MEDIA_IMAGES"
|
||||||
|
],
|
||||||
|
"adaptiveIcon": {
|
||||||
|
"backgroundColor": "#EFFCF6",
|
||||||
|
"foregroundImage": "./assets/images/android-icon-foreground.png",
|
||||||
|
"backgroundImage": "./assets/images/android-icon-background.png",
|
||||||
|
"monochromeImage": "./assets/images/android-icon-monochrome.png"
|
||||||
|
},
|
||||||
|
"predictiveBackGestureEnabled": true
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
"output": "static",
|
||||||
|
"favicon": "./assets/images/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"expo-router",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "X-Financial 需要使用相机拍摄和识别报销票据。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-image-picker",
|
||||||
|
{
|
||||||
|
"photosPermission": "X-Financial 需要读取相册中的票据图片用于报销识别。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-splash-screen",
|
||||||
|
{
|
||||||
|
"backgroundColor": "#059669",
|
||||||
|
"android": {
|
||||||
|
"image": "./assets/images/splash-icon.png",
|
||||||
|
"imageWidth": 76
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-audio",
|
||||||
|
{
|
||||||
|
"microphonePermission": "X-Financial 需要使用麦克风进行语音输入。"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"expo-secure-store"
|
||||||
|
],
|
||||||
|
"experiments": {
|
||||||
|
"typedRoutes": true,
|
||||||
|
"reactCompiler": true
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"apiBaseUrl": "http://10.0.2.2:8000/api/v1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
mobile/app/assets/expo.icon/Assets/expo-symbol 2.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg width="652" height="606" viewBox="0 0 652 606" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M353.554 0H298.446C273.006 0 249.684 14.6347 237.962 37.9539L4.37994 502.646C-1.04325 513.435 -1.45067 526.178 3.2716 537.313L22.6123 582.918C34.6475 611.297 72.5404 614.156 88.4414 587.885L309.863 222.063C313.34 216.317 319.439 212.826 326 212.826C332.561 212.826 338.659 216.317 342.137 222.063L563.559 587.885C579.46 614.156 617.352 611.297 629.388 582.918L648.728 537.313C653.451 526.178 653.043 513.435 647.62 502.646L414.038 37.9539C402.316 14.6347 378.994 0 353.554 0Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 608 B |
BIN
mobile/app/assets/expo.icon/Assets/grid.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
40
mobile/app/assets/expo.icon/icon.json
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"automatic-gradient" : "extended-srgb:0.00000,0.47843,1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "expo-symbol 2.svg",
|
||||||
|
"name" : "expo-symbol 2",
|
||||||
|
"position" : {
|
||||||
|
"scale" : 1,
|
||||||
|
"translation-in-points" : [
|
||||||
|
1.1008400065293245e-05,
|
||||||
|
-16.046875
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"image-name" : "grid.png",
|
||||||
|
"name" : "grid"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"translucency" : {
|
||||||
|
"enabled" : true,
|
||||||
|
"value" : 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
mobile/app/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
mobile/app/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
mobile/app/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/app/assets/images/expo-badge-white.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/app/assets/images/expo-badge.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
mobile/app/assets/images/expo-logo.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/app/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
mobile/app/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 780 KiB |
BIN
mobile/app/assets/images/logo-glow.png
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
mobile/app/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
mobile/app/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
mobile/app/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
mobile/app/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
mobile/app/assets/images/tabIcons/explore.png
Normal file
|
After Width: | Height: | Size: 215 B |
BIN
mobile/app/assets/images/tabIcons/explore@2x.png
Normal file
|
After Width: | Height: | Size: 347 B |
BIN
mobile/app/assets/images/tabIcons/explore@3x.png
Normal file
|
After Width: | Height: | Size: 468 B |
BIN
mobile/app/assets/images/tabIcons/home.png
Normal file
|
After Width: | Height: | Size: 253 B |
BIN
mobile/app/assets/images/tabIcons/home@2x.png
Normal file
|
After Width: | Height: | Size: 343 B |
BIN
mobile/app/assets/images/tabIcons/home@3x.png
Normal file
|
After Width: | Height: | Size: 479 B |
BIN
mobile/app/assets/images/tutorial-web.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
10
mobile/app/eslint.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// https://docs.expo.dev/guides/using-eslint/
|
||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const expoConfig = require("eslint-config-expo/flat");
|
||||||
|
|
||||||
|
module.exports = defineConfig([
|
||||||
|
expoConfig,
|
||||||
|
{
|
||||||
|
ignores: ["dist/*"],
|
||||||
|
}
|
||||||
|
]);
|
||||||
13071
mobile/app/package-lock.json
generated
Normal file
57
mobile/app/package.json
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
{
|
||||||
|
"name": "x-financial-mobile",
|
||||||
|
"main": "expo-router/entry",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"start": "expo start",
|
||||||
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
|
"android": "expo start --android",
|
||||||
|
"android:clear": "expo start --android --clear",
|
||||||
|
"android:clear": "expo start --android --clear",
|
||||||
|
"ios": "expo start --ios",
|
||||||
|
"web": "expo start --web",
|
||||||
|
"lint": "expo lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@expo/vector-icons": "^15.1.1",
|
||||||
|
"@react-navigation/bottom-tabs": "^7.15.5",
|
||||||
|
"@react-navigation/elements": "^2.9.10",
|
||||||
|
"@react-navigation/native": "^7.1.33",
|
||||||
|
"@tanstack/react-query": "^5.100.11",
|
||||||
|
"expo": "~55.0.26",
|
||||||
|
"expo-audio": "~55.0.14",
|
||||||
|
"expo-camera": "~55.0.19",
|
||||||
|
"expo-constants": "~55.0.16",
|
||||||
|
"expo-device": "~55.0.17",
|
||||||
|
"expo-file-system": "~55.0.22",
|
||||||
|
"expo-font": "~55.0.8",
|
||||||
|
"expo-glass-effect": "~55.0.11",
|
||||||
|
"expo-image": "~55.0.11",
|
||||||
|
"expo-image-picker": "~55.0.20",
|
||||||
|
"expo-linking": "~55.0.15",
|
||||||
|
"expo-router": "~55.0.16",
|
||||||
|
"expo-secure-store": "~55.0.14",
|
||||||
|
"expo-splash-screen": "~55.0.21",
|
||||||
|
"expo-status-bar": "~55.0.6",
|
||||||
|
"expo-symbols": "~55.0.9",
|
||||||
|
"expo-system-ui": "~55.0.18",
|
||||||
|
"expo-web-browser": "~55.0.16",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-native": "0.83.6",
|
||||||
|
"react-native-gesture-handler": "~2.30.0",
|
||||||
|
"react-native-reanimated": "4.2.1",
|
||||||
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
|
"react-native-screens": "~4.23.0",
|
||||||
|
"react-native-web": "~0.21.0",
|
||||||
|
"react-native-worklets": "0.7.4",
|
||||||
|
"zustand": "^5.0.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "~19.2.2",
|
||||||
|
"eslint": "^9.0.0",
|
||||||
|
"eslint-config-expo": "~55.0.1",
|
||||||
|
"typescript": "~5.9.2"
|
||||||
|
},
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
114
mobile/app/scripts/reset-project.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This script is used to reset the project to a blank state.
|
||||||
|
* It deletes or moves the /src and /scripts directories to /example based on user input and creates a new /src/app directory with an index.tsx and _layout.tsx file.
|
||||||
|
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const readline = require("readline");
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const oldDirs = ["src", "scripts"];
|
||||||
|
const exampleDir = "example";
|
||||||
|
const newAppDir = "src/app";
|
||||||
|
const exampleDirPath = path.join(root, exampleDir);
|
||||||
|
|
||||||
|
const indexContent = `import { Text, View, StyleSheet } from "react-native";
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text>Edit src/app/index.tsx to edit this screen.</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
|
||||||
|
const layoutContent = `import { Stack } from "expo-router";
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return <Stack />;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
const moveDirectories = async (userInput) => {
|
||||||
|
try {
|
||||||
|
if (userInput === "y") {
|
||||||
|
// Create the app-example directory
|
||||||
|
await fs.promises.mkdir(exampleDirPath, { recursive: true });
|
||||||
|
console.log(`📁 /${exampleDir} directory created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move old directories to new app-example directory or delete them
|
||||||
|
for (const dir of oldDirs) {
|
||||||
|
const oldDirPath = path.join(root, dir);
|
||||||
|
if (fs.existsSync(oldDirPath)) {
|
||||||
|
if (userInput === "y") {
|
||||||
|
const newDirPath = path.join(root, exampleDir, dir);
|
||||||
|
await fs.promises.rename(oldDirPath, newDirPath);
|
||||||
|
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
|
||||||
|
} else {
|
||||||
|
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
|
||||||
|
console.log(`❌ /${dir} deleted.`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`➡️ /${dir} does not exist, skipping.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new /src/app directory
|
||||||
|
const newAppDirPath = path.join(root, newAppDir);
|
||||||
|
await fs.promises.mkdir(newAppDirPath, { recursive: true });
|
||||||
|
console.log("\n📁 New /src/app directory created.");
|
||||||
|
|
||||||
|
// Create index.tsx
|
||||||
|
const indexPath = path.join(newAppDirPath, "index.tsx");
|
||||||
|
await fs.promises.writeFile(indexPath, indexContent);
|
||||||
|
console.log("📄 src/app/index.tsx created.");
|
||||||
|
|
||||||
|
// Create _layout.tsx
|
||||||
|
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
|
||||||
|
await fs.promises.writeFile(layoutPath, layoutContent);
|
||||||
|
console.log("📄 src/app/_layout.tsx created.");
|
||||||
|
|
||||||
|
console.log("\n✅ Project reset complete. Next steps:");
|
||||||
|
console.log(
|
||||||
|
`1. Run \`npx expo start\` to start a development server.\n2. Edit src/app/index.tsx to edit the main screen.\n3. Put all your application code in /src, only screens and layout files should be in /src/app.${
|
||||||
|
userInput === "y"
|
||||||
|
? `\n4. Delete the /${exampleDir} directory when you're done referencing it.`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ Error during script execution: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rl.question(
|
||||||
|
"Do you want to move existing files to /example instead of deleting them? (Y/n): ",
|
||||||
|
(answer) => {
|
||||||
|
const userInput = answer.trim().toLowerCase() || "y";
|
||||||
|
if (userInput === "y" || userInput === "n") {
|
||||||
|
moveDirectories(userInput).finally(() => rl.close());
|
||||||
|
} else {
|
||||||
|
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
64
mobile/app/src/app/_layout.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Tabs } from 'expo-router';
|
||||||
|
import React from 'react';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/theme';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
type TabIconProps = {
|
||||||
|
color: string;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function HomeIcon(props: TabIconProps) {
|
||||||
|
return <Ionicons name="home-outline" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClaimsIcon(props: TabIconProps) {
|
||||||
|
return <Ionicons name="document-text-outline" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ApprovalsIcon(props: TabIconProps) {
|
||||||
|
return <Ionicons name="people-outline" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssistantIcon(props: TabIconProps) {
|
||||||
|
return <Ionicons name="sparkles-outline" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileIcon(props: TabIconProps) {
|
||||||
|
return <Ionicons name="person-outline" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootLayout() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Tabs
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
tabBarActiveTintColor: Colors.light.primary,
|
||||||
|
tabBarInactiveTintColor: Colors.light.textSecondary,
|
||||||
|
tabBarStyle: {
|
||||||
|
height: 72,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingBottom: 10,
|
||||||
|
borderTopColor: Colors.light.border,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
tabBarLabelStyle: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Tabs.Screen name="index" options={{ title: '首页', tabBarIcon: HomeIcon }} />
|
||||||
|
<Tabs.Screen name="claims" options={{ title: '报销', tabBarIcon: ClaimsIcon }} />
|
||||||
|
<Tabs.Screen name="approvals" options={{ title: '审批', tabBarIcon: ApprovalsIcon }} />
|
||||||
|
<Tabs.Screen name="assistant" options={{ title: 'AI 助手', tabBarIcon: AssistantIcon }} />
|
||||||
|
<Tabs.Screen name="profile" options={{ title: '我的', tabBarIcon: ProfileIcon }} />
|
||||||
|
<Tabs.Screen name="explore" options={{ href: null }} />
|
||||||
|
</Tabs>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
mobile/app/src/app/approvals.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ActionButton } from '@/shared/components/ActionButton';
|
||||||
|
import { ClaimCard } from '@/shared/components/ClaimCard';
|
||||||
|
import { Screen } from '@/shared/components/Screen';
|
||||||
|
import { Colors, Radius, Spacing } from '@/constants/theme';
|
||||||
|
import { approvalClaims } from '@/shared/mock/claims';
|
||||||
|
|
||||||
|
export default function ApprovalsScreen() {
|
||||||
|
return (
|
||||||
|
<Screen title="审批中心" subtitle="处理待我审批的报销单,查看 AI 风控提示后再做决定。">
|
||||||
|
<View style={styles.summary}>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.summaryNumber}>{approvalClaims.length}</Text>
|
||||||
|
<Text style={styles.summaryLabel}>待审批单据</Text>
|
||||||
|
</View>
|
||||||
|
<View>
|
||||||
|
<Text style={styles.summaryNumber}>1</Text>
|
||||||
|
<Text style={styles.summaryLabel}>需关注风险</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{approvalClaims.map((claim) => (
|
||||||
|
<View key={claim.id} style={styles.approvalItem}>
|
||||||
|
<ClaimCard claim={claim} />
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<ActionButton accessibilityLabel={`驳回 ${claim.claimNo}`} variant="danger">
|
||||||
|
驳回
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton accessibilityLabel={`同意 ${claim.claimNo}`}>同意</ActionButton>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
summary: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.three,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
backgroundColor: Colors.light.primarySoft,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#BFEEDB',
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
summaryNumber: {
|
||||||
|
color: Colors.light.primary,
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
summaryLabel: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
approvalItem: {
|
||||||
|
gap: Spacing.three,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.three,
|
||||||
|
},
|
||||||
|
});
|
||||||
180
mobile/app/src/app/assistant.tsx
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ActionButton } from '@/shared/components/ActionButton';
|
||||||
|
import { Screen } from '@/shared/components/Screen';
|
||||||
|
import { Colors, Radius, Spacing } from '@/constants/theme';
|
||||||
|
import { captureReceiptFromCamera, pickReceiptFromLibrary } from '@/platform/camera/receiptCapture';
|
||||||
|
import { transcribeVoice, useVoiceRecorder } from '@/platform/voice/voiceInput';
|
||||||
|
|
||||||
|
export default function AssistantScreen() {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [receiptName, setReceiptName] = useState('');
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const voice = useVoiceRecorder();
|
||||||
|
|
||||||
|
async function handleCamera() {
|
||||||
|
try {
|
||||||
|
const receipt = await captureReceiptFromCamera();
|
||||||
|
if (receipt) {
|
||||||
|
setReceiptName(receipt.fileName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('无法拍照', error instanceof Error ? error.message : '请检查相机权限。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLibrary() {
|
||||||
|
try {
|
||||||
|
const receipt = await pickReceiptFromLibrary();
|
||||||
|
if (receipt) {
|
||||||
|
setReceiptName(receipt.fileName);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('无法选择票据', error instanceof Error ? error.message : '请检查相册权限。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleVoice() {
|
||||||
|
try {
|
||||||
|
if (!isRecording) {
|
||||||
|
setIsRecording(true);
|
||||||
|
await voice.start();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uri = await voice.stop();
|
||||||
|
setIsRecording(false);
|
||||||
|
if (uri) {
|
||||||
|
const result = await transcribeVoice(uri);
|
||||||
|
setMessage(result.text);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setIsRecording(false);
|
||||||
|
Alert.alert('语音输入失败', error instanceof Error ? error.message : '请检查麦克风权限。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="AI 助手" subtitle="输入问题、拍摄票据或使用语音描述费用,助手会返回可确认的报销建议。">
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.badge}>AI 报销助手</Text>
|
||||||
|
<Text style={styles.title}>你好,我是你的报销助手</Text>
|
||||||
|
<Text style={styles.copy}>我可以帮你识别费用类型、解读报销制度、检测缺失材料并生成报销单。</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.answerCard}>
|
||||||
|
<Text style={styles.answerTitle}>示例建议</Text>
|
||||||
|
<Text style={styles.answerText}>
|
||||||
|
你可以说:“我昨天打车 86 元,请客户吃饭 320 元,怎么报?”
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{receiptName ? (
|
||||||
|
<View style={styles.receiptBar}>
|
||||||
|
<Text style={styles.receiptText}>已选择票据:{receiptName}</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<View style={styles.inputCard}>
|
||||||
|
<TextInput
|
||||||
|
accessibilityLabel="助手输入框"
|
||||||
|
multiline
|
||||||
|
placeholder="描述费用或输入你的问题"
|
||||||
|
placeholderTextColor={Colors.light.textSecondary}
|
||||||
|
style={styles.input}
|
||||||
|
value={message}
|
||||||
|
onChangeText={setMessage}
|
||||||
|
/>
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<ActionButton accessibilityLabel="拍照识别票据" variant="secondary" onPress={handleCamera}>
|
||||||
|
拍照
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton accessibilityLabel="从相册上传票据" variant="secondary" onPress={handleLibrary}>
|
||||||
|
相册
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton accessibilityLabel="语音输入" variant={isRecording ? 'danger' : 'secondary'} onPress={toggleVoice}>
|
||||||
|
{isRecording ? '停止' : '语音'}
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton accessibilityLabel="发送给 AI 助手">发送</ActionButton>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
hero: {
|
||||||
|
gap: Spacing.three,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
backgroundColor: Colors.light.primarySoft,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#BFEEDB',
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderRadius: Radius.pill,
|
||||||
|
backgroundColor: '#DFF8EC',
|
||||||
|
color: Colors.light.primary,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 23,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
copy: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 23,
|
||||||
|
},
|
||||||
|
answerCard: {
|
||||||
|
gap: Spacing.two,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.light.border,
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
answerTitle: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
answerText: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 23,
|
||||||
|
},
|
||||||
|
receiptBar: {
|
||||||
|
borderRadius: Radius.md,
|
||||||
|
backgroundColor: '#E8F1FF',
|
||||||
|
padding: Spacing.three,
|
||||||
|
},
|
||||||
|
receiptText: {
|
||||||
|
color: Colors.light.info,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
inputCard: {
|
||||||
|
gap: Spacing.three,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.light.border,
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
minHeight: 92,
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 15,
|
||||||
|
textAlignVertical: 'top',
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: Spacing.three,
|
||||||
|
},
|
||||||
|
});
|
||||||
48
mobile/app/src/app/claims.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ActionButton } from '@/shared/components/ActionButton';
|
||||||
|
import { ClaimCard } from '@/shared/components/ClaimCard';
|
||||||
|
import { Screen } from '@/shared/components/Screen';
|
||||||
|
import { Colors, Radius, Spacing } from '@/constants/theme';
|
||||||
|
import { myClaims } from '@/shared/mock/claims';
|
||||||
|
|
||||||
|
export default function ClaimsScreen() {
|
||||||
|
return (
|
||||||
|
<Screen title="我的报销" subtitle="查看草稿、审批中、已通过、已驳回和已付款的报销单。">
|
||||||
|
<View style={styles.searchBox}>
|
||||||
|
<Text style={styles.searchText}>搜索报销单号、事由、费用类型、金额</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.actionRow}>
|
||||||
|
<ActionButton accessibilityLabel="新建报销">新建报销</ActionButton>
|
||||||
|
<ActionButton accessibilityLabel="筛选报销单" variant="secondary">
|
||||||
|
筛选
|
||||||
|
</ActionButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{myClaims.map((claim) => (
|
||||||
|
<ClaimCard key={claim.id} claim={claim} />
|
||||||
|
))}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
searchBox: {
|
||||||
|
minHeight: 48,
|
||||||
|
borderRadius: Radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.light.border,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: Spacing.four,
|
||||||
|
},
|
||||||
|
searchText: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
actionRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.three,
|
||||||
|
},
|
||||||
|
});
|
||||||
181
mobile/app/src/app/explore.tsx
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import React from 'react';
|
||||||
|
import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { ExternalLink } from '@/components/external-link';
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { Collapsible } from '@/components/ui/collapsible';
|
||||||
|
import { WebBadge } from '@/components/web-badge';
|
||||||
|
import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||||
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
export default function TabTwoScreen() {
|
||||||
|
const safeAreaInsets = useSafeAreaInsets();
|
||||||
|
const insets = {
|
||||||
|
...safeAreaInsets,
|
||||||
|
bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three,
|
||||||
|
};
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const contentPlatformStyle = Platform.select({
|
||||||
|
android: {
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
paddingTop: Spacing.six,
|
||||||
|
paddingBottom: Spacing.four,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
||||||
|
contentInset={insets}
|
||||||
|
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedView style={styles.titleContainer}>
|
||||||
|
<ThemedText type="subtitle">Explore</ThemedText>
|
||||||
|
<ThemedText style={styles.centerText} themeColor="textSecondary">
|
||||||
|
This starter app includes example{'\n'}code to help you get started.
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||||
|
<Pressable style={({ pressed }) => pressed && styles.pressed}>
|
||||||
|
<ThemedView type="backgroundElement" style={styles.linkButton}>
|
||||||
|
<ThemedText type="link">Expo documentation</ThemedText>
|
||||||
|
<SymbolView
|
||||||
|
tintColor={theme.text}
|
||||||
|
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
</Pressable>
|
||||||
|
</ExternalLink>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
<ThemedView style={styles.sectionsWrapper}>
|
||||||
|
<Collapsible title="File-based routing">
|
||||||
|
<ThemedText type="small">
|
||||||
|
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
|
||||||
|
<ThemedText type="code">src/app/explore.tsx</ThemedText>
|
||||||
|
</ThemedText>
|
||||||
|
<ThemedText type="small">
|
||||||
|
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
|
||||||
|
the tab navigator.
|
||||||
|
</ThemedText>
|
||||||
|
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||||
|
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||||
|
</ExternalLink>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title="Android, iOS, and web support">
|
||||||
|
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
|
||||||
|
<ThemedText type="small">
|
||||||
|
You can open this project on Android, iOS, and the web. To open the web version,
|
||||||
|
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
|
||||||
|
project.
|
||||||
|
</ThemedText>
|
||||||
|
<Image
|
||||||
|
source={require('@/assets/images/tutorial-web.png')}
|
||||||
|
style={styles.imageTutorial}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title="Images">
|
||||||
|
<ThemedText type="small">
|
||||||
|
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
|
||||||
|
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
|
||||||
|
screen densities.
|
||||||
|
</ThemedText>
|
||||||
|
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
|
||||||
|
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||||
|
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||||
|
</ExternalLink>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title="Light and dark mode components">
|
||||||
|
<ThemedText type="small">
|
||||||
|
This template has light and dark mode support. The{' '}
|
||||||
|
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
|
||||||
|
user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||||
|
</ThemedText>
|
||||||
|
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||||
|
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||||
|
</ExternalLink>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
<Collapsible title="Animations">
|
||||||
|
<ThemedText type="small">
|
||||||
|
This template includes an example of an animated component. The{' '}
|
||||||
|
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
|
||||||
|
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
|
||||||
|
animate opening this hint.
|
||||||
|
</ThemedText>
|
||||||
|
</Collapsible>
|
||||||
|
</ThemedView>
|
||||||
|
{Platform.OS === 'web' && <WebBadge />}
|
||||||
|
</ThemedView>
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
contentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
maxWidth: MaxContentWidth,
|
||||||
|
flexGrow: 1,
|
||||||
|
},
|
||||||
|
titleContainer: {
|
||||||
|
gap: Spacing.three,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: Spacing.four,
|
||||||
|
paddingVertical: Spacing.six,
|
||||||
|
},
|
||||||
|
centerText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
linkButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
paddingHorizontal: Spacing.four,
|
||||||
|
paddingVertical: Spacing.two,
|
||||||
|
borderRadius: Spacing.five,
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: Spacing.one,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
sectionsWrapper: {
|
||||||
|
gap: Spacing.five,
|
||||||
|
paddingHorizontal: Spacing.four,
|
||||||
|
paddingTop: Spacing.three,
|
||||||
|
},
|
||||||
|
collapsibleContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
imageTutorial: {
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: 296 / 171,
|
||||||
|
borderRadius: Spacing.three,
|
||||||
|
marginTop: Spacing.two,
|
||||||
|
},
|
||||||
|
imageReact: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
|
});
|
||||||
105
mobile/app/src/app/index.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ActionButton } from '@/shared/components/ActionButton';
|
||||||
|
import { ClaimCard } from '@/shared/components/ClaimCard';
|
||||||
|
import { Screen } from '@/shared/components/Screen';
|
||||||
|
import { Colors, Radius, Spacing } from '@/constants/theme';
|
||||||
|
import { myClaims } from '@/shared/mock/claims';
|
||||||
|
|
||||||
|
export default function HomeScreen() {
|
||||||
|
return (
|
||||||
|
<Screen title="上午好,张三" subtitle="使用 AI 报销助手快速识别票据、生成草稿和跟踪审批。">
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<Text style={styles.heroLabel}>AI 报销助手</Text>
|
||||||
|
<Text style={styles.heroTitle}>描述费用或上传票据,AI 帮你快速报销</Text>
|
||||||
|
<Text style={styles.heroCopy}>自动识别票据信息,智能推荐报销类型与科目。</Text>
|
||||||
|
<View style={styles.heroActions}>
|
||||||
|
<ActionButton accessibilityLabel="拍照识别票据">拍照识别</ActionButton>
|
||||||
|
<ActionButton accessibilityLabel="上传票据" variant="secondary">
|
||||||
|
上传票据
|
||||||
|
</ActionButton>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.quickGrid}>
|
||||||
|
<View style={styles.quickCard}>
|
||||||
|
<Text style={styles.quickTitle}>新建报销</Text>
|
||||||
|
<Text style={styles.quickCopy}>发起报销申请</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.quickCard}>
|
||||||
|
<Text style={styles.quickTitle}>待我审批</Text>
|
||||||
|
<Text style={styles.quickCopy}>2 单待处理</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionTitle}>最近报销进度</Text>
|
||||||
|
{myClaims.slice(0, 2).map((claim) => (
|
||||||
|
<ClaimCard key={claim.id} claim={claim} />
|
||||||
|
))}
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
hero: {
|
||||||
|
gap: Spacing.three,
|
||||||
|
padding: Spacing.four,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#BFEEDB',
|
||||||
|
backgroundColor: Colors.light.primarySoft,
|
||||||
|
},
|
||||||
|
heroLabel: {
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
borderRadius: Radius.pill,
|
||||||
|
backgroundColor: '#DFF8EC',
|
||||||
|
color: Colors.light.primary,
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 4,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 21,
|
||||||
|
fontWeight: '900',
|
||||||
|
lineHeight: 29,
|
||||||
|
},
|
||||||
|
heroCopy: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
heroActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.three,
|
||||||
|
},
|
||||||
|
quickGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.three,
|
||||||
|
},
|
||||||
|
quickCard: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 5,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.light.border,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
quickTitle: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
quickCopy: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
marginTop: Spacing.two,
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
});
|
||||||
92
mobile/app/src/app/profile.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { StyleSheet, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { ActionButton } from '@/shared/components/ActionButton';
|
||||||
|
import { Screen } from '@/shared/components/Screen';
|
||||||
|
import { Colors, Radius, Spacing } from '@/constants/theme';
|
||||||
|
import { useSessionStore } from '@/shared/auth/session';
|
||||||
|
|
||||||
|
export default function ProfileScreen() {
|
||||||
|
const user = useSessionStore((state) => state.user);
|
||||||
|
const signInAsDemoUser = useSessionStore((state) => state.signInAsDemoUser);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen title="我的" subtitle="查看当前移动端登录身份、角色和基础设置。">
|
||||||
|
<View style={styles.profileCard}>
|
||||||
|
<View style={styles.avatar}>
|
||||||
|
<Text style={styles.avatarText}>{user?.displayName.slice(0, 1) || '未'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.name}>{user?.displayName || '未登录'}</Text>
|
||||||
|
<Text style={styles.meta}>账号:{user?.username || '-'}</Text>
|
||||||
|
<Text style={styles.meta}>角色:{user?.roleCodes.join(', ') || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.settingCard}>
|
||||||
|
<Text style={styles.settingTitle}>移动端初始化状态</Text>
|
||||||
|
<Text style={styles.settingText}>相机、相册、麦克风、SecureStore 和接口客户端已接入工程骨架。</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ActionButton accessibilityLabel="恢复演示用户" onPress={signInAsDemoUser}>
|
||||||
|
恢复演示用户
|
||||||
|
</ActionButton>
|
||||||
|
</Screen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
profileCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: Spacing.four,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: Colors.light.border,
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: Colors.light.primarySoft,
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
color: Colors.light.primary,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 13,
|
||||||
|
},
|
||||||
|
settingCard: {
|
||||||
|
gap: Spacing.two,
|
||||||
|
borderRadius: Radius.lg,
|
||||||
|
backgroundColor: Colors.light.primarySoft,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#BFEEDB',
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
settingTitle: {
|
||||||
|
color: Colors.light.text,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '900',
|
||||||
|
},
|
||||||
|
settingText: {
|
||||||
|
color: Colors.light.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
});
|
||||||
6
mobile/app/src/components/animated-icon.module.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.expoLogoBackground {
|
||||||
|
background-image: linear-gradient(180deg, #3c9ffe, #0274df);
|
||||||
|
border-radius: 40px;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
132
mobile/app/src/components/animated-icon.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Dimensions, StyleSheet, View } from 'react-native';
|
||||||
|
import Animated, { Easing, Keyframe } from 'react-native-reanimated';
|
||||||
|
import { scheduleOnRN } from 'react-native-worklets';
|
||||||
|
|
||||||
|
const INITIAL_SCALE_FACTOR = Dimensions.get('screen').height / 90;
|
||||||
|
const DURATION = 600;
|
||||||
|
|
||||||
|
export function AnimatedSplashOverlay() {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
|
if (!visible) return null;
|
||||||
|
|
||||||
|
const splashKeyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
transform: [{ scale: INITIAL_SCALE_FACTOR }],
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
20: {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
70: {
|
||||||
|
opacity: 0,
|
||||||
|
easing: Easing.elastic(0.7),
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
opacity: 0,
|
||||||
|
transform: [{ scale: 1 }],
|
||||||
|
easing: Easing.elastic(0.7),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={splashKeyframe.duration(DURATION).withCallback((finished) => {
|
||||||
|
'worklet';
|
||||||
|
if (finished) {
|
||||||
|
scheduleOnRN(setVisible, false);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
style={styles.backgroundSolidColor}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
transform: [{ scale: INITIAL_SCALE_FACTOR }],
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
transform: [{ scale: 1 }],
|
||||||
|
easing: Easing.elastic(0.7),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoKeyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
transform: [{ scale: 1.3 }],
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
40: {
|
||||||
|
transform: [{ scale: 1.3 }],
|
||||||
|
opacity: 0,
|
||||||
|
easing: Easing.elastic(0.7),
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
opacity: 1,
|
||||||
|
transform: [{ scale: 1 }],
|
||||||
|
easing: Easing.elastic(0.7),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const glowKeyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
transform: [{ rotateZ: '0deg' }],
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
transform: [{ rotateZ: '7200deg' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AnimatedIcon() {
|
||||||
|
return (
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
|
||||||
|
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View entering={keyframe.duration(DURATION)} style={styles.background} />
|
||||||
|
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
|
||||||
|
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
imageContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
width: 201,
|
||||||
|
height: 201,
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 76,
|
||||||
|
height: 71,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
borderRadius: 40,
|
||||||
|
experimental_backgroundImage: `linear-gradient(180deg, #3C9FFE, #0274DF)`,
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
backgroundSolidColor: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#208AEF',
|
||||||
|
zIndex: 1000,
|
||||||
|
},
|
||||||
|
});
|
||||||
108
mobile/app/src/components/animated-icon.web.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import Animated, { Keyframe, Easing } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import classes from './animated-icon.module.css';
|
||||||
|
const DURATION = 300;
|
||||||
|
|
||||||
|
export function AnimatedSplashOverlay() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
transform: [{ scale: 0 }],
|
||||||
|
},
|
||||||
|
60: {
|
||||||
|
transform: [{ scale: 1.2 }],
|
||||||
|
easing: Easing.elastic(1.2),
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
transform: [{ scale: 1 }],
|
||||||
|
easing: Easing.elastic(1.2),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const logoKeyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
60: {
|
||||||
|
transform: [{ scale: 1.2 }],
|
||||||
|
opacity: 0,
|
||||||
|
easing: Easing.elastic(1.2),
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
transform: [{ scale: 1 }],
|
||||||
|
opacity: 1,
|
||||||
|
easing: Easing.elastic(1.2),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const glowKeyframe = new Keyframe({
|
||||||
|
0: {
|
||||||
|
transform: [{ rotateZ: '-180deg' }, { scale: 0.8 }],
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
[DURATION / 1000]: {
|
||||||
|
transform: [{ rotateZ: '0deg' }, { scale: 1 }],
|
||||||
|
opacity: 1,
|
||||||
|
easing: Easing.elastic(0.7),
|
||||||
|
},
|
||||||
|
100: {
|
||||||
|
transform: [{ rotateZ: '7200deg' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AnimatedIcon() {
|
||||||
|
return (
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
<Animated.View entering={glowKeyframe.duration(60 * 1000 * 4)} style={styles.glow}>
|
||||||
|
<Image style={styles.glow} source={require('@/assets/images/logo-glow.png')} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View style={styles.background} entering={keyframe.duration(DURATION)}>
|
||||||
|
<div className={classes.expoLogoBackground} />
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.View style={styles.imageContainer} entering={logoKeyframe.duration(DURATION)}>
|
||||||
|
<Image style={styles.image} source={require('@/assets/images/expo-logo.png')} />
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
zIndex: 1000,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 128 / 2 + 138,
|
||||||
|
},
|
||||||
|
imageContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
width: 201,
|
||||||
|
height: 201,
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 76,
|
||||||
|
height: 71,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
width: 128,
|
||||||
|
height: 128,
|
||||||
|
position: 'absolute',
|
||||||
|
},
|
||||||
|
});
|
||||||
33
mobile/app/src/components/app-tabs.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { NativeTabs } from 'expo-router/unstable-native-tabs';
|
||||||
|
import React from 'react';
|
||||||
|
import { useColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/theme';
|
||||||
|
|
||||||
|
export default function AppTabs() {
|
||||||
|
const scheme = useColorScheme();
|
||||||
|
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NativeTabs
|
||||||
|
backgroundColor={colors.background}
|
||||||
|
indicatorColor={colors.backgroundElement}
|
||||||
|
labelStyle={{ selected: { color: colors.text } }}>
|
||||||
|
<NativeTabs.Trigger name="index">
|
||||||
|
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
src={require('@/assets/images/tabIcons/home.png')}
|
||||||
|
renderingMode="template"
|
||||||
|
/>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
|
||||||
|
<NativeTabs.Trigger name="explore">
|
||||||
|
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||||
|
<NativeTabs.Trigger.Icon
|
||||||
|
src={require('@/assets/images/tabIcons/explore.png')}
|
||||||
|
renderingMode="template"
|
||||||
|
/>
|
||||||
|
</NativeTabs.Trigger>
|
||||||
|
</NativeTabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
mobile/app/src/components/app-tabs.web.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
TabTrigger,
|
||||||
|
TabSlot,
|
||||||
|
TabTriggerSlotProps,
|
||||||
|
TabListProps,
|
||||||
|
} from 'expo-router/ui';
|
||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import React from 'react';
|
||||||
|
import { Pressable, useColorScheme, View, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ExternalLink } from './external-link';
|
||||||
|
import { ThemedText } from './themed-text';
|
||||||
|
import { ThemedView } from './themed-view';
|
||||||
|
|
||||||
|
import { Colors, MaxContentWidth, Spacing } from '@/constants/theme';
|
||||||
|
|
||||||
|
export default function AppTabs() {
|
||||||
|
return (
|
||||||
|
<Tabs>
|
||||||
|
<TabSlot style={{ height: '100%' }} />
|
||||||
|
<TabList asChild>
|
||||||
|
<CustomTabList>
|
||||||
|
<TabTrigger name="home" href="/" asChild>
|
||||||
|
<TabButton>Home</TabButton>
|
||||||
|
</TabTrigger>
|
||||||
|
<TabTrigger name="explore" href="/explore" asChild>
|
||||||
|
<TabButton>Explore</TabButton>
|
||||||
|
</TabTrigger>
|
||||||
|
</CustomTabList>
|
||||||
|
</TabList>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
|
||||||
|
return (
|
||||||
|
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
|
||||||
|
<ThemedView
|
||||||
|
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
|
||||||
|
style={styles.tabButtonView}>
|
||||||
|
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
|
||||||
|
{children}
|
||||||
|
</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomTabList(props: TabListProps) {
|
||||||
|
const scheme = useColorScheme();
|
||||||
|
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View {...props} style={styles.tabListContainer}>
|
||||||
|
<ThemedView type="backgroundElement" style={styles.innerContainer}>
|
||||||
|
<ThemedText type="smallBold" style={styles.brandText}>
|
||||||
|
Expo Starter
|
||||||
|
</ThemedText>
|
||||||
|
|
||||||
|
{props.children}
|
||||||
|
|
||||||
|
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||||
|
<Pressable style={styles.externalPressable}>
|
||||||
|
<ThemedText type="link">Docs</ThemedText>
|
||||||
|
<SymbolView
|
||||||
|
tintColor={colors.text}
|
||||||
|
name={{ ios: 'arrow.up.right.square', web: 'link' }}
|
||||||
|
size={12}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
|
</ExternalLink>
|
||||||
|
</ThemedView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
tabListContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: '100%',
|
||||||
|
padding: Spacing.three,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
innerContainer: {
|
||||||
|
paddingVertical: Spacing.two,
|
||||||
|
paddingHorizontal: Spacing.five,
|
||||||
|
borderRadius: Spacing.five,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexGrow: 1,
|
||||||
|
gap: Spacing.two,
|
||||||
|
maxWidth: MaxContentWidth,
|
||||||
|
},
|
||||||
|
brandText: {
|
||||||
|
marginRight: 'auto',
|
||||||
|
},
|
||||||
|
pressed: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
tabButtonView: {
|
||||||
|
paddingVertical: Spacing.one,
|
||||||
|
paddingHorizontal: Spacing.three,
|
||||||
|
borderRadius: Spacing.three,
|
||||||
|
},
|
||||||
|
externalPressable: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.one,
|
||||||
|
marginLeft: Spacing.three,
|
||||||
|
},
|
||||||
|
});
|
||||||
25
mobile/app/src/components/external-link.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Href, Link } from 'expo-router';
|
||||||
|
import { openBrowserAsync, WebBrowserPresentationStyle } from 'expo-web-browser';
|
||||||
|
import { type ComponentProps } from 'react';
|
||||||
|
|
||||||
|
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: Href & string };
|
||||||
|
|
||||||
|
export function ExternalLink({ href, ...rest }: Props) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
{...rest}
|
||||||
|
href={href}
|
||||||
|
onPress={async (event) => {
|
||||||
|
if (process.env.EXPO_OS !== 'web') {
|
||||||
|
// Prevent the default behavior of linking to the default browser on native.
|
||||||
|
event.preventDefault();
|
||||||
|
// Open the link in an in-app browser.
|
||||||
|
await openBrowserAsync(href, {
|
||||||
|
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
mobile/app/src/components/hint-row.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React, { type ReactNode } from 'react';
|
||||||
|
import { View, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from './themed-text';
|
||||||
|
import { ThemedView } from './themed-view';
|
||||||
|
|
||||||
|
import { Spacing } from '@/constants/theme';
|
||||||
|
|
||||||
|
type HintRowProps = {
|
||||||
|
title?: string;
|
||||||
|
hint?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function HintRow({ title = 'Try editing', hint = 'app/index.tsx' }: HintRowProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.stepRow}>
|
||||||
|
<ThemedText type="small">{title}</ThemedText>
|
||||||
|
<ThemedView type="backgroundSelected" style={styles.codeSnippet}>
|
||||||
|
<ThemedText themeColor="textSecondary">{hint}</ThemedText>
|
||||||
|
</ThemedView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
stepRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
codeSnippet: {
|
||||||
|
borderRadius: Spacing.two,
|
||||||
|
paddingVertical: Spacing.half,
|
||||||
|
paddingHorizontal: Spacing.two,
|
||||||
|
},
|
||||||
|
});
|
||||||
73
mobile/app/src/components/themed-text.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Platform, StyleSheet, Text, type TextProps } from 'react-native';
|
||||||
|
|
||||||
|
import { Fonts, ThemeColor } from '@/constants/theme';
|
||||||
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
export type ThemedTextProps = TextProps & {
|
||||||
|
type?: 'default' | 'title' | 'small' | 'smallBold' | 'subtitle' | 'link' | 'linkPrimary' | 'code';
|
||||||
|
themeColor?: ThemeColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedText({ style, type = 'default', themeColor, ...rest }: ThemedTextProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
{ color: theme[themeColor ?? 'text'] },
|
||||||
|
type === 'default' && styles.default,
|
||||||
|
type === 'title' && styles.title,
|
||||||
|
type === 'small' && styles.small,
|
||||||
|
type === 'smallBold' && styles.smallBold,
|
||||||
|
type === 'subtitle' && styles.subtitle,
|
||||||
|
type === 'link' && styles.link,
|
||||||
|
type === 'linkPrimary' && styles.linkPrimary,
|
||||||
|
type === 'code' && styles.code,
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
small: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
smallBold: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontWeight: 700,
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 24,
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 48,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 52,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
lineHeight: 44,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
linkPrimary: {
|
||||||
|
lineHeight: 30,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#3c87f7',
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
fontFamily: Fonts.mono,
|
||||||
|
fontWeight: Platform.select({ android: 700 }) ?? 500,
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
});
|
||||||
16
mobile/app/src/components/themed-view.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { View, type ViewProps } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemeColor } from '@/constants/theme';
|
||||||
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
export type ThemedViewProps = ViewProps & {
|
||||||
|
lightColor?: string;
|
||||||
|
darkColor?: string;
|
||||||
|
type?: ThemeColor;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ThemedView({ style, lightColor, darkColor, type, ...otherProps }: ThemedViewProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return <View style={[{ backgroundColor: theme[type ?? 'background'] }, style]} {...otherProps} />;
|
||||||
|
}
|
||||||
65
mobile/app/src/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { SymbolView } from 'expo-symbols';
|
||||||
|
import { PropsWithChildren, useState } from 'react';
|
||||||
|
import { Pressable, StyleSheet } from 'react-native';
|
||||||
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { ThemedText } from '@/components/themed-text';
|
||||||
|
import { ThemedView } from '@/components/themed-view';
|
||||||
|
import { Spacing } from '@/constants/theme';
|
||||||
|
import { useTheme } from '@/hooks/use-theme';
|
||||||
|
|
||||||
|
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView>
|
||||||
|
<Pressable
|
||||||
|
style={({ pressed }) => [styles.heading, pressed && styles.pressedHeading]}
|
||||||
|
onPress={() => setIsOpen((value) => !value)}>
|
||||||
|
<ThemedView type="backgroundElement" style={styles.button}>
|
||||||
|
<SymbolView
|
||||||
|
name={{ ios: 'chevron.right', android: 'chevron_right', web: 'chevron_right' }}
|
||||||
|
size={14}
|
||||||
|
weight="bold"
|
||||||
|
tintColor={theme.text}
|
||||||
|
style={{ transform: [{ rotate: isOpen ? '-90deg' : '90deg' }] }}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
|
||||||
|
<ThemedText type="small">{title}</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
{isOpen && (
|
||||||
|
<Animated.View entering={FadeIn.duration(200)}>
|
||||||
|
<ThemedView type="backgroundElement" style={styles.content}>
|
||||||
|
{children}
|
||||||
|
</ThemedView>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
heading: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.two,
|
||||||
|
},
|
||||||
|
pressedHeading: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: Spacing.four,
|
||||||
|
height: Spacing.four,
|
||||||
|
borderRadius: 12,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
marginTop: Spacing.three,
|
||||||
|
borderRadius: Spacing.three,
|
||||||
|
marginLeft: Spacing.four,
|
||||||
|
padding: Spacing.four,
|
||||||
|
},
|
||||||
|
});
|
||||||
44
mobile/app/src/components/web-badge.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { version } from 'expo/package.json';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import React from 'react';
|
||||||
|
import { useColorScheme, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
import { ThemedText } from './themed-text';
|
||||||
|
import { ThemedView } from './themed-view';
|
||||||
|
|
||||||
|
import { Spacing } from '@/constants/theme';
|
||||||
|
|
||||||
|
export function WebBadge() {
|
||||||
|
const scheme = useColorScheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemedView style={styles.container}>
|
||||||
|
<ThemedText type="code" themeColor="textSecondary" style={styles.versionText}>
|
||||||
|
v{version}
|
||||||
|
</ThemedText>
|
||||||
|
<Image
|
||||||
|
source={
|
||||||
|
scheme === 'dark'
|
||||||
|
? require('@/assets/images/expo-badge-white.png')
|
||||||
|
: require('@/assets/images/expo-badge.png')
|
||||||
|
}
|
||||||
|
style={styles.badgeImage}
|
||||||
|
/>
|
||||||
|
</ThemedView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
padding: Spacing.five,
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.two,
|
||||||
|
},
|
||||||
|
versionText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
badgeImage: {
|
||||||
|
width: 123,
|
||||||
|
aspectRatio: 123 / 24,
|
||||||
|
},
|
||||||
|
});
|
||||||
80
mobile/app/src/constants/theme.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import '@/global.css';
|
||||||
|
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
export const Colors = {
|
||||||
|
light: {
|
||||||
|
text: '#071124',
|
||||||
|
background: '#F7FAFC',
|
||||||
|
backgroundElement: '#FFFFFF',
|
||||||
|
backgroundSelected: '#DFF8EC',
|
||||||
|
textSecondary: '#58677F',
|
||||||
|
primary: '#059669',
|
||||||
|
primarySoft: '#EFFCF6',
|
||||||
|
border: '#DBE5EF',
|
||||||
|
warning: '#F59E0B',
|
||||||
|
danger: '#EF4444',
|
||||||
|
info: '#2563EB',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
text: '#ffffff',
|
||||||
|
background: '#071124',
|
||||||
|
backgroundElement: '#111C2F',
|
||||||
|
backgroundSelected: '#143B35',
|
||||||
|
textSecondary: '#B0B8C8',
|
||||||
|
primary: '#34D399',
|
||||||
|
primarySoft: '#0F2F2A',
|
||||||
|
border: '#26364E',
|
||||||
|
warning: '#FBBF24',
|
||||||
|
danger: '#F87171',
|
||||||
|
info: '#60A5FA',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ThemeColor = keyof typeof Colors.light & keyof typeof Colors.dark;
|
||||||
|
|
||||||
|
export const Fonts = Platform.select({
|
||||||
|
ios: {
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignDefault` */
|
||||||
|
sans: 'system-ui',
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignSerif` */
|
||||||
|
serif: 'ui-serif',
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignRounded` */
|
||||||
|
rounded: 'ui-rounded',
|
||||||
|
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
|
||||||
|
mono: 'ui-monospace',
|
||||||
|
},
|
||||||
|
default: {
|
||||||
|
sans: 'normal',
|
||||||
|
serif: 'serif',
|
||||||
|
rounded: 'normal',
|
||||||
|
mono: 'monospace',
|
||||||
|
},
|
||||||
|
web: {
|
||||||
|
sans: 'var(--font-display)',
|
||||||
|
serif: 'var(--font-serif)',
|
||||||
|
rounded: 'var(--font-rounded)',
|
||||||
|
mono: 'var(--font-mono)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Spacing = {
|
||||||
|
half: 2,
|
||||||
|
one: 4,
|
||||||
|
two: 8,
|
||||||
|
three: 12,
|
||||||
|
four: 16,
|
||||||
|
five: 24,
|
||||||
|
six: 32,
|
||||||
|
seven: 48,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const Radius = {
|
||||||
|
sm: 6,
|
||||||
|
md: 8,
|
||||||
|
lg: 12,
|
||||||
|
pill: 999,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const BottomTabInset = Platform.select({ ios: 54, android: 76 }) ?? 0;
|
||||||
|
export const MaxContentWidth = 800;
|
||||||
11
mobile/app/src/features/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# X-Financial Mobile Features
|
||||||
|
|
||||||
|
本目录按业务功能拆分移动端页面和逻辑:
|
||||||
|
|
||||||
|
- `home`:首页聚合、待办、最近报销进度。
|
||||||
|
- `claims`:我的报销、新建报销、草稿、补材料。
|
||||||
|
- `approvals`:审批列表、审批详情、同意、驳回、转交。
|
||||||
|
- `assistant`:AI 助手、语音输入、票据识别建议。
|
||||||
|
- `profile`:个人信息、角色、设置、退出登录。
|
||||||
|
|
||||||
|
初始化阶段页面入口仍放在 `src/app`,后续复杂业务逻辑优先下沉到对应 feature。
|
||||||
9
mobile/app/src/global.css
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
:root {
|
||||||
|
--font-display:
|
||||||
|
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
|
||||||
|
Segoe UI Symbol, Noto Color Emoji;
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
|
||||||
|
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
|
||||||
|
--font-serif: Georgia, 'Times New Roman', serif;
|
||||||
|
}
|
||||||
1
mobile/app/src/hooks/use-color-scheme.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { useColorScheme } from 'react-native';
|
||||||
21
mobile/app/src/hooks/use-color-scheme.web.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||||
|
*/
|
||||||
|
export function useColorScheme() {
|
||||||
|
const [hasHydrated, setHasHydrated] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasHydrated(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colorScheme = useRNColorScheme();
|
||||||
|
|
||||||
|
if (hasHydrated) {
|
||||||
|
return colorScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'light';
|
||||||
|
}
|
||||||
14
mobile/app/src/hooks/use-theme.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Learn more about light and dark modes:
|
||||||
|
* https://docs.expo.dev/guides/color-schemes/
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Colors } from '@/constants/theme';
|
||||||
|
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const scheme = useColorScheme();
|
||||||
|
const theme = scheme === 'unspecified' ? 'light' : scheme;
|
||||||
|
|
||||||
|
return Colors[theme];
|
||||||
|
}
|
||||||
10
mobile/app/src/platform/README.md
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Platform Layer
|
||||||
|
|
||||||
|
平台能力统一放在本目录,页面不直接绑定具体 Expo/原生库。
|
||||||
|
|
||||||
|
- `camera`:相机、相册、票据采集和本地预处理。
|
||||||
|
- `voice`:录音、语音转写、麦克风权限。
|
||||||
|
- `upload`:附件上传、进度、重试、临时附件引用。
|
||||||
|
- `permissions`:Android / iOS 权限文案和降级策略。
|
||||||
|
|
||||||
|
相机与语音都先产生用户可确认的中间结果,不直接触发草稿持久化或提交审批。
|
||||||
55
mobile/app/src/platform/camera/receiptCapture.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
|
||||||
|
export type CapturedReceipt = {
|
||||||
|
uri: string;
|
||||||
|
fileName: string;
|
||||||
|
mimeType?: string;
|
||||||
|
source: 'camera' | 'library';
|
||||||
|
};
|
||||||
|
|
||||||
|
function toReceipt(asset: ImagePicker.ImagePickerAsset, source: CapturedReceipt['source']): CapturedReceipt {
|
||||||
|
return {
|
||||||
|
uri: asset.uri,
|
||||||
|
fileName: asset.fileName || `receipt-${Date.now()}.jpg`,
|
||||||
|
mimeType: asset.mimeType,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function captureReceiptFromCamera(): Promise<CapturedReceipt | null> {
|
||||||
|
const permission = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
throw new Error('需要相机权限才能拍摄票据。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchCameraAsync({
|
||||||
|
allowsEditing: false,
|
||||||
|
quality: 0.84,
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toReceipt(result.assets[0], 'camera');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pickReceiptFromLibrary(): Promise<CapturedReceipt | null> {
|
||||||
|
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (!permission.granted) {
|
||||||
|
throw new Error('需要相册权限才能选择票据图片。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
allowsEditing: false,
|
||||||
|
quality: 0.88,
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.canceled || !result.assets[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return toReceipt(result.assets[0], 'library');
|
||||||
|
}
|
||||||
25
mobile/app/src/platform/voice/voiceInput.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { useAudioRecorder, RecordingPresets } from 'expo-audio';
|
||||||
|
|
||||||
|
export function useVoiceRecorder() {
|
||||||
|
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
recorder,
|
||||||
|
async start() {
|
||||||
|
await recorder.prepareToRecordAsync();
|
||||||
|
recorder.record();
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
await recorder.stop();
|
||||||
|
return recorder.uri;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transcribeVoice(uri: string) {
|
||||||
|
// 后续接入 /api/v1/mobile/voice/transcribe;初始化阶段先返回可见占位结果。
|
||||||
|
return {
|
||||||
|
text: `已收到语音文件:${uri.split('/').pop() || 'voice-recording'}`,
|
||||||
|
confidence: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
11
mobile/app/src/shared/README.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# Shared Layer
|
||||||
|
|
||||||
|
共享层承载跨 feature 的稳定能力:
|
||||||
|
|
||||||
|
- `api`:接口 client、OpenAPI 生成类型、请求错误映射。
|
||||||
|
- `auth`:登录态、SecureStore、后端模拟身份请求头。
|
||||||
|
- `components`:可复用展示组件和触控组件。
|
||||||
|
- `domain`:报销状态、审批阶段、权限判断和 view model 映射。
|
||||||
|
- `mock`:初始化阶段的本地演示数据,接入后端后逐步替换。
|
||||||
|
|
||||||
|
业务状态判断优先放在 `domain`,避免 Web 和 mobile 对同一状态出现不同解释。
|
||||||
29
mobile/app/src/shared/api/client.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Constants from 'expo-constants';
|
||||||
|
|
||||||
|
type ApiOptions = RequestInit & {
|
||||||
|
authHeaders?: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackApiBaseUrl = 'http://10.0.2.2:8000/api/v1';
|
||||||
|
|
||||||
|
export const apiBaseUrl =
|
||||||
|
(Constants.expoConfig?.extra?.apiBaseUrl as string | undefined) || fallbackApiBaseUrl;
|
||||||
|
|
||||||
|
export async function apiRequest<T>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||||
|
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.authHeaders,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
throw new Error(body || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||