chengc 3 weeks ago
parent
commit
bd95d55cde
34 changed files with 2275 additions and 1193 deletions
  1. 1 0
      .gitignore
  2. 30 1
      assets/i18n/en.json
  3. 30 1
      assets/i18n/zh_CN.json
  4. 30 1
      assets/i18n/zh_TW.json
  5. 67 59
      docs/superpowers/specs/tboss-oa-database.md
  6. 186 303
      docs/superpowers/specs/tboss-oa-prd.md
  7. 12 3
      lib/app.dart
  8. 51 0
      lib/core/navigation/host_app_channel.dart
  9. 46 1
      lib/core/network/api_client.dart
  10. 24 0
      lib/core/network/mock_data.dart
  11. 14 12
      lib/core/network/mock_interceptor.dart
  12. 48 0
      lib/core/storage/draft_storage.dart
  13. 42 18
      lib/features/expense/expense_api.dart
  14. 35 0
      lib/features/expense/expense_apply_mapping_model.dart
  15. 24 2
      lib/features/expense/expense_create_controller.dart
  16. 134 5
      lib/features/expense/expense_create_page.dart
  17. 230 411
      lib/features/expense/expense_detail_page.dart
  18. 94 16
      lib/features/expense/expense_list_controller.dart
  19. 6 0
      lib/features/expense/expense_model.dart
  20. 28 20
      lib/features/expense_apply/expense_apply_api.dart
  21. 138 85
      lib/features/expense_apply/expense_apply_create_page.dart
  22. 173 199
      lib/features/expense_apply/expense_apply_detail_page.dart
  23. 80 11
      lib/features/expense_apply/expense_apply_list_controller.dart
  24. 6 0
      lib/features/expense_apply/expense_apply_model.dart
  25. 3 3
      lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart
  26. 16 1
      lib/main.dart
  27. 126 0
      lib/shared/models/attachment_file.dart
  28. 30 6
      lib/shared/models/pagination_model.dart
  29. 66 9
      lib/shared/widgets/app_scaffold.dart
  30. 438 0
      lib/shared/widgets/attachment_picker.dart
  31. 4 15
      lib/shared/widgets/nav_bar_config.dart
  32. 12 10
      lib/shared/widgets/status_banner.dart
  33. 49 1
      pubspec.lock
  34. 2 0
      pubspec.yaml

+ 1 - 0
.gitignore

@@ -53,3 +53,4 @@ app.*.map.json
 .vscode/
 docs/superpowers/prototype/
 
+.claude

+ 30 - 1
assets/i18n/en.json

@@ -395,6 +395,11 @@
     "attachmentUpload": "attachmentUpload",
     "maxAttachment": "maxAttachment",
     "attachments": "attachments",
+    "pickImage": "Select Image",
+    "pickFile": "Select File",
+    "fileTooLarge": "File size exceeds limit",
+    "imageSizeLimit": "Image must be ≤${max}MB",
+    "fileSizeLimit": "File must be ≤${max}MB",
     "addExpenseDetailFirst": "addExpenseDetailFirst"
   },
   "expense": {
@@ -884,6 +889,30 @@
     "itDept": "itDept",
     "adminDept": "adminDept",
     "marketDept": "marketDept",
-    "techDept": "techDept"
+    "techDept": "techDept",
+    "expenseApplyNo": "Application No.",
+    "critical": "Critical",
+    "unused": "Unused",
+    "partiallyUsed": "Partially Used",
+    "fullyUsed": "Fully Used",
+    "usageStatus": "Usage Status",
+    "bankTransferNo": "Bank Transfer No.",
+    "approvalStepSubmitted": "Submitted",
+    "approvalStepApproved": "Approved",
+    "approvalStepFinanceReview": "Finance Review",
+    "approvalStepInvoice": "Invoice Verification",
+    "approvalStepPayment": "Payment & Archive",
+    "approvalStepArchive": "Archive",
+    "approvalStepArchiveDesc": "Auto-archived after approval completes",
+    "approvalStepPaymentDesc": "Pending all 3 verifications",
+    "approvalDescSubmitted": "Zhang San",
+    "approvalDescApproved": "Li Si — Approved",
+    "approvalDescFinanceReview": "Wang — Finance Dept.",
+    "approvalDescInvoice": "Wang — Finance Dept.",
+    "draftFound": "Draft Found",
+    "draftRestorePrompt": "An unfinished form was found. Restore it?",
+    "discard": "Discard",
+    "restore": "Restore",
+    "noAttachment": "No attachments"
   }
 }

+ 30 - 1
assets/i18n/zh_CN.json

@@ -341,6 +341,11 @@
     "attachmentUpload": "附件上传",
     "maxAttachment": "最多上传9个附件,支持图片/PDF/Word/Excel(图片≤10MB,文档≤20MB)",
     "attachments": "附件",
+    "pickImage": "选择图片",
+    "pickFile": "选择文件",
+    "fileTooLarge": "文件大小超过限制",
+    "imageSizeLimit": "图片大小不能超过${max}MB",
+    "fileSizeLimit": "文件大小不能超过${max}MB",
     "addExpenseDetailFirst": "请在明细中添加费用项"
   },
   "expense": {
@@ -736,6 +741,30 @@
     "itDept": "信息技术部",
     "adminDept": "行政部",
     "marketDept": "市场部",
-    "techDept": "技术部"
+    "techDept": "技术部",
+    "expenseApplyNo": "申请单号",
+    "critical": "特急",
+    "unused": "未使用",
+    "partiallyUsed": "部分使用",
+    "fullyUsed": "已用完",
+    "usageStatus": "使用状态",
+    "bankTransferNo": "银行流水号",
+    "approvalStepSubmitted": "已提交",
+    "approvalStepApproved": "审核同意",
+    "approvalStepFinanceReview": "财务审批",
+    "approvalStepInvoice": "发票查验",
+    "approvalStepPayment": "打款归档",
+    "approvalStepArchive": "待归档",
+    "approvalStepArchiveDesc": "审批流程结束后自动归档",
+    "approvalStepPaymentDesc": "待三项查验全部通过",
+    "approvalDescSubmitted": "张三",
+    "approvalDescApproved": "李四 — 同意报销",
+    "approvalDescFinanceReview": "王财务 — 财务部",
+    "approvalDescInvoice": "王财务 — 财务部",
+    "draftFound": "发现草稿",
+    "draftRestorePrompt": "检测到上次未完成的填写记录,是否恢复?",
+    "discard": "丢弃",
+    "restore": "恢复",
+    "noAttachment": "暂无附件"
   }
 }

+ 30 - 1
assets/i18n/zh_TW.json

@@ -341,6 +341,11 @@
     "attachmentUpload": "附件上传",
     "maxAttachment": "最多上传9個附件,支持圖片/PDF/Word/Excel(圖片≤10MB,文檔≤20MB)",
     "attachments": "附件",
+    "pickImage": "選擇圖片",
+    "pickFile": "選擇文件",
+    "fileTooLarge": "文件大小超過限制",
+    "imageSizeLimit": "圖片大小不能超過${max}MB",
+    "fileSizeLimit": "文件大小不能超過${max}MB",
     "addExpenseDetailFirst": "請在明細中添加費用項"
   },
   "expense": {
@@ -736,6 +741,30 @@
     "itDept": "信息技术部",
     "adminDept": "行政部",
     "marketDept": "市場部",
-    "techDept": "技术部"
+    "techDept": "技術部",
+    "expenseApplyNo": "申請單號",
+    "critical": "特急",
+    "unused": "未使用",
+    "partiallyUsed": "部分使用",
+    "fullyUsed": "已用完",
+    "usageStatus": "使用狀態",
+    "bankTransferNo": "銀行流水號",
+    "approvalStepSubmitted": "已提交",
+    "approvalStepApproved": "審核同意",
+    "approvalStepFinanceReview": "財務審批",
+    "approvalStepInvoice": "發票查驗",
+    "approvalStepPayment": "打款歸檔",
+    "approvalStepArchive": "待歸檔",
+    "approvalStepArchiveDesc": "審批流程結束後自動歸檔",
+    "approvalStepPaymentDesc": "待三項查驗全部通過",
+    "approvalDescSubmitted": "張三",
+    "approvalDescApproved": "李四 — 同意報銷",
+    "approvalDescFinanceReview": "王財務 — 財務部",
+    "approvalDescInvoice": "王財務 — 財務部",
+    "draftFound": "發現草稿",
+    "draftRestorePrompt": "檢測到上次未完成的填寫記錄,是否恢復?",
+    "discard": "丟棄",
+    "restore": "恢復",
+    "noAttachment": "暫無附件"
   }
 }

+ 67 - 59
docs/superpowers/specs/tboss-oa-database.md

@@ -1,6 +1,6 @@
 # TBOSS OA 模块 — 数据库表结构设计
 
-> 版本:v1.0 | 日期:2026-06-04 | 基于 PRD v1.0
+> 版本:v1.1 | 日期:2026-06-27 | 基于 PRD v1.0 + 需求讨论确认
 
 ---
 
@@ -85,8 +85,8 @@
   │                    OA 自管业务表                        │
   │                                                       │
   │  ExpenseApply ──(1:N)── ExpenseApplyDetail        │
-  │       │                         (明细行)
-  │          
+  │       │                  (明细行)      
+  │       
   │  ExpenseApplyMapping ── 申请↔报销多对多          │
   │       ├──(N:1)── Expense                              │
   │       │            (费用明细, 含币种)                   │
@@ -133,6 +133,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **PermissionName** | NVARCHAR(50) | ✅ | 权限点中文名称,如"发起报销",用于管理界面展示 |
 | **Module** | VARCHAR(30) | ✅ | 所属功能模块,用于分组展示:expense / expense_apply / overtime / vehicle / outing_log / announcement / report / admin |
 | **SortOrder** | INT | ✅ | 管理界面排序权重,值越小越靠前,默认 0 |
+| **IsActive** | BIT | ✅ | 权限点全局启用/停用标记,1=启用,0=停用,默认 1。停用后所有用户无法使用该权限 |
 
 > 本表为字典表,不包含 CreateTime/UpdateTime/IsDeleted。权限点预置后一般不增删。
 
@@ -164,8 +165,8 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **TargetUserId** | NVARCHAR(30) | ✅ | 被操作的目标用户 ERP 用户 ID |
 | **OperatorId** | NVARCHAR(30) | ✅ | 执行操作的管理员 ERP 用户 ID |
 | **ChangeType** | VARCHAR(20) | ✅ | 变更类型:`assign`(赋权) / `revoke`(收权) / `toggle_active`(启停) |
-| **BeforeSnapshot** | NVARCHAR(MAX) | ✅ | 变更前权限 JSON 快照(含权限列表 + IsActive 状态) |
-| **AfterSnapshot** | NVARCHAR(MAX) | ✅ | 变更后权限 JSON 快照 |
+| **BeforeSnapshot** | NVARCHAR(MAX) | ✅ | 变更前权限 JSON 快照,格式 `{"permissions": ["oa.expense.apply"], "isActive": true}` |
+| **AfterSnapshot** | NVARCHAR(MAX) | ✅ | 变更后权限 JSON 快照,格式同上 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE(),审计时间戳 |
 
 > 本表无 UpdateTime 和 IsDeleted —— 审计日志不可修改、不可删除。
@@ -195,12 +196,13 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **Remark** | NVARCHAR(500) | | 备注,补充说明 |
 | **EffectiveDate** | DATETIME | | 生效时间 |
 | **AuditorId** | NVARCHAR(30) | | 审核人 ERP 用户 ID |
-| **Status** | VARCHAR(20) | ✅ | 业务状态:draft(草稿)/pending(审批中)/approved(已通过)/rejected(已拒绝)/withdrawn(已撤回)。审批状态通过 .NET → ERP 实时查询 |
-| **UsageStatus** | VARCHAR(20) | ✅ | 被报销引用的状态:unused(未引用)/partially_used(部分引用)/fully_used(已用完)。由报销单提交/删除时自动重算,默认 unused |
-| **ValidUntil** | DATE | | 申请有效期截止日。到期后定时 Job 自动释放冻结预算,UsageStatus 标记为 fully_used,不可再被报销引用 |
+| **Status** | VARCHAR(20) | ✅ | 业务状态:draft(草稿)/pending(审批中)/approved(已通过)/rejected(已拒绝)/withdrawn(已撤回)/archived(已归档)。审批状态通过 .NET → ERP 实时查询。当 UsageStatus=fully_used 时自动流转为 archived |
+| **UsageStatus** | VARCHAR(20) | ✅ | 被报销引用的状态:unused(未引用)/partially_used(部分引用)/fully_used(已用完)。由报销单提交/删除时自动重算,默认 unused。在非 approved/archived 状态下始终为 unused |
+| **ValidUntil** | DATE | | 申请有效期截止日。到期后定时 Job 自动释放冻结预算,UsageStatus 标记为 fully_used,Status 流转为 archived,不可再被报销引用 |
 | **ReferenceNo** | NVARCHAR(100) | | 关联的外部单号(合同号/询价单号),用于采购类申请追溯,非必填 |
 | **ApprovalInstanceId** | VARCHAR(50) | | ERP 审批实例 ID,提交审批时由 .NET → ERP 创建后回写。撤回/驳回重新提交时覆盖为新值 |
 | **PreviousInstanceIds** | VARCHAR(MAX) | | 历史审批实例 ID JSON 数组,撤回/驳回重新提交时追加旧 ApprovalInstanceId,用于追溯完整审批历史 |
+| **Version** | BIGINT | ✅ | 乐观锁并发控制字段,每次更新时 +1,默认 1 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 | **UpdateTime** | DATETIME | | |
 | **IsDeleted** | BIT | ✅ | DEFAULT 0 |
@@ -221,8 +223,8 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **Purpose** | NVARCHAR(500) | ✅ | 费用事由,限制 500 字 |
 | **ProjectId** | NVARCHAR(60) | | 关联 ERP 项目代号 |
 | **ProjectName** | NVARCHAR(200) | | 关联 ERP 项目名称,提交时写入 |
-| **CostDeptId** | NVARCHAR(30) | | 费用承担部门 ERP 部门 ID |
-| **CostDeptName** | NVARCHAR(200) | | 费用承担部门名称冗余,提交时写入 |
+| **CostDeptId** | NVARCHAR(30) | | 费用承担部门 ERP 部门 ID,V1 中自动等于申请人所在 DeptId |
+| **CostDeptName** | NVARCHAR(200) | | 费用承担部门名称冗余,V1 中自动等于申请人所在 DeptName |
 | **AcctSubjectId** | NVARCHAR(50) | | 关联 ERP 会计科目代号,选择费用类型时会自动带出,值来源于 SysCostCategory.AcctSubjectId |
 | **AcctSubjectName** | NVARCHAR(400) | | 关联 ERP 会计科目名称冗余,选择费用类型时会自动带出,值来源于 SysCostCategory.AcctSubjectName |
 | **EstimatedStartDate** | DATE | | 预计开始日期,所有费用类型可见。选填 |
@@ -245,7 +247,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **Id** | BIGINT | ✅ | 主键,IDENTITY(1,1) |
 | **ExpenseId** | BIGINT | ✅ | 报销单 ID,FK → Expense.Id |
 | **ExpenseApplyId** | BIGINT | ✅ | 费用申请 ID,FK → ExpenseApply.Id |
-| **ImportedAmount** | DECIMAL(28,8) | ✅ | 本次报销从该申请导入的金额。∑ImportedAmount 不能超过申请 EstimatedAmount |
+| **ImportedAmount** | DECIMAL(28,8) | ✅ | 本次报销从该申请导入的金额。∑ImportedAmount 不能超过申请 EstimatedAmount。仅 `Status IN ('pending', 'approved', 'archived')` 的报销单计入占用总额,`withdrawn` 和 `IsDeleted=1` 的报销单不计入 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 
 **约束**:`UX_ExpenseApplyMapping_ExpenseId_ExpenseApplyId` UNIQUE (ExpenseId, ExpenseApplyId) — 同一张报销单不能重复导入同一张申请
@@ -271,13 +273,13 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **TotalAmount** | DECIMAL(28,8) | ✅ | 报销总金额 = 所有明细行 TotalAmount 汇总,只读不手动修改,默认 0 |
 | **Purpose** | NVARCHAR(500) | ✅ | 报销事由说明 |
 | **Remark** | NVARCHAR(500) | | 明细项说明备注 |
-| **PaymentMethod** | NVARCHAR(20) | | 支付方式,bankTransfer(银行转账)/cash(现金)/alipay(支付宝)/wechat(微信) |
+| **PaymentMethod** | NVARCHAR(20) | | 支付方式,员工填单时选择:bankTransfer(银行转账)/cash(现金)/alipay(支付宝)/wechat(微信) |
 | **IsInvoiceVerified** | BIT | ✅ | 财务核销标记一:发票已在全国增值税发票查验平台验真,默认 0 |
 | **IsTaxIdMatched** | BIT | ✅ | 财务核销标记二:发票抬头与公司税号一致,默认 0 |
 | **IsCategoryCompliant** | BIT | ✅ | 财务核销标记三:报销类目与发票项目匹配合规,默认 0 |
 | **BankTransferNo** | VARCHAR(50) | | 财务核销时录入的银行电汇流水号 |
 | **PaymentStatus** | VARCHAR(20) | ✅ | 付款状态:unpaid(待付款)/paid(已付款)。仅 Status=approved 时允许流转,默认 unpaid |
-| **Status** | VARCHAR(20) | ✅ | 业务状态:draft/pending/approved/rejected/withdrawn。审批状态通过 .NET → ERP 实时查询 |
+| **Status** | VARCHAR(20) | ✅ | 业务状态:draft/pending/approved/rejected/withdrawn/archived(已归档)。审批状态通过 .NET → ERP 实时查询。PaymentStatus=paid 且业务完结后自动流转为 archived |
 | **ApprovalInstanceId** | VARCHAR(50) | | ERP 审批实例 ID,提交审批时由 .NET → ERP 创建后回写 |
 | **PreviousInstanceIds** | VARCHAR(MAX) | | 历史审批实例 ID JSON 数组 |
 | **EffectiveDate** | DATETIME | | 生效时间 |
@@ -302,8 +304,8 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **Remark** | NVARCHAR(500) | | 明细项说明备注 |
 | **ProjectId** | NVARCHAR(60) | | 关联 ERP 项目代号 |
 | **ProjectName** | NVARCHAR(200) | | 关联 ERP 项目名称,提交时写入 |
-| **CostDeptId** | NVARCHAR(30) | | 费用承担部门 ERP 部门 ID |
-| **CostDeptName** | NVARCHAR(200) | | 费用承担部门名称冗余,提交时写入 |
+| **CostDeptId** | NVARCHAR(30) | | 费用承担部门 ERP 部门 ID,V1 中自动等于申请人所在 DeptId |
+| **CostDeptName** | NVARCHAR(200) | | 费用承担部门名称冗余,V1 中自动等于申请人所在 DeptName |
 | **AcctSubjectId** | NVARCHAR(50) | | 关联 ERP 会计科目代号,选择费用类型时会自动带出,值来源于 SysCostCategory.AcctSubjectId |
 | **AcctSubjectName** | NVARCHAR(400) | | 关联 ERP 会计科目名称冗余,选择费用类型时会自动带出,值来源于 SysCostCategory.AcctSubjectName |
 | **Amount** | DECIMAL(28,8) | ✅ | 原币不含税金额。多币种时为原币金额 |
@@ -313,10 +315,10 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **CurrencyCode** | VARCHAR(10) | | 原币币种代码,默认 CNY。选外币(如 USD/EUR/JPY)时从 .NET → ERP ExchangeRateService 获取当日汇率自动填入 ExchangeRate |
 | **ExchangeRate** | DECIMAL(10,4) | | 原币→本币汇率,CNY 时为 1.0000。由 .NET 服务端从 ERP 汇率表查询后填入 |
 | **BaseAmount** | DECIMAL(28,8) | | 折算后的本币金额 = TotalAmount × ExchangeRate。预算校验和报表统计均以本币为准 |
-| **ApprovedAmount** | DECIMAL(28,8) | ✅ | 该行核准金额,默认=TotalAmount(审批直接通过时),审批人可修改。当 ApprovedAmount 发生变化时,应以 ApprovedAmount 为基准重新计算本币金额:BaseAmount = ApprovedAmount × ExchangeRate |
-| **CustomerVendorId** | NVARCHAR(30) | | 客户或供应商Id,用于采购/招待类费用追溯 |
+| **ApprovedAmount** | DECIMAL(28,8) | ✅ | 该行核准金额,默认=TotalAmount(审批直接通过时),审批人可修改。当 ApprovedAmount 发生变化时,应以 ApprovedAmount 为基准重新计算本币金额:BaseAmount = ApprovedAmount × ExchangeRate。财务核销时只读不可修改 |
+| **CustomerVendorId** | NVARCHAR(30) | | 客户或供应商 ID,用于采购/招待类费用追溯。客户和供应商均可使用 |
 | **CustomerVendorName** | NVARCHAR(300) | | 客户或供应商名称,用于采购/招待类费用追溯 |
-| **OffsetAmount** | DECIMAL(28,8) | ✅ | 已冲账金额(如预付/借款冲抵),默认 0 |
+| **OffsetAmount** | DECIMAL(28,8) | ✅ | 已冲账金额(如预付/借款冲抵),V1 暂不使用,值始终为 0,默认 0。预留 V2 扩展 |
 | **BankName** | NVARCHAR(100) | ✅ | 收款银行全称,前端支持下拉联想(数据源 .NET 字典 API),也可自由输入 |
 | **BankAccountName** | NVARCHAR(50) | ✅ | 收款开户户名,默认填入当前用户姓名,可修改 |
 | **BankAccount** | VARCHAR(50) | ✅ | 收款银行账号,前端校验 16-19 位数字格式 |
@@ -348,7 +350,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **EndTime** | DATETIME | | 加班结束时间,提交时必填 |
 | **NetOtHours** | DECIMAL(4,1) | ✅ | 实际净工时。由 .NET 服务端自动扣除午餐(12:00-13:00)和晚餐(18:00-18:30)盲区后计算,前端只读展示。≤0 时提交按钮置灰 |
 | **Reason** | NVARCHAR(500) | ✅ | 加班原因说明 |
-| **Status** | VARCHAR(20) | ✅ | 业务状态:draft/pending/approved/rejected/withdrawn。审批状态通过 .NET → ERP 实时查询 |
+| **Status** | VARCHAR(20) | ✅ | 业务状态:draft/pending/approved/rejected/withdrawn/archived(已归档)。审批状态通过 .NET → ERP 实时查询。业务完结(加班费已发放或调休已确认)后自动流转为 archived |
 | **ApprovalInstanceId** | VARCHAR(50) | | ERP 审批实例 ID |
 | **PreviousInstanceIds** | VARCHAR(MAX) | | 历史审批实例 ID JSON 数组 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
@@ -389,7 +391,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **EndOdometer** | DECIMAL(10,2) | | 还车登记:还车后里程表读数。校验:必须 > StartOdometer |
 | **ActualCost** | DECIMAL(28,8) | | 还车登记:路桥费/停车费等实际费用总额 |
 | **CostRemark** | NVARCHAR(500) | | 还车登记:费用明细备注 |
-| **Status** | VARCHAR(20) | ✅ | 业务状态:draft/pending/approved/rejected/withdrawn/returned(已还车)。审批状态通过 .NET → ERP 实时查询 |
+| **Status** | VARCHAR(20) | ✅ | 业务状态:draft/pending/approved/rejected/withdrawn/returned(已还车)/archived(已归档)。审批状态通过 .NET → ERP 实时查询。还车登记完成(returned)后自动流转为 archived |
 | **ApprovalInstanceId** | VARCHAR(50) | | ERP 审批实例 ID |
 | **PreviousInstanceIds** | VARCHAR(MAX) | | 历史审批实例 ID JSON 数组 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
@@ -424,7 +426,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **Brand** | NVARCHAR(50) | | 品牌型号 |
 | **Seats** | INT | | 核定座位数 |
 | **DriverName** | NVARCHAR(50) | | 默认驾驶员姓名 |
-| **Status** | VARCHAR(20) | ✅ | 车辆状态:idle(空闲可用)/in_use(使用中)/maintenance(维修中)。审批通过自动变更为 in_use,还车后恢复 idle |
+| **Status** | VARCHAR(20) | ✅ | 车辆状态:idle(空闲可用)/in_use(使用中)/maintenance(维修中)。用车申请审批通过自动变更为 in_use,还车登记完成(Vehicle.Status=returned/archived)后恢复 idle。maintenance 状态下不可被申请 |
 | **IsActive** | BIT | ✅ | 启用/停用标记,默认 1。停用的车辆不可被申请 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 | **UpdateTime** | DATETIME | | |
@@ -442,7 +444,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 |------|------|------|------|
 | **Id** | BIGINT | ✅ | 主键,IDENTITY(1,1) |
 | **VisitNo** | VARCHAR(30) | ✅ | 单据唯一编号,格式 VST-YYYYMMDD-XXX,`UX_OutingLog_VisitNo` UNIQUE 约束 |
-| **SalespersonId** | NVARCHAR(30) | ✅ | 业务员 ERP 用户 ID |
+| **SalespersonId** | NVARCHAR(30) | ✅ | 业务员 ERP 用户 ID(外勤日志使用 SalespersonId 而非 ApplicantId,强调销售角色属性) |
 | **DeptId** | NVARCHAR(30) | ✅ | 所属部门 ERP 部门 ID |
 | **CustomerId** | NVARCHAR(30) | | 拜访客户 ERP 客户 ID(非 FK)。输入新客户名时,提交后由 .NET → ERP CustomerService 创建新客户并回填此字段 |
 | **CustomerName** | NVARCHAR(300) | ✅ | 客户公司全称冗余字段,前端输入时调 ERP 联想匹配,也支持自由输入 |
@@ -452,11 +454,11 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **CheckInAddress** | NVARCHAR(500) | | 逆地理编码出的街道地址,前端设为只读,不可手动修改 |
 | **VisitSummary** | NVARCHAR(2000) | ✅ | 今日工作核心总结 |
 | **NextPlan** | NVARCHAR(500) | | 后续推进计划 |
-| **Status** | VARCHAR(20) | ✅ | 状态:draft(草稿)/completed(已提交)。不走审批流,提交即完成 |
-| **LastViewedTime** | DATETIME | | 员工最后一次查看详情页的时间,用于判断是否有新点评(红点逻辑) |
+| **Status** | VARCHAR(20) | ✅ | 状态:draft(草稿)/completed(已提交)。不走审批流,提交即完成,提交后锁定不可再编辑 |
+| **LastViewedTime** | DATETIME | | 员工最后一次查看详情页的时间(仅打开详情页时更新),用于判断是否有新点评(红点逻辑:MAX(CreateTime) > LastViewedTime) |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE(),服务器授时(用于防伪水印) |
 | **UpdateTime** | DATETIME | | |
-| **IsDeleted** | BIT | ✅ | DEFAULT 0 |
+| **IsDeleted** | BIT | ✅ | DEFAULT 0(仅管理员可软删除已提交日志) |
 
 **索引**:
 - `IX_OutingLog_Sales` (SalespersonId, CreateTime DESC) INCLUDE (CustomerId, CustomerName, CheckInAddress)
@@ -473,8 +475,8 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **CommenterId** | NVARCHAR(30) | ✅ | 点评人(经理)ERP 用户 ID |
 | **RatingStars** | INT | | 星级评分 1-5,CHECK 约束 (RatingStars IS NULL OR RatingStars >= 1 AND RatingStars <= 5) |
 | **CommentText** | NVARCHAR(1000) | ✅ | 点评指导意见文字内容 |
-| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
-| **UpdateTime** | DATETIME | | |
+| **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE()(红点判定基于此字段) |
+| **UpdateTime** | DATETIME | |(修改点评不更新 CreateTime,不触发新红点) |
 | **IsDeleted** | BIT | ✅ | DEFAULT 0 |
 
 **索引**:`IX_OutingLogComment_LogId` (LogId, IsDeleted) INCLUDE (CommenterId, RatingStars, CommentText, CreateTime)
@@ -513,8 +515,8 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 |------|------|------|------|
 | **Id** | BIGINT | ✅ | 主键,IDENTITY(1,1) |
 | **AnnouncementId** | BIGINT | ✅ | 关联公告主表,FK → Announcement.Id |
-| **TargetType** | VARCHAR(10) | ✅ | 目标实体类型:dept(按部门)/user(按指定个人) |
-| **TargetId** | NVARCHAR(30) | ✅ | 多态外键:TargetType=dept 时指向 ERP 部门 ID,TargetType=user 时指向 ERP 用户 ID |
+| **TargetType** | VARCHAR(10) | ✅ | 目标实体类型:all(全员)/dept(按部门)/user(按指定个人)。PrivateLevel=0 时写入一条 all 记录 |
+| **TargetId** | NVARCHAR(30) | ✅ | 多态外键:TargetType=all 时为 'ALL';TargetType=dept 时指向 ERP 部门 ID;TargetType=user 时指向 ERP 用户 ID |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 | **UpdateTime** | DATETIME | | |
 
@@ -529,7 +531,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **UserId** | NVARCHAR(30) | ✅ | 被触达员工 ERP 用户 ID |
 | **IsRead** | BIT | ✅ | 已读标记:0=未读,1=已读。停留 ≥2 秒自动标记,默认 0 |
 | **ReadTime** | DATETIME | | 员工实际阅读时间 |
-| **IsUrged** | BIT | ✅ | 是否已被管理员 DING 催办过,默认 0 |
+| **IsUrged** | BIT | ✅ | 是否已被管理员 DING 催办过,默认 0(催办后仍可再次催办,更新 LastUrgeTime) |
 | **LastUrgeTime** | DATETIME | | 最后一次 DING 催办时间 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 | **UpdateTime** | DATETIME | | |
@@ -551,7 +553,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **DetailId** | BIGINT | | 仅 BizType='expense' 使用,绑定 ExpenseDetail.Id,实现发票与明细行的关联 |
 | **FileName** | NVARCHAR(200) | ✅ | 原始文件名 |
 | **FileUrl** | VARCHAR(500) | ✅ | 云端对象存储(OSS)绝对 URL |
-| **FileType** | VARCHAR(20) | ✅ | 文件类型:image/pdf/doc/xls(通用)/ sign_in_photo(签到照)/ visit_photo(现场照)/ other(外勤专用) |
+| **FileType** | VARCHAR(20) | ✅ | 文件类型:image(通用图片)/pdf/doc/xls/sign_in_photo(签到照)/visit_photo(现场照)/other |
 | **FileSize** | BIGINT | | 文件字节数,用于上传大小校验 |
 | **SortOrder** | INT | | 排序号,默认 0。外勤照片墙按此排序展示 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
@@ -561,7 +563,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 **应用层约束**(前端 + .NET 校验):
 - expense_apply:≤9 个,图片 ≤20MB / PDF/Word/Excel ≤20MB
 - expense:≤9 个,图片 ≤20MB / PDF/Word/Excel ≤20MB
-- outing_log:1~9 张,仅支持 image 类型,≤20MB
+- outing_log:1~9 张,仅支持 image/sign_in_photo/visit_photo 类型,≤20MB
 - announcement:≤9 个,PDF/图片/Word/Excel ≤20MB
 
 **索引**:`IX_Attachment_Biz` (BizType, BizId, IsDeleted) INCLUDE (FileName, FileUrl, FileType, FileSize, DetailId, SortOrder)
@@ -587,7 +589,7 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 | **IsActive** | BIT | ✅ | 启用/停用标记:1=启用,0=停用 |
 | **DisableDate** | DATETIME | | 停用日期,到期后该类别不可被新单据引用 |
 | **IsAttachmentRequired** | BIT | ✅ | 是否需要附件:0=不需要,1=必须上传附件,DEFAULT 0 |
-| **BizScope** | VARCHAR(20) | ✅ | 适用业务范围:expense_apply(仅费用申请)/expense(仅报销)/both(通用),默认 both |
+| **BizScope** | VARCHAR(20) | ✅ | 适用业务范围:expense_apply(仅费用申请)/expense(仅报销)/both(通用),默认 both。费用申请下拉仅显示 expense_apply 和 both;报销下拉仅显示 expense 和 both;导入报销时也不豁免 |
 | **CreateTime** | DATETIME | ✅ | DEFAULT GETDATE() |
 | **UpdateTime** | DATETIME | | |
 | **IsDeleted** | BIT | ✅ | DEFAULT 0 |
@@ -640,15 +642,21 @@ OA 独立权限系统的权限点定义表,存储所有可用权限编码及
 消息通知 → .NET 服务端消息模块在 ERP 审批事件后触发推送
 ```
 
+**ERP 创建审批实例失败处理**:保持 `draft` 状态,提示"提交审批失败,请稍后重试",用户可重新提交。
+
+**ERP 创建成功但 OA 回写失败处理**:采用异步补偿机制,后台定时任务扫描"创建成功但未回写"的事务日志,补偿回写 `ApprovalInstanceId`。
+
 ### 4.3 费用申请-报销联动
 
 `UsageStatus` 由 .NET 服务端在报销单状态变更时自动重算:
 
-| 已报总额 | UsageStatus |
-|----------|-------------|
-| SUM(ExpenseApplyMapping.ImportedAmount) = 0 | unused |
-| > 0 且 < EstimatedAmount | partially_used |
-| ≥ EstimatedAmount | fully_used |
+| 已报总额(仅计入 Status IN ('pending', 'approved', 'archived') 的报销单) | UsageStatus | Status 联动 |
+|----------|-------------|-------------|
+| SUM(ExpenseApplyMapping.ImportedAmount) = 0 | unused | 保持当前状态 |
+| > 0 且 < EstimatedAmount | partially_used | 保持当前状态 |
+| ≥ EstimatedAmount | fully_used | 自动流转为 `archived` |
+
+> **注意**:`Status='withdrawn'` 和 `IsDeleted=1` 的报销单不计入已报总额。
 
 ### 4.4 级联删除(软删除)
 
@@ -758,7 +766,6 @@ INCLUDE (Amount, TotalAmount, ApprovedAmount, CustomerVendorName, OffsetAmount);
 CREATE NONCLUSTERED INDEX IX_ExpenseApplyDetail_AppId 
 ON ExpenseApplyDetail (ExpenseApplyId, SortOrder) 
 INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
-
 ```
 
 ---
@@ -767,18 +774,19 @@ INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
 
 ### 6.1 单据业务状态
 
-审批状态(draft/pending/approved/rejected/withdrawn)仅 draft 由 OA 本地直接写入,其余状态由 ERP 审批引擎管理,OA 通过 .NET → ERP 实时查询。
+审批状态(draft/pending/approved/rejected/withdrawn)仅 draft 由 OA 本地直接写入,其余状态由 ERP 审批引擎管理,OA 通过 .NET → ERP 实时查询。`archived` 为业务完结终态,由 OA 本地根据业务规则自动流转。
 
-| 值 | 含义 | 适用表 | 写入方 |
-|----|------|--------|--------|
-| `draft` | 草稿 | 全部业务表 | OA 本地 |
-| `pending` | 待审批 | ExpenseApply, Expense, Overtime, Vehicle | OA 写入后 ERP 接管 |
-| `approved` | 已通过 | 同上 | ERP 管理 |
-| `rejected` | 已拒绝 | 同上 | ERP 管理 |
-| `withdrawn` | 已撤回 | 同上 | OA 本地 |
-| `returned` | 已还车 | Vehicle(独有) | OA 本地 |
-| `completed` | 已提交 | OutingLog(不走审批流) | OA 本地 |
-| `published` | 已发布 | Announcement(不走审批流) | OA 本地 |
+| 值 | 含义 | 适用表 | 写入方 | 触发条件 |
+|----|------|--------|--------|---------|
+| `draft` | 草稿 | 全部业务表 | OA 本地 | 用户存草稿 |
+| `pending` | 待审批 | ExpenseApply, Expense, Overtime, Vehicle | OA 写入后 ERP 接管 | 提交审批 |
+| `approved` | 已通过 | 同上 | ERP 管理 | 审批全部通过 |
+| `rejected` | 已拒绝 | 同上 | ERP 管理 | 审批人拒绝 |
+| `withdrawn` | 已撤回 | 同上 | OA 本地 | 员工撤回 |
+| `returned` | 已还车 | Vehicle(独有) | OA 本地 | 还车登记完成 |
+| `completed` | 已提交 | OutingLog(不走审批流) | OA 本地 | 提交日志 |
+| `published` | 已发布 | Announcement(不走审批流) | OA 本地 | 发布公告 |
+| `archived` | 已归档 | ExpenseApply, Expense, Overtime, Vehicle | OA 本地 | 业务完结:ExpenseApply.UsageStatus=fully_used;Expense.PaymentStatus=paid;Overtime 业务完结;Vehicle 还车登记完成 |
 
 ### 6.2 付款状态(PaymentStatus)
 
@@ -801,7 +809,7 @@ INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
 |----|------|
 | `unused` | 未被报销引用 |
 | `partially_used` | 部分金额已报销 |
-| `fully_used` | 全部金额已报销,不可再被引用 |
+| `fully_used` | 全部金额已报销,不可再被引用。触发 Status 流转为 archived |
 
 ### 6.5 加班类型(OtType)
 
@@ -854,11 +862,11 @@ INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
 
 ### 6.11 公告范围(PrivateLevel / TargetType)
 
-| PrivateLevel | 含义 | TargetType | 含义 |
-|-------------|------|-----------|------|
-| `0` | 全员 | — | — |
-| `1` | 按部门 | `dept` | TargetId → ERP 部门 ID |
-| `2` | 按用户 | `user` | TargetId → ERP 用户 ID |
+| PrivateLevel | 含义 | TargetType | TargetId |
+|-------------|------|-----------|----------|
+| `0` | 全员 | `all` | `'ALL'` |
+| `1` | 按部门 | `dept` | ERP 部门 ID |
+| `2` | 按用户 | `user` | ERP 用户 ID |
 
 ### 6.12 费用类别适用范围(BizScope)
 
@@ -872,7 +880,7 @@ INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
 
 | 值 | 含义 | 适用 BizType |
 |----|------|------------|
-| `image` | 图片(JPG/PNG/WebP) | 通用 |
+| `image` | 通用图片(JPG/PNG/WebP) | expense_apply / expense / announcement |
 | `pdf` | PDF 文档 | expense_apply / expense / announcement |
 | `doc` | Word 文档 | expense_apply / expense / announcement |
 | `xls` | Excel 表格 | expense_apply / expense / announcement |
@@ -912,8 +920,8 @@ INCLUDE (ExpenseCategory, EstimatedAmount, Remark);
 |----|------|
 | `assign` | 赋予权限 |
 | `revoke` | 移除权限 |
-| `toggle_active` | 启用/禁用 |
+| `toggle_active` | 启用/禁用(权限点全局) |
 
 ---
 
-> **文档版本**:v1.0 | 日期:2026-06-04
+> **文档版本**:v1.1 | 日期:2026-06-27

+ 186 - 303
docs/superpowers/specs/tboss-oa-prd.md

@@ -1,6 +1,6 @@
 # TBOSS OA 模块 — 产品需求文档
 
-> 版本:v1.0 | 日期:2026-06-04 | 状态:待评审
+> 版本:v1.1 | 日期:2026-06-27 | 状态:待评审
 
 ---
 
@@ -39,7 +39,7 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 |------|--------|---------|
 | 报销全流程耗时 | 从填单到付款到账 ≤3 个工作日 | 系统记录 CreateTime → PaymentStatus=paid 时间差 |
 | 移动端 OA 覆盖率 | 80% 以上 OA 单据通过移动端发起 | 移动端提交量 / 总提交量 |
-| 公告触达率 | 发布后 24h 内 ≥85% 员工已读 | AnnouncementReadLog.IsRead=1 / 应达总人数 |
+| 公告触达率 | 发布后 24h 内 ≥85% 员工已读 | AnnouncementReadLog.IsRead=1 / 应达总人数(发布时固定快照) |
 | 财务核销效率 | 单笔核销操作 ≤2 分钟 | 详情页打开 → 归档完成时间差 |
 | 系统可用性 | 99.5%(月度) | 监控平台 |
 
@@ -56,6 +56,8 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 | 独立的消息推送通道 | 复用 .NET 服务端已有推送 |
 | 电子签章 | — |
 | PC Web 端 | V1 仅移动端(Flutter) |
+| 预付/借款冲抵 | V1 暂不支持(OffsetAmount 字段保留为 0) |
+| 跨部门费用承担 | V1 费用承担部门自动等于申请人所属部门 |
 
 ---
 
@@ -86,10 +88,10 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 | 属性 | 描述 |
 |------|------|
 | **身份** | 财务核销专员,负责发票合规查验和线下打款确认 |
-| **日常场景** | 查看全公司已通过待付款的报销单 → 核验发票真伪/税号/类目合规 → 线下打款 → 录入电汇流水号和记账凭证号归档 |
-| **核心诉求** | 发票影像清晰可查验、合规检查项不可跳过、连续核销不退回列表、数据可导出 |
+| **日常场景** | 查看全公司已通过待付款的报销单 → 核验发票影像 → 手动勾选三项合规检查 → 线下打款 → 录入电汇流水号和记账凭证号归档 |
+| **核心诉求** | 发票影像清晰可查验、合规检查项手动确认、连续核销不退回列表、数据可导出 |
 | **使用频率** | 每天 5-20 笔核销 |
-| **移动端偏好** | 发票大图预览、合规复选框联动锁定、凭证归档表单、导出 Excel |
+| **移动端偏好** | 发票大图预览、合规复选框联动锁定、一键全部通过、凭证归档表单、导出 Excel |
 
 ### 2.4 系统管理员(Admin)
 
@@ -182,27 +184,28 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
 | 新建申请 | P0 | 多费用类型(可同时勾选差旅+办公等)。关联项目(.NET → ERP ProjectService)→ 加载科目(.NET → ERP SubjectService)→ 查预算余额。预估明细行金额,附件上传 |
-| 预算查看 | P0 | 选完项目+科目后实时展示可用余额(BudgetService)。余额不足时金额标红并触发超支审批链。ERP 无预算数据时跳过校验,不影响提交 |
-| 草稿暂存 | P0 | 草稿可多次编辑后提交 |
-| 提交审批 | P0 | 校验必填项+预算 → 提交至 ERP 审批引擎 |
-| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回 |
+| 预算查看 | P0 | 选完项目+科目后实时展示可用余额(BudgetService)。余额不足时金额标红并触发超支审批链。ERP 无预算数据时跳过校验,不影响提交。预算服务不可用时隐藏预算信息,允许正常提交 |
+| 草稿暂存 | P0 | 草稿可多次编辑后提交。草稿仅创建者本人可见(管理员也不例外),所有查询入口排除草稿 |
+| 提交审批 | P0 | 校验必填项+预算 → 提交至 ERP 审批引擎。超预算时允许提交但携带 `budget_exceeded` 标签,由 ERP 决定是否进入特批链路 |
+| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回/已归档 |
 | 详情查看 | P0 | 状态横幅 + 费用明细 + 附件 + 审批时间线 |
 | 撤回申请 | P1 | pending 状态可撤回(任何审批阶段均可撤,ERP 最终裁决) |
 | 删除草稿 | P1 | draft 状态可删除(仅本人),删除后不可恢复 |
+| 终态归档 | P0 | 当 UsageStatus=fully_used 时,Status 自动流转为 archived,不可再被引用 |
 
 ### 3.4 费用报销(Expense Reimbursement)
 
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
-| 新建报销 | P0 | 两种方式:①导入费用申请(项目/科目自动带入,填每张导入金额);②直接新建(手动选项目→科目→查预算,与费用申请相同的链式操作)。均支持外币明细(自动汇率折算)、发票影像上传。 |
-| 导入费用申请 | P0 | 从已通过且尚有额度的申请多选导入,每条填导入金额。导入后自动回填项目/科目,额度实时校验,超申请金额不可提交。未导入时手动选择项目+科目触发预算查询 |
-| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回(与费用申请一致) |
+| 新建报销 | P0 | 两种方式:①导入费用申请(项目/科目自动带入,填每张导入金额);②直接新建(手动选项目→科目→查预算,与费用申请相同的链式操作)。均支持外币明细(自动汇率折算)、发票影像上传。支付方式(PaymentMethod)由员工在填单时选择 |
+| 导入费用申请 | P0 | 从已通过且尚有额度的申请多选导入,每条填导入金额。导入后自动回填项目/科目,额度实时校验,超申请金额不可提交。严格按 BizScope 过滤,不可导入 expense_apply 专属类别 |
+| 列表筛选 | P0 | 全部/草稿/审批中/已通过/待付款/已付款/已拒绝/已撤回/已归档 |
 | 详情查看 | P0 | 状态横幅 + 费用明细 + 审批时间线 + 附件预览 |
-| 撤回申请 | P1 | pending 状态可撤回(任何审批阶段均可撤,ERP 最终裁决) |
+| 撤回申请 | P1 | pending 状态可撤回(任何审批阶段均可撤,ERP 最终裁决),撤回后立即释放已占用的申请额度 |
 | 删除草稿 | P1 | draft 状态可删除(仅本人) |
-| 草稿/提交 | P0 | 支持存为草稿后继续编辑,提交时校验必填项+预算,提交至 ERP 审批引擎 |
-| 财务核销 | P0 | 发票合规三字查验(验真/税号/类目)→ 录入电汇流水号+记账凭证号 → 归档 |
-| 连续核销 | P1 | 归档后跳转下一笔待付款 |
+| 草稿/提交 | P0 | 支持存为草稿后继续编辑,提交时校验必填项+预算,提交至 ERP 审批引擎。从费用申请导入的报销单提交时不重复校验预算 |
+| 财务核销 | P0 | 发票合规三字查验(验真/税号/类目)→ 三项均由财务人员手动勾选,提供"一键全部通过"快捷操作 → 录入电汇流水号+记账凭证号 → 归档。单笔 ≤200 元小额零星支出时系统自动勾选三项,财务仍可取消 |
+| 连续核销 | P1 | 归档后跳转下一笔待付款(FIFO:CreateTime 最早优先) |
 | 退回修改 | P1 | 退给员工重填 或 退给经理重审 |
 
 ### 3.5 加班申请(Overtime)
@@ -212,7 +215,7 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 | 新建申请 | P0 | 加班类型(工作日/休息日/节假日)、补偿方式(转调休/加班费/混合)、开始/结束时间、原因 |
 | 净工时自动计算 | P0 | 扣除午休(12:00-13:00)和晚餐(18:00-18:30)盲区 |
 | 混合模式 | P1 | 调休比例滑块(10%-90%,步长10%),实时显示分配文案 |
-| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回 |
+| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回/已归档 |
 | 详情查看 | P0 | 状态横幅 + 加班信息 + 审批时间线 |
 | 撤回申请 | P1 | pending 状态可撤回(任何审批阶段均可撤,ERP 最终裁决) |
 | 删除草稿 | P1 | draft 状态可删除(仅本人) |
@@ -221,26 +224,27 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
-| 新建申请 | P0 | 车牌号选择、始发地/目的地(支持地图选点)、出车/还车时间、同行人(通讯录多选) |
-| 排期冲突检测 | P0 | 选好车牌+时间后异步校验是否存在时段重叠冲突 |
-| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回/已还车 |
+| 新建申请 | P0 | 车牌号选择(草稿时可空,提交时必填)、始发地/目的地(支持地图选点)、出车/还车时间、同行人(通讯录多选或手动输入外部人员) |
+| 排期冲突检测 | P0 | 选好车牌+时间后实时防抖校验,冲突时警告并置灰提交按钮。草稿状态下也支持实时检测(但不锁车) |
+| 列表筛选 | P0 | 全部/草稿/审批中/已通过/已拒绝/已撤回/已还车/已归档 |
 | 详情查看 | P0 | 状态横幅 + 行程信息 + 审批时间线 + 地图标记 |
 | 撤回申请 | P1 | pending 状态可撤回(任何审批阶段均可撤,ERP 最终裁决) |
 | 删除草稿 | P1 | draft 状态可删除(仅本人) |
-| 还车登记 | P0 | approved 状态可登记还车:实还时间、出车前/还车后里程表读数、路桥停车费 |
-| 车辆池管理 | P1 | 管理员维护车辆信息(车牌/类型/品牌/座位/驾驶员/状态) |
+| 还车登记 | P0 | approved 状态可登记还车:实还时间、出车前/还车后里程表读数、路桥停车费。还车登记完成后 Status 自动流转为 returned,再自动流转为 archived,车辆状态恢复 idle |
+| 车辆池管理 | P1 | 管理员维护车辆信息(车牌/类型/品牌/座位/驾驶员/状态)。maintenance 状态车辆不可被申请 |
 
 ### 3.7 外勤日志(Outing Log)
 
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
-| 新建日志 | P0 | GPS 自动定位(只读,不可手动修改)、客户名(自动联想 ERP 客户池)、工作总结、后续计划 |
-| 现场拍照 | P0 | 强制调用相机(不可选相册),照片带防伪水印(服务器授时+经纬度),最少 1 张最多 9 张 |
-| 主管点评 | P0 | 经理在详情页底部星级评分 + 文字点评,气泡样式展示 |
+| 新建日志 | P0 | GPS 自动定位(只读,不可手动修改)、客户名(自动联想 ERP 客户池)、工作总结、后续计划。草稿状态下不强制 GPS,提交时校验 |
+| 现场拍照 | P0 | 强制调用相机(不可选相册),照片带防伪水印(服务器授时+经纬度),最少 1 张最多 9 张。区分签到照(sign_in_photo)和现场照(visit_photo) |
+| 主管点评 | P0 | 经理在详情页底部星级评分 + 文字点评,气泡样式展示。红点基于 CreateTime,修改点评不触发新红点 |
 | 列表筛选 | P0 | 全部/草稿/已完成 |
 | 详情查看 | P0 | 定位地图 + 工作摘要 + 照片墙 + 点评历史 |
-| 新点评标记 | P1 | 列表页橙色"新点评"标记(对比 LastViewedTime) |
-| 客户动态创建 | P2 | 输入新客户名后提交时自动在 ERP 创建 |
+| 新点评标记 | P1 | 列表页橙色"新点评"标记(MAX(CreateTime) > LastViewedTime)。LastViewedTime 仅打开详情页时更新 |
+| 客户动态创建 | P2 | 输入新客户名时提交后自动在 ERP 创建 |
+| 提交即锁定 | P0 | 提交后 Status=completed,不可编辑。仅管理员可软删除 |
 
 ### 3.8 公告管理(Announcement)
 
@@ -248,9 +252,9 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 |------|--------|------|
 | 公告列表 | P0 | 置顶优先 → 未过期按发布时间倒序 → 已过期置灰;类型筛选;不支持搜索 |
 | 公告详情 | P0 | 红头文件样式、HTML/Markdown 正文、附件下载;停留 ≥2s 自动标记已读 |
-| 发布公告 | P0 | 富文本编辑、置顶开关、有效期、接收范围(全员/部门树/指定用户)、预览 |
-| 触达审计 | P1 | 管理员查看已读/未读员工列表 |
-| DING 催办 | P1 | 管理员在公告详情页查看已读/未读名单,对未读员工一键推送高优先级通知 |
+| 发布公告 | P0 | 富文本编辑、置顶开关、有效期、接收范围(全员/部门树/指定用户)、预览。全员时写入 TargetType='all' 记录 |
+| 触达审计 | P1 | 管理员查看已读/未读员工列表。已离职员工在未读名单中标记灰色"已离职",催办时自动过滤 |
+| DING 催办 | P1 | 管理员在公告详情页查看已读/未读名单,对未读员工一键推送高优先级通知。可多次催办 |
 
 ### 3.9 数据报表(Reports)
 
@@ -270,7 +274,7 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 | 功能 | 优先级 | 说明 |
 |------|--------|------|
 | 用户搜索 | P0 | 支持按姓名或工号模糊搜索,输入过程中自动联想匹配 |
-| 权限配置 | P0 | 快捷套餐 + 单点加减;无角色用户默认等同"员工" |
+| 权限配置 | P0 | 快捷套餐 + 单点加减;无角色用户默认等同"员工"。快捷套餐权限点清单见附录 |
 | 变更审计 | P1 | 权限变更历史时间线 |
 | 自保护 | P0 | 管理员无法取消自己的 admin 角色;至少保留一名管理员 |
 
@@ -297,11 +301,11 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 
 | 报表 | 数值卡片 | 专属筛选 | 员工图表 | 经理图表 | 财务/管理员图表 |
 |------|---------|---------|---------|---------|-------------|
-| 费用申请明细 | ①累计申请总额 ②当月笔数 ③已通过笔数 ④已通过金额 | 状态(全部/已通过/已拒绝/已撤回) | 个人近 12 月申请金额 vs 已通过金额双折线 | 本部门横向对比柱状图(每人两根柱:申请/已通过) + 底部明细列表 | 全公司趋势图(同员工) + 底部明细列表 |
-| 费用报销明细 | ①累计核销总额 ②当月笔数 ③待审批笔数 ④待付款笔数 | 状态 + 付款状态 | 个人近 12 月报销金额 vs 审批通过金额双折线 | 本部门横向对比柱状图(每人两根柱:报销/通过) + 底部明细列表 | 全公司趋势图 + 底部明细列表 |
-| 加班明细 | ①当月净工时 ②当月次数 ③累计调休小时(应用层计算:NetOtHours × CompLeaveRatio) ④加班费次数 | 加班类型(工作日/休息日/节假日) | 个人近 12 月加班工时柱状图,按类型分色堆叠 | 本部门横向对比柱状图(每人汇总工时) + 底部明细列表 | 全公司趋势图 + 底部明细列表 |
-| 用车明细 | ①当月次数 ②累计里程 ③路桥停车费 ④未还车数 | 车辆 + 用途 | 个人近 12 月用车次数折线 | 本部门横向对比柱状图(每人次数/费用) + 底部明细列表 | 全公司趋势图(次数 vs 费用双轴) + 底部明细列表 |
-| 外勤日志明细 | ①当月拜访次数 ②拜访客户数(去重)③平均评分 ④未点评数 | 客户名模糊搜索 | 个人近 12 月拜访次数 vs 评分双轴折线 | 本部门横向对比柱状图(每人次数/评分) + 底部明细列表 | 全公司趋势图 + 底部明细列表 |
+| 费用申请明细 | ①累计申请总额(含草稿)②当月笔数 ③已通过金额(Status='approved',含 fully_used)④待审批笔数 | 状态(全部/已通过/已拒绝/已撤回/已归档) | 个人近 12 月申请金额 vs 已通过金额双折线 | 本部门横向对比柱状图(每人两根柱:申请/已通过)+ 底部明细列表 | 全公司趋势图(同员工)+ 底部明细列表 |
+| 费用报销明细 | ①累计核销总额(PaymentStatus='paid')②当月笔数 ③待审批笔数 ④待付款笔数 | 状态 + 付款状态 | 个人近 12 月报销金额 vs 审批通过金额双折线 | 本部门横向对比柱状图(每人两根柱:报销/通过)+ 底部明细列表 | 全公司趋势图 + 底部明细列表 |
+| 加班明细 | ①当月净工时 ②当月次数 ③累计调休小时(comp_leave 100%净工时,mixed 按 CompLeaveRatio 比例)④加班费次数 | 加班类型(工作日/休息日/节假日) | 个人近 12 月加班工时柱状图,按类型分色堆叠 | 本部门横向对比柱状图(每人汇总工时)+ 底部明细列表 | 全公司趋势图 + 底部明细列表 |
+| 用车明细 | ①当月次数 ②累计里程(仅 Status='returned',EndOdometer-StartOdometer)③路桥停车费 ④未还车数 | 车辆 + 用途 | 个人近 12 月用车次数折线 | 本部门横向对比柱状图(每人次数/费用)+ 底部明细列表 | 全公司趋势图(次数 vs 费用双轴)+ 底部明细列表 |
+| 外勤日志明细 | ①当月拜访次数 ②拜访客户数(去重)③平均评分(至少一条点评,取最新点评,无点评显示"暂无点评")④未点评数 | 客户名模糊搜索 | 个人近 12 月拜访次数 vs 评分双轴折线 | 本部门横向对比柱状图(每人次数/评分)+ 底部明细列表 | 全公司趋势图 + 底部明细列表 |
 
 **数值卡片说明**:所有卡片数据受时间筛选(本月/本季/本年)和角色数据范围(本人/本部门/全公司)双重约束。无数据时显示 0 或 ¥0.00。卡片仅做展示,不可点击。
 
@@ -317,28 +321,28 @@ TBOSS OA 的核心差异化:**不是独立产品,而是 ERP 的移动 OA 外
 | **审批数据来源** | 审批状态、审批时间线、待办列表均通过 .NET → ERP 实时查询,OA 本地不存储。列表页和详情页每次打开均实时请求,保证数据始终最新 |
 | **多 ERP 路由** | 宿主 App 登录时已确定用户所属 ERP 实例,OA 通过 .NET 服务端上下文获取,无需额外路由逻辑。一人跨多 ERP 场景由宿主 App 切换账套时重新初始化 |
 | **附件存储** | 统一上传至云端对象存储(OSS),`Attachment.FileUrl` 存绝对 URL。访问通过 CDN 加速。上传限制:图片/PDF/Word/Excel ≤20MB。附件存储不设自动清理机制 |
-| **无发票场景** | 单笔费用 ≤200 元且类别为办公费/交通卡充值等小额零星支出时,三项合规检查(验真/税号/类目)自动通过,财务仅审核费用合理性 |
-| **申请过期处理** | `ValidUntil` 到期后,定时 Job 自动释放冻结预算(调 BudgetService.Release),`UsageStatus` 标记为 `fully_used`(不可再被报销引用)。通知申请人"申请已过期,预算已释放" |
+| **无发票场景** | 单笔费用 ≤200 元且类别为办公费/交通卡充值等小额零星支出时,三项合规检查自动勾选,财务人员仍可手动取消勾选 |
+| **申请过期处理** | `ValidUntil` 到期后,定时 Job 自动释放冻结预算(调 BudgetService.Release),`UsageStatus` 标记为 `fully_used`,`Status` 流转为 `archived`。通知申请人"申请已过期,预算已释放" |
 | **报表数据权限** | 个人报表:仅本人可见。部门报表(经理):按人汇总展示 + 可展开逐笔明细。全公司报表(财务/管理员):展示全公司所有明细。数据范围由角色权限控制,详见 §3.11 |
 | **审批撤回窗口** | `Status=pending` 即可撤回(不限制当前审批级别)。已审批通过的节点历史保留,流程终止。ERP 不支持撤回时,OA 按钮仍展示,调 ERP 后被拒则弹错误提示 |
-| **预算、项目、科目(概念与关系)** | 三个概念构成两级核算体系。**项目**是企业内按目标划分的独立核算单元(如"华东市场拓展""ERP 系统升级")。**科目**是费用类别在财务口径下的分类(如"差旅费""招待费")。**预算**挂在**项目×科目**的交叉点上——同一个科目在不同项目下预算不同。操作链:员工填费用申请时选项目 → 系统加载该项目下有预算的科目列表 → 选定科目后实时查询该项目该科目的可用余额。三个字段均通过 .NET 适配器从 ERP 获取,ERP 无数据时对应下拉为空,后续预算校验自动跳过,不影响提交 |
-| **外币/汇率** | 报销明细行默认币种 CNY。员工选择外币(如 USD)后,OA 调 .NET → ERP ExchangeRateService 获取当日汇率,自动填入 `ExchangeRate`。服务端计算 `BaseAmount = TotalAmount × ExchangeRate`,预算校验和报表均以本币(CNY)为准。ERP 无汇率数据时仅支持 CNY,外币选项隐藏 |
-| **费用申请状态** | **审批状态(Status)**:`draft` 草稿→可编辑/提交/删除;`pending` 审批中→可撤回;`approved` 已通过→可被报销引用;`rejected` 已拒绝→可重新编辑提交;`withdrawn` 已撤回→仅查看。**使用状态(UsageStatus,仅 approved 后生效)**:`unused` 未被报销引用;`partially_used` 部分金额已报销;`fully_used` 全部金额已报销,不可再被引用。`draft`/`pending`/`rejected`/`withdrawn` 状态下 UsageStatus 无意义 |
-| **费用报销状态** | 同审批流程(`draft`→`pending`→`approved`/`rejected`/`withdrawn`)。**付款状态(PaymentStatus)**:仅 `approved` 后生效,`unpaid` 待付款→财务核销后变为 `paid` 已付款。两个状态独立但有序:必须先审批通过,才进入付款环节 |
-| **加班申请状态** | 同费用申请,仅审批状态(`draft`→`pending`→`approved`/`rejected`/`withdrawn`),无二级状态 |
-| **用车申请状态** | 审批状态同其他单据,额外一个独有终态:`returned` 已还车。完整流转:`draft`→`pending`→`approved`→`returned`(还车登记完成,车辆恢复空闲)。`rejected`/`withdrawn` 同理 |
-| **外勤日志状态** | 不走审批流,`draft` 草稿→`completed` 已提交。提交即完成,主管可在详情页点评 |
+| **预算、项目、科目** | 三个概念构成两级核算体系。**项目**是企业内按目标划分的独立核算单元。**科目**是费用类别在财务口径下的分类。**预算**挂在**项目×科目**的交叉点上。操作链:选项目 → 加载科目列表 → 选定科目后查询可用余额。三个字段均通过 .NET 适配器从 ERP 获取,ERP 无数据时对应下拉为空,后续预算校验自动跳过 |
+| **外币/汇率** | 报销明细行默认币种 CNY。员工选择外币后,OA 调 .NET → ERP ExchangeRateService 获取当日汇率,自动填入 `ExchangeRate`。服务端计算 `BaseAmount = TotalAmount × ExchangeRate`,预算校验和报表均以本币(CNY)为准 |
+| **费用申请状态** | **审批状态(Status)**:`draft`→可编辑/提交/删除;`pending`→可撤回;`approved`→可被报销引用;`rejected`→可重新编辑提交;`withdrawn`→仅查看;`archived`→已归档终态。**使用状态(UsageStatus)**:`unused`/`partially_used`/`fully_used`(仅 `approved` 后有意义)。当 `UsageStatus=fully_used` 时 `Status` 自动流转为 `archived` |
+| **费用报销状态** | **审批状态(Status)**:`draft`→`pending`→`approved`/`rejected`/`withdrawn`/`archived`。**付款状态(PaymentStatus)**:仅 `approved` 后生效,`unpaid`→财务核销后变为 `paid`。`PaymentStatus=paid` 且业务完结后 `Status` 自动流转为 `archived` |
+| **加班申请状态** | 同费用申请,审批状态:`draft`→`pending`→`approved`/`rejected`/`withdrawn`/`archived`。业务完结后自动归档 |
+| **用车申请状态** | 审批状态同其他单据,额外终态:`returned`(已还车)→`archived`。完整流转:`draft`→`pending`→`approved`→`returned`→`archived`(还车登记完成,车辆恢复空闲) |
+| **外勤日志状态** | 不走审批流,`draft` 草稿→`completed` 已提交。提交即完成且锁定不可编辑,仅管理员可软删除 |
 | **公告状态** | 不走审批流,`draft` 草稿(仅创建者和管理员可见)→`published` 已发布(全员可见,触发触达记录写入) |
-| **列表筛选** | 使用顶部 Tabs 选项卡按状态筛选,按时间倒序排列。Tabs 下方增加筛选栏(日期范围 + 业务专属维度)。各单据 Tabs:**事前/加班** 全部/草稿/审批中/已通过/已拒绝/已撤回;**报销** 全部/草稿/审批中/已通过/待付款/已付款/已拒绝/已撤回;**用车**额外含已还车;**外勤日志**全部/草稿/已完成。**公告**不按状态筛——**员工侧** Tabs 为公告类型(全部/通知公告/人事制度/放假活动),仅展示已发布公告;**管理员侧**在类型 Tabs 基础上,右上角多一个"我的草稿"入口,点开仅展示自己创建的草稿。<br/><br/>**筛选栏明细**:<br/>• **费用申请**:日期范围 + 费用类型(差旅/招待/办公/会议)+ 紧急程度(普通/紧急/特急)<br/>• **费用报销**:日期范围<br/>• **加班申请**:日期范围 + 加班类型(工作日/休息日/节假日)<br/>• **用车申请**:日期范围 + 用车目的(客户接待/商务出行/公务)<br/>• **外勤日志**:日期范围<br/>• **公告**:无额外筛选 |
-| **列表搜索** | 各列表页在经理范围切换(如有)和状态 Tab 之间提供搜索框(`TDSearchBar`),客户端本地过滤,输入即时生效(不发起服务端请求)。搜索栏位:<br/>• **费用报销**:报销单号(ExpenseNo)、申请人姓名<br/>• **费用申请**:申请单号(ExpenseApplyNo)、申请人姓名<br/>• **加班申请**:加班单号(ApplicationNo)、申请人姓名<br/>• **用车申请**:用车单号(ApplicationNo)、申请人姓名<br/>• **外勤日志**:客户名称(CustomerName)、业务员姓名<br/>• **公告**:公告标题(Title) |
-| **经理列表范围** | 审批类单据列表页(事前/报销/加班/用车)顶部状态 Tab 上方增加两个 `TDChip` 切换:`我的发起`(默认,展示本人提交的单据)和 `下属审批`(展示本部门下属提交的单据)。切换时列表刷新,`下属审批` 模式下卡片额外显示申请人姓名和部门。处于 `pending` 状态的下属卡片左滑→`[一键同意]`(绿色按钮),无需进入详情页即可就地审批通过,调 `POST /api/oa/approval/action` |
-| **财务列表操作** | 费用报销列表页,财务角色默认查看全公司数据。列表页不提供快捷付款操作——财务核销必须进入详情页完成三字查验(验真/税号/类目)并录入电汇流水号和记账凭证号后方可归档。`[待付款]` Tab 中的卡片点击进入详情页操作 |
-| **预算冻结异常** | 提交时调 ERP BudgetService.Freeze() 可能因 ERP 超时或预算版本冲突失败。失败时 OA 提示"预算扣减失败,请稍后重试",不创建审批实例,用户可重新提交 |
-| **草稿生命周期** | 存草稿:表单页点击「存为草稿」→ 写入数据库(Status=draft)→ 返回列表页并默认激活"草稿"Tab。找草稿:列表页切换到「草稿」Tab 即可查看所有草稿。继续编辑:点击草稿卡片 → 打开表单页,自动回填已保存数据 → 可继续编辑后再次存草稿或提交。草稿仅创建者本人可见,不可被审批人查看。草稿无过期时间,可长期保存 |
-| **并发编辑** | 草稿仅创建者可见和编辑,不存在多人并发问题。驳回重新提交时采用乐观锁(WHERE Version=原值),冲突时提示"单据已被修改,请刷新后重试" |
+| **列表筛选** | 使用顶部 Tabs 选项卡按状态筛选,按时间倒序排列。Tabs 下方增加筛选栏(日期范围 + 业务专属维度)。各单据 Tabs:**事前/加班** 全部/草稿/审批中/已通过/已拒绝/已撤回/已归档;**报销** 全部/草稿/审批中/已通过/待付款/已付款/已拒绝/已撤回/已归档;**用车**额外含已还车;**外勤日志**全部/草稿/已完成。**公告**——**员工侧** Tabs 为公告类型(全部/通知公告/人事制度/放假活动),仅展示已发布公告;**管理员侧**在类型 Tabs 基础上,右上角多一个"我的草稿"入口,点开仅展示自己创建的草稿 |
+| **列表搜索** | 各列表页在经理范围切换(如有)和状态 Tab 之间提供搜索框(`TDSearchBar`),客户端本地过滤。搜索栏位:**费用报销**:报销单号、申请人姓名;**费用申请**:申请单号、申请人姓名;**加班申请**:加班单号、申请人姓名;**用车申请**:用车单号、申请人姓名;**外勤日志**:客户名称、业务员姓名;**公告**:公告标题 |
+| **经理列表范围** | 审批类单据列表页顶部状态 Tab 上方增加两个 `TDChip` 切换:`我的发起`(默认)和 `下属审批`(展示本部门下属提交的单据)。`下属审批` 模式下卡片显示申请人姓名和部门。`pending` 状态卡片左滑→`[一键同意]` |
+| **财务列表操作** | 费用报销列表页,财务角色默认查看全公司数据。`[待付款]` Tab 中卡片点击进入详情页,完成三项合规查验后方可归档 |
+| **预算冻结异常** | 提交时调 ERP BudgetService.Freeze() 可能因超时或版本冲突失败。失败时提示"预算扣减失败,请稍后重试",不创建审批实例,用户可重新提交 |
+| **草稿生命周期** | 存草稿→写入数据库(Status=draft)→返回列表页默认激活"草稿"Tab。继续编辑→打开表单页自动回填。草稿仅创建者本人可见(管理员也不例外),所有查询入口排除草稿。无过期时间,可长期保存 |
+| **并发编辑** | 草稿仅创建者可见和编辑,不存在多人并发。报销单提交时使用乐观锁(Version 字段),冲突时提示"单据已被修改,请刷新后重试" |
 | **设备兼容性** | 仅支持竖屏手机。平板可运行但不做布局优化。折叠屏展开态默认按手机竖屏布局居中显示 |
-| **多语言** | V1 支持简体中文、繁体中文、英文。所有 UI 文案通过 Flutter 国际化(`AppLocalizations`)管理。默认跟随系统语言,用户可在个人中心手动切换(入口:`/profile` → 语言设置 → 选择语言 → 立即生效) |
-| **深色模式** | V1 支持浅色/深色主题切换。入口在"我的"→"语言"下方 TDSwitch。通过 `ThemeExtension` 实现所有组件自动适配两种模式。详细规范见设计文档 §6.13 |
+| **多语言** | V1 支持简体中文、繁体中文、英文。所有 UI 文案通过 Flutter 国际化(`AppLocalizations`)管理。数据库枚举值存储英文标识,前端负责翻译映射。默认跟随系统语言,用户可在个人中心手动切换 |
+| **深色模式** | V1 支持浅色/深色主题切换。入口在"我的"→"语言"下方 TDSwitch。通过 `ThemeExtension` 实现所有组件自动适配两种模式 |
 
 ---
 
@@ -379,13 +383,15 @@ flowchart TD
     F -->|存草稿| G[草稿保存]
     G --> A
     F -->|提交审批| H[校验必填项 + .NET 调 ERP BudgetService 预算]
-    H -->|超预算| I[金额标红警告]
-    I --> K[调 ERP BudgetService 冻结预算, tags: budget_exceeded]
+    H -->|超预算| I[金额标红警告, 允许提交带 budget_exceeded 标签]
+    I --> K[调 ERP BudgetService 冻结预算]
     H -->|校验失败| J[ScrollTo 报错字段]
     J --> E
     H -->|校验通过| K
     K --> L[.NET → ERP 创建审批实例]
-    L --> M[OA 存 ApprovalInstanceId, Status=pending]
+    L -->|创建失败| L1[保持 draft, 提示重试]
+    L1 --> A
+    L -->|创建成功| M[OA 存 ApprovalInstanceId, Status=pending]
     M --> N[.NET 消息推送审批人]
 
     N --> O[经理审批]
@@ -400,15 +406,19 @@ flowchart TD
 
     R --> W[.NET 通知申请人]
     W --> X{财务核销}
-    X --> Y[查验: 发票验真+税号一致+类目合规]
+    X --> Y[查验: 手动勾选三项合规]
     Y --> Z{三项全勾?}
     Z -->|否| X
     Z -->|是| AA[录入电汇流水号+记账凭证号]
-    AA --> AB[PaymentStatus=paid, 归档 + 连续核销]
+    AA --> AB[PaymentStatus=paid, 归档]
+    AB --> AC{连续核销?}
+    AC -->|是| AD[跳转下一笔待付款 FIFO]
+    AD --> X
+    AC -->|否| AE[返回列表]
 
-    T --> AC[.NET 通知: 已拒绝]
-    AC --> AD[员工重新编辑发起]
-    AD --> A
+    T --> AF[.NET 通知: 已拒绝]
+    AF --> AG[员工重新编辑发起]
+    AG --> A
 ```
 
 ### 4.3 费用申请全流程
@@ -419,81 +429,43 @@ flowchart TD
     B --> C[选择费用类型: 多选, 如差旅+办公]
     C --> D[选择项目 → 加载科目 → 查预算余额]
     D --> E[填预估明细行: 费用类别+预估金额, 支持多行]
-    E --> F[上传支撑材料: 报价单/合同等, 最多9张]
+    E --> F[上传支撑材料: 最多9张]
     F --> G{存草稿 还是 提交?}
     G -->|存草稿| H[草稿保存]
     H --> A
     G -->|提交审批| I[校验必填项 + 调 ERP BudgetService 查预算]
-    I -->|超预算| J[金额标红警告]
+    I -->|超预算| J[金额标红警告, 允许提交]
     J --> L[调 ERP BudgetService 冻结预算, tags: budget_exceeded]
     I -->|校验失败| K[ScrollTo 报错字段]
     K --> E
     I -->|校验通过| L
-    L --> M[.NET → ERP 创建审批实例]
-    M --> N[OA 存 ApprovalInstanceId, Status=pending]
-    N --> O[.NET 消息推送审批人]
+    L --> L1[.NET → ERP 创建审批实例]
+    L1 -->|创建失败| L2[保持 draft, 提示重试]
+    L2 --> A
+    L1 -->|创建成功| M[OA 存 ApprovalInstanceId, Status=pending]
+    M --> N[.NET 消息推送审批人]
 
-    O --> P[经理审批]
-    P --> Q{审批动作}
-    Q -->|同意| R{是否最后一级?}
-    R -->|是| S[Status=approved, ERP 预算冻结转扣减]
-    R -->|否| T[流转下一级]
-    T --> O
-    Q -->|拒绝| U[Status=rejected]
+    N --> O[经理审批]
+    O --> P{审批动作}
+    P -->|同意| Q{是否最后一级?}
+    Q -->|是| R[Status=approved, ERP 预算冻结转扣减]
+    Q -->|否| T[流转下一级]
+    T --> N
+    P -->|拒绝| U[Status=rejected]
 
     N -.->|员工随时撤回| V[调 ERP 撤回 → 释放冻结预算]
     V --> W[Status=withdrawn, 审批终止]
 
-    S --> X[.NET 通知申请人]
+    R --> X[.NET 通知申请人]
     X --> Y[UsageStatus=unused, 等待报销引用]
-    U --> Z[.NET 通知: 已拒绝]
-    Z --> AA[员工重新编辑发起]
-    AA --> A
+    Y --> Z{报销引用后 UsageStatus?}
+    Z -->|fully_used| AA[Status 自动流转为 archived]
+    U --> AB[.NET 通知: 已拒绝]
+    AB --> AC[员工重新编辑发起]
+    AC --> A
 ```
 
-### 4.4 加班申请全流程
-
-```mermaid
-flowchart TD
-    A[员工发起] --> B[选择加班类型: 工作日/休息日/节假日]
-    B --> C[选择补偿方式: 转调休/加班费/混合]
-    C --> D{混合模式?}
-    D -->|是| E[拖动比例滑块: 10%-90%, 步长10%]
-    D -->|否| F[填开始/结束时间]
-    E --> F
-    F --> G[系统自动计算净工时: 扣除午餐12-13点 + 晚餐18-18:30]
-    G --> H{净工时 > 0?}
-    H -->|否| I[净工时标红, 提交按钮置灰]
-    I --> F
-    H -->|是| J[填写加班原因]
-    J --> K{存草稿 还是 提交?}
-    K -->|存草稿| L[草稿保存]
-    L --> A
-    K -->|提交审批| M[校验必填项]
-    M -->|校验失败| N[ScrollTo 报错字段]
-    N --> J
-    M -->|校验通过| O[.NET → ERP 创建审批实例]
-    O --> P[OA 存 ApprovalInstanceId, Status=pending]
-    P --> Q[.NET 消息推送审批人]
-
-    Q --> R[经理审批]
-    R --> S{审批动作}
-    S -->|同意| T{是否最后一级?}
-    T -->|是| U[Status=approved]
-    T -->|否| V[流转下一级]
-    V --> Q
-    S -->|拒绝| W[Status=rejected]
-
-    P -.->|员工随时撤回| X[调 ERP 撤回]
-    X --> Y[Status=withdrawn, 审批终止]
-
-    U --> Z[.NET 通知申请人]
-    W --> AA[.NET 通知: 已拒绝]
-    AA --> AB[员工重新编辑发起]
-    AB --> A
-```
-
-### 4.5 外勤日志全流程
+### 4.4 外勤日志全流程
 
 ```mermaid
 flowchart TD
@@ -503,55 +475,58 @@ flowchart TD
     D -->|失败| E[提交按钮置灰, 提示检查定位权限]
     D -->|成功| F[输入/选择客户名称]
     F --> G[填写工作总结 + 后续计划]
-    G --> H[强制拍照: 跳过相册, 直接唤起相机]
+    G --> H[强制拍照: 签到照1张 + 现场照1-8张]
     H --> I{至少 1 张照片?}
     I -->|否| J[提交按钮置灰]
     J --> H
     I -->|是| K[照片自动添加水印: 授时+GPS]
     K --> L{存草稿 还是 提交?}
-    L -->|存草稿| M[保存草稿]
-    L -->|提交| N[Status=completed, 写入数据库]
+    L -->|存草稿| M[保存草稿, 不校验GPS/照片]
+    L -->|提交| N[校验GPS+照片, Status=completed, 写入数据库, 锁定不可编辑]
 
     N --> O[经理在详情页查看]
     O --> P[经理星级评分 + 文字点评]
     P --> Q[点评数据写入 OutingLogComment]
-    Q --> R["列表页橙色新点评标记"]
-    R --> S["员工查看点评, 更新 LastViewedTime"]
-    S --> T[新点评标记消失]
+    Q --> R["列表页红点: MAX(CreateTime) > LastViewedTime"]
+    R --> S[员工点击查看详情, 更新 LastViewedTime]
+    S --> T[红点消失]
+
+    N -.->|管理员| U[管理员可软删除已提交日志]
 ```
 
-### 4.6 用车申请全流程
+### 4.5 用车申请全流程
 
 ```mermaid
 flowchart TD
     A[员工发起] --> B[选择车牌号: OA 本地 SysVehicle 车池]
     B --> C[填写用车事由 + 始发地/目的地]
-    C --> D[地图选点: MethodChannel 唤醒原生地图, 回填经纬度]
+    C --> D[地图选点: 回填经纬度]
     D --> E[选择出车/还车时间]
-    E --> F[添加同行人: MethodChannel 通讯录多选]
-    F --> G{排期冲突检测}
-    G -->|冲突| H[红色警告: 该时段车辆已被预订, 提交按钮置灰]
+    E --> F[添加同行人: 通讯录多选/手动输入外部人员]
+    F --> G[实时排期冲突检测: 防抖500ms]
+    G -->|冲突| H[红色警告, 提交按钮置灰]
     G -->|无冲突| I{存草稿 还是 提交?}
     H --> B
-    I -->|存草稿| J[草稿保存]
-    J --> A
-    I -->|提交审批| K[校验必填项]
+    I -->|存草稿| J[草稿保存, VehicleId可空]
+    I -->|提交审批| K[校验必填项 + VehicleId必填]
     K -->|校验失败| L[ScrollTo 报错字段]
     L --> C
     K -->|校验通过| M[.NET → ERP 创建审批实例]
-    M --> N[OA 存 ApprovalInstanceId, Status=pending]
+    M -->|创建失败| M1[保持 draft, 提示重试]
+    M1 --> A
+    M -->|创建成功| N[OA 存 ApprovalInstanceId, Status=pending]
     N --> O[.NET 消息推送审批人]
 
     O --> P[经理审批]
     P --> Q{审批动作}
     Q -->|同意| R{是否最后一级?}
-    R -->|是| S[Status=approved, 车辆状态更新为使用中]
+    R -->|是| S[Status=approved, 车辆状态更新为 in_use]
     R -->|否| T[流转下一级]
     T --> O
-    Q -->|拒绝| U[Status=rejected]
+    Q -->|拒绝| U[Status=rejected, 车辆状态不变]
 
     N -.->|员工随时撤回| V[调 ERP 撤回]
-    V --> W[Status=withdrawn, 审批终止]
+    V --> W[Status=withdrawn, 车辆状态不变]
 
     S --> X[.NET 通知申请人]
     X --> Y[用车中, 等待还车登记]
@@ -561,34 +536,34 @@ flowchart TD
     AB -->|否| AC[红色提示, 提交按钮置灰]
     AC --> AA
     AB -->|是| AD[确认提交]
-    AD --> AE[Status=returned, 车辆恢复空闲, 单据归档]
+    AD --> AE[Status=returned → archived, 车辆恢复 idle]
     U --> AF[.NET 通知: 已拒绝]
     AF --> AG[员工重新编辑发起]
     AG --> A
 ```
 
-### 4.7 公告发布全流程
+### 4.6 公告发布全流程
 
 ```mermaid
 flowchart TD
-    A[管理员进入公告发布页] --> B[填写标题 + 选择分类: 通知/制度/活动]
-    B --> C[富文本编辑正文: 加粗/斜体/列表/图片/链接/字号]
-    C --> D[上传附件: 最多9个, PDF/图片/Word/Excel, 单文件<=20MB]
+    A[管理员进入公告发布页] --> B[填写标题 + 选择分类]
+    B --> C[富文本编辑正文]
+    C --> D[上传附件: 最多9个]
     D --> E[设置: 置顶开关 + 有效期可选]
     E --> F[选择接收范围]
     F --> G{范围类型}
-    G -->|全员| H[PrivateLevel=0, 无需指定目标]
-    G -->|按部门| I[部门树多选, 实时统计覆盖人数]
-    G -->|按用户| J[员工搜索多选, 实时统计覆盖人数]
+    G -->|全员| H[PrivateLevel=0, 写入 TargetType='all']
+    G -->|按部门| I[部门树多选, 实时统计覆盖人数, 预写 ReadLog]
+    G -->|按用户| J[员工搜索多选, 实时统计覆盖人数, 预写 ReadLog]
     H --> K{操作}
     I --> K
     J --> K
     K -->|存草稿| L[Status=draft, 仅创建者和管理员可见]
     L --> M[后续可编辑后发布]
-    K -->|预览| N[TDDialog 全屏模拟详情页展示效果]
+    K -->|预览| N[全屏模拟详情页展示效果]
     N --> K
-    K -->|发布| O[确认弹窗: 确认向 N 名员工发布? 发布后不可撤回]
-    O -->|确认| P[Status=published, 异步写入 AnnouncementReadLog]
+    K -->|发布| O[确认弹窗: 确认向 N 名员工发布?]
+    O -->|确认| P[Status=published, 批量写入 AnnouncementReadLog]
     P --> Q[.NET 消息推送范围内员工]
 
     P --> R[员工查看公告]
@@ -596,55 +571,11 @@ flowchart TD
     S --> T[AnnouncementReadLog.IsRead=1, 列表红点消失]
 
     P -.->|管理员后续操作| U[查看触达统计: 已读/未读名单]
-    U --> V[DING 一键催办未读员工: 强推 Push]
+    U --> V[未读中标记已离职灰色标签]
+    V --> W[DING 一键催办未读员工: 自动过滤已离职]
 ```
 
-### 4.8 审批流对接时序
-
-```mermaid
-sequenceDiagram
-    participant E as 员工(Flutter)
-    participant S as .NET 服务端
-    participant ERP as ERP 审批引擎
-    participant M as 经理(Flutter)
-    participant P as 消息推送
-
-    E->>S: POST 提交单据
-    S->>ERP: 创建审批实例(bizType, bizId, 表单数据)
-    ERP-->>S: { instanceId: "inst_001" }
-    S-->>E: { success, instanceId }
-    Note over E,S: OA 存 ApprovalInstanceId + Status=pending
-
-    ERP->>P: 触发审批待办通知
-    P->>M: 推送: "张三提交了费用申请, 待审批"
-
-    M->>S: GET 待审批列表
-    S->>ERP: 查询待办(approverId=M)
-    ERP-->>S: [{ bizType, bizId, 摘要 }]
-    S-->>M: 待办列表
-
-    M->>S: GET 单据详情 + 审批时间线
-    S->>ERP: 查询实例状态 + 流程节点历史
-    ERP-->>S: { status, timeline }
-    S-->>M: 详情 + 时间线
-
-    M->>S: POST 审批动作(approve, instanceId)
-    S->>ERP: 执行审批(agree, instanceId)
-    ERP-->>S: { success, newStatus }
-    S-->>M: 审批成功
-    ERP->>P: 触发审批结果通知
-    P->>E: 推送: "您的费用申请已通过"
-
-    Note over M,S: 拒绝流程
-    M->>S: POST 审批动作(reject, instanceId, opinion)
-    S->>ERP: 执行审批(reject, instanceId, opinion)
-    ERP-->>S: { success }
-    S-->>M: 拒绝成功
-    ERP->>P: 触发审批结果通知
-    P->>E: 推送: "您的费用申请已被拒绝"
-```
-
-### 4.9 角色权限配置流程
+### 4.7 权限管理配置流程
 
 ```mermaid
 flowchart TD
@@ -672,43 +603,11 @@ flowchart TD
     P --> D
     O -->|移除最后admin| Q[后端拒绝: 至少保留一名管理员]
     Q --> D
-    O -->|通过| R[写入 OaUserPermission + 审计日志]
+    O -->|通过| R[写入 OaUserPermission + 审计日志 JSON快照]
     R --> S[Toast: 权限已更新]
     S --> N
 ```
 
-### 4.10 报表使用流程
-
-```mermaid
-flowchart TD
-    A[用户进入工作台] --> B[点击金刚区报表入口]
-    B --> C{选择报表}
-    C -->|费用申请报表| D1[进入报表页]
-    C -->|费用报销报表| D2[进入报表页]
-    C -->|加班报表| D3[进入报表页]
-    C -->|用车报表| D4[进入报表页]
-    C -->|外勤日志报表| D5[进入报表页]
-
-    D1 --> E[.NET 根据角色加载数据范围]
-    D2 --> E
-    D3 --> E
-    D4 --> E
-    D5 --> E
-
-    E --> F[页面渲染: 数值卡片 + 趋势图]
-    F --> G{用户操作}
-    G -->|切换时间| H[选择本月/本季/本年 或自定义起止日期]
-    G -->|切换筛选| I[选择专属筛选条件: 状态/类型/车辆/客户等]
-    H --> J[图表 300ms 刷新]
-    I --> J
-    J --> F
-
-    F --> K{角色判断}
-    K -->|经理| L[点击柱状图员工柱子 → 下方列表联动过滤 → 可穿透跳转详情页]
-    K -->|财务/管理员| M[查看底部明细列表 + 导出 Excel]
-    K -->|员工| N[仅查看个人趋势, 无下钻]
-```
-
 ---
 
 ## 5. 技术可行性分析
@@ -717,10 +616,10 @@ flowchart TD
 
 | 层 | 技术 | 成熟度 | 风险 |
 |----|------|--------|------|
-| 移动端 | Flutter 3.38.10 | ✅ 已验证 | 低 — 项目已有 Flutter 模块 |
-| Android 宿主 | Java/Kotlin | ✅ 已验证 | 低 — MethodChannel 互调已验证 |
-| iOS 宿主 | OC, Xcode 14.2 | ✅ 已验证 | 中 — Flutter 依赖需兼容 OC 宿主 |
-| 后端 | .NET Framework 4.8 | ✅ 已验证 | 低 — 已有服务端 |
+| 移动端 | Flutter 3.38.10 | ✅ 已验证 | 低 |
+| Android 宿主 | Java/Kotlin | ✅ 已验证 | 低 |
+| iOS 宿主 | OC, Xcode 14.2 | ✅ 已验证 | 中 |
+| 后端 | .NET Framework 4.8 | ✅ 已验证 | 低 |
 | 数据库 | SQL Server 2019+ | ✅ 已验证 | 低 |
 
 ### 5.2 关键风险与缓解
@@ -728,57 +627,57 @@ flowchart TD
 | 风险 | 影响 | 缓解措施 |
 |------|------|---------|
 | **多 ERP 审批 API 签名不一致** | 适配器层开发量不确定 | 先对接一套 ERP,验证适配器模式后再扩展;Flutter 端不感知差异 |
-| **无 ERP 回调机制** | 审批状态同步延迟 | 列表页缓存 + 后台静默刷新 + 详情页实时校准;用户体验可接受 |
-| **ERP 审批引擎能力边界未知** | 会签/或签等高级审批形态不可用 | 需求层面做最小假设(基础审批即可),高级形态作为后续增强 |
-| **iOS 编译兼容性** | Flutter 3.38.10 依赖可能不兼容 Xcode 14.2 | 每次新增 Flutter 依赖后在 iOS 端编译验证 |
-| **预算/项目/科目依赖 ERP** | 适配器开发量不确定 | 统一适配器模式,先对接一套 ERP 验证;ERP 无预算→返回空→OA 隐藏预算区块 |
+| **无 ERP 回调机制** | 审批状态同步延迟 | 列表页缓存 + 后台静默刷新 + 详情页实时校准 |
+| **ERP 审批引擎能力边界未知** | 会签/或签等高级审批形态不可用 | 需求层面做最小假设(基础审批即可) |
+| **iOS 编译兼容性** | Flutter 依赖可能不兼容 Xcode 14.2 | 每次新增依赖后在 iOS 端编译验证 |
+| **预算/项目/科目依赖 ERP** | 适配器开发量不确定 | 统一适配器模式,ERP 无数据时返回空/不限,OA 隐藏对应功能区块 |
 
 ### 5.3 与现有系统的集成点
 
 | 集成点 | 方向 | 方式 |
 |--------|------|------|
-| 用户信息 | ERP → OA | .NET 服务端查询 ERP 主数据,OA 通过 API 获取 |
+| 用户信息 | ERP → OA | .NET 服务端查询 ERP 主数据 |
 | 组织架构 | ERP → OA | 同上 |
-| 客户数据 | ERP → OA | .NET CustomerService,外勤日志输入时联想查询,提交时关联 ERP 客户 ID |
-| 预算 | ERP → OA | .NET BudgetService,余额查询/冻结/扣减/释放,ERP 无预算时返回空→OA 隐藏预算区块 |
+| 客户数据 | ERP → OA | .NET CustomerService,外勤日志输入时联想 |
+| 预算 | ERP → OA | .NET BudgetService,余额查询/冻结/扣减/释放 |
 | 项目 | ERP → OA | .NET ProjectService,费用申请表单级联下拉 |
 | 科目 | ERP → OA | .NET SubjectService,依赖项目选定后加载 |
-| 汇率 | ERP → OA | .NET ExchangeRateService,默认 CNY,选外币后自动填写当日汇率 |
+| 汇率 | ERP → OA | .NET ExchangeRateService,默认 CNY |
 | 审批发起 | OA → ERP | .NET 服务端调用 ERP 审批 API |
 | 审批状态查询 | OA → ERP | 同上 |
 | 审批动作 | OA → ERP | 同上 |
-| 消息推送 | ERP → 用户 | .NET 服务端消息模块在审批事件后触发推送 |
-| 原生能力 | App → OA | MethodChannel:相机、相册、通讯录、地图、GPS 定位 |
+| 消息推送 | ERP → 用户 | .NET 服务端消息模块触发推送 |
+| 原生能力 | App → OA | MethodChannel:相机、相册、通讯录、地图、GPS |
 
 ### 5.4 性能考量
 
 | 场景 | 策略 | 预期体验 |
 |------|------|---------|
-| 列表页加载 | OA 本地业务数据 + .NET → ERP 实时查询审批状态 | 首次 1-2 秒(内网 ERP 延迟低) |
-| 详情页打开 | 实时调用 ERP 刷新审批状态 | 200-500ms 延迟 |
+| 列表页加载 | OA 本地业务数据 + .NET → ERP 实时查询审批状态 | 首次 1-2 秒 |
+| 详情页打开 | 实时调用 ERP 刷新审批状态 | 200-500ms |
 | 附件上传 | 分片上传 + 缩略图预生成 | 图片 ≤20MB 3-5 秒 |
-| 报表图表 | 后端预计算 + 5 分钟缓存 | 首次 1-2 秒,后续即时 |
+| 报表图表 | 后端预计算 + 5 分钟缓存 | 首次 1-2 秒 |
 | 多 ERP 查询 | .NET 服务端并行调用 + 超时熔断 | 单 ERP 失败不影响其他 |
 
 ### 5.5 非功能需求
 
 #### 5.5.1 性能
 
-| 指标 | 目标 | 测量方法 |
-|------|------|---------|
-| 应用冷启动 | ≤3 秒 | Flutter 引擎初始化到首页渲染完成 |
-| 列表页首屏 | ≤1 秒(本地数据) | 本地缓存命中时 |
-| 详情页加载 | ≤2 秒(含 ERP 状态刷新) | 打开→所有数据渲染完成 |
-| 附件上传 | 图片 ≤5 秒(20MB) | 上传进度条完成 |
-| API 响应(列表) | P95 ≤500ms | .NET 服务端处理时间 |
-| API 响应(ERP 查询) | P95 ≤3s(含 ERP 耗时) | .NET → ERP → 返回 |
+| 指标 | 目标 |
+|------|------|
+| 应用冷启动 | ≤3 秒 |
+| 列表页首屏 | ≤1 秒(本地数据) |
+| 详情页加载 | ≤2 秒(含 ERP 状态刷新) |
+| 附件上传 | 图片 ≤5 秒(20MB) |
+| API 响应(列表) | P95 ≤500ms |
+| API 响应(ERP 查询) | P95 ≤3s(含 ERP 耗时) |
 
 #### 5.5.2 可用性
 
 | 指标 | 目标 |
 |------|------|
-| 月度可用率 | ≥99.5%(允许月度故障 ≤3.6 小时) |
-| 单 ERP 不可用时的降级 | 其他 ERP 用户不受影响;受影响用户看到明确错误提示(非白屏) |
+| 月度可用率 | ≥99.5% |
+| 单 ERP 不可用降级 | 其他 ERP 用户不受影响;受影响用户看到明确错误提示 |
 | 数据持久性 | 已提交单据零丢失(SQL Server 主备) |
 
 #### 5.5.3 兼容性
@@ -787,7 +686,7 @@ flowchart TD
 |------|---------|
 | Android | 8.0 (API 26) |
 | iOS | 14.0 |
-| Flutter | 3.38.10(已锁定) |
+| Flutter | 3.38.10 |
 | SQL Server | 2019+ |
 
 #### 5.5.4 安全
@@ -796,9 +695,9 @@ flowchart TD
 |------|------|
 | 传输加密 | 全链路 HTTPS(客户端→服务端);内网 HTTP(服务端→ERP) |
 | 身份认证 | 复用宿主 App Token;401 自动触发宿主重登录 |
-| 数据隔离 | 列表查询按 OA 权限(view_own/view_dept/view_all)严格控制数据范围 |
-| 敏感数据脱敏 | 银行账号列表页展示后 4 位(`****0123`),详情页完整展示 |
-| 审计 | 权限变更全量记录 OaPermissionChangeLog;单据操作通过 CreateTime/UpdateTime 追溯 |
+| 数据隔离 | 列表查询按 OA 权限严格控制数据范围 |
+| 敏感数据脱敏 | 银行账号列表页展示后 4 位,详情页完整展示 |
+| 审计 | 权限变更全量记录 OaPermissionChangeLog |
 
 ### 5.6 依赖与假设
 
@@ -807,50 +706,29 @@ flowchart TD
 | 宿主 App Flutter 集成 | Flutter Module 已嵌入 Android/iOS 宿主 | OA 模块不可用 |
 | ERP API 可用 | 审批/预算/项目/科目/汇率/客户接口 | 各 Adapter 返回空/不限,OA 隐藏对应功能区块 |
 | 定时 Job 调度 | 申请过期自动释放预算 | 预算无法自动释放,需人工处理 |
-| .NET 服务端消息模块 | 推送通知 | 站内消息仍可见(轮询),Push 通知不可用 |
+| .NET 服务端消息模块 | 推送通知 | 站内消息仍可见(轮询) |
 | MethodChannel | 相机/相册/通讯录/GPS/地图选点 | 对应功能不可用,按钮置灰+提示 |
 | SQL Server | OA 业务数据持久化 | 全模块不可用 |
 
 **假设**:
 - 宿主 App 已完成用户登录认证,OA 启动时能从宿主获取 Token 和 ERP 用户 ID
-- ERP 审批引擎支持基础审批动作(同意/拒绝),转交和高级形态(会签/或签)暂不支持
-- 每套 ERP 的审批 API 签名虽不同,但功能语义一致(都有创建实例/查询状态/执行动作)
-- 预算、汇率等数据在 ERP 端已维护;费用标准 V1 不涉及 | 后续版本再接入
+- ERP 审批引擎支持基础审批动作(同意/拒绝)
+- 每套 ERP 的审批 API 签名虽不同,但功能语义一致
 
-### 5.7 消息通知规范
-
-| 事件 | 接收人 | 标题模板 | 点击跳转 |
-|------|--------|---------|---------|
-| 单据提交审批 | 首级审批人 | "{申请人}提交了{单据类型},待审批" | 对应详情页 + 审批操作栏 |
-| 审批通过(非最后一级) | 下一级审批人 | "{单据类型}待审批:{摘要}" | 对应详情页 + 审批操作栏 |
-| 审批全部通过 | 申请人 | "您的{单据类型}已全部通过审批" | 对应详情页 |
-| 审批拒绝 | 申请人 | "您的{单据类型}已被拒绝" | 对应详情页(展示拒绝理由) |
-| 申请被撤回 | 当前审批人 | "{申请人}撤回了{单据类型}" | 消息列表(单据已不可审批) |
-| 公告发布 | 范围内全员 | "【{公告类型}】{标题}" | 公告详情页 |
-| DING 催办 | 未读员工 | "【行政催办】{公告标题}" | 公告详情页(高优先级 Push) |
-| 申请已过期 | 申请人 | "您的{单据类型}{单号}已过期,预算已释放" | 对应详情页 |
-
-**通道优先级**:审批类 → App Push + 站内消息。公告类 → 站内消息。DING 催办 → App Push(高优先级,无视免打扰)。
-
-### 5.8 灰度发布策略
+---
 
-| 阶段 | 范围 | 时长 | 验证重点 |
-|------|------|------|---------|
-| 内部试用 | 项目组 + 财务部(~20 人) | 1-2 周 | 功能完整性、流程闭环、严重 Bug |
-| 部门试点 | 1-2 个部门(~100 人) | 2-4 周 | 真实场景覆盖、审批链适配、ERP 集成稳定性 |
-| 全量发布 | 全公司 | — | 性能、并发、用户反馈 |
+## 附录
 
-灰度期间发现的严重问题回滚到上一版本,非严重问题记录后在全量发布前修复。
+### 附录 A:快捷套餐权限点清单
 
-### 5.9 开发阶段建议
+| 套餐 | 包含权限点 |
+|------|----------|
+| **员工(默认)** | `oa.expense_apply.submit`、`oa.expense.submit`、`oa.overtime.submit`、`oa.vehicle.submit`、`oa.outing_log.submit`、`oa.announcement.view`、`oa.report.view_own` |
+| **审批人(经理)** | 员工所有权限 + `oa.expense_apply.approve`、`oa.expense.approve`、`oa.overtime.approve`、`oa.vehicle.approve`、`oa.outing_log.comment`、`oa.report.view_dept` |
+| **财务** | 员工所有权限 + `oa.expense.verify_invoice`、`oa.expense.pay`、`oa.report.view_all`、`oa.report.export` |
+| **管理员** | 全部权限(含以上所有)+ `oa.announcement.publish`、`oa.announcement.ding`、`oa.admin.permission`、`oa.admin.vehicle`、`oa.admin.banner`、`oa.admin.cost_category` |
 
-| 阶段 | 内容 | 周期估算 |
-|------|------|---------|
-| **Phase 1** | 费用申请(多类型+预算) + 费用报销(拼单导入+币种) + 加班 + 用车 + 外勤日志 + 公告 + 5 大报表 + 权限 + 工作台 + ERP 适配器(审批/预算/项目/科目/汇率/客户) | 8 模块闭环 |
-
----
-
-## 附录:术语表
+### 附录 B:术语表
 
 | 术语 | 说明 |
 |------|------|
@@ -858,15 +736,20 @@ flowchart TD
 | ACL | 访问控制列表,按用户逐个赋权 |
 | 费用申请 | 费用发生前的预估申请,审批通过后可被报销单引用 |
 | 审批实例 | ERP 审批引擎中一次审批流程的运行实例 |
-| 审批时间线 | 审批流程各节点的操作历史(谁在什么时间做了什么动作) |
+| 审批时间线 | 审批流程各节点的操作历史 |
 | 净工时 | 扣除午休和晚餐休息盲区后的实际加班小时数 |
 | 核销 | 财务确认发票合规、线下打款、凭证归档的完整闭环 |
 | DING 催办 | 管理员对未读公告的员工推送高优先级通知 |
-| 快捷套餐 | 预设的权限组合模板,一键赋予员工/审批人/财务/管理员权限 |
-| ERP 适配器 | .NET 服务端统一接口层,每套 ERP 一个实现,隔离差异 |
-| 预算控制 | 费用申请/报销关联项目预算,超支自动警告(V1 通过 ERP 适配器实现) |
-| 预算科目 | 财务核算科目(如差旅费、招待费),预算挂在项目×科目的交叉点上 |
-| 外币折算 | 报销明细选择外币后自动从 ERP 获取汇率,折算为本币(CNY)进行预算校验和报表统计 |
-| 拼单导入 | 报销时从多张已通过的费用申请导入金额,合并为一张报销单提交 |
-| 排期冲突 | 用车申请选定车牌+时间后检测是否与已批准的申请存在时段重叠 |
+| 快捷套餐 | 预设的权限组合模板 |
+| ERP 适配器 | .NET 服务端统一接口层,每套 ERP 一个实现 |
+| 预算控制 | 费用申请/报销关联项目预算,超支自动警告 |
+| 预算科目 | 财务核算科目,预算挂在项目×科目的交叉点上 |
+| 外币折算 | 报销明细选择外币后自动从 ERP 获取汇率,折算为本币 |
+| 拼单导入 | 报销时从多张已通过的费用申请导入金额 |
+| 排期冲突 | 用车申请选定车牌+时间后检测时段重叠 |
 | 触达率 | 公告发布后已读人数占应达总人数的比例 |
+| 乐观锁 | 使用 Version 字段控制并发更新冲突 |
+
+---
+
+> **文档版本**:v1.1 | 日期:2026-06-27

+ 12 - 3
lib/app.dart

@@ -1,8 +1,10 @@
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_localizations/flutter_localizations.dart';
 import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'core/theme/app_colors.dart';
 import 'core/theme/app_theme.dart';
 import 'core/theme/theme_mode_provider.dart';
 import 'core/theme/tdesign_resource_delegate.dart';
@@ -12,11 +14,12 @@ import 'core/auth/auth_service.dart';
 import 'core/i18n/app_localizations.dart';
 import 'core/i18n/locale_provider.dart';
 import 'core/navigation/navigation_channel.dart';
+import 'core/navigation/host_app_channel.dart';
 
 final apiClientProvider = Provider<ApiClient>((ref) {
   const useMock = true;
   final client = ApiClient(
-    baseUrl: 'https://your-api-host.com/api',
+    baseUrl: HostAppChannel.baseUrl,
     useMock: useMock,
   );
   final authService = ref.read(authServiceProvider);
@@ -45,7 +48,12 @@ class App extends ConsumerWidget {
       (context) => TDResourceI18nDelegate(context),
       needAlwaysBuild: true,
     );
-    return MaterialApp.router(
+    return AnnotatedRegion<SystemUiOverlayStyle>(
+      value: const SystemUiOverlayStyle(
+        statusBarColor: AppColors.bgCard,
+        statusBarIconBrightness: Brightness.dark,
+      ),
+      child: MaterialApp.router(
         key: ValueKey(locale),
         title: 'TBOSS OA',
         theme: AppTheme.light,
@@ -65,6 +73,7 @@ class App extends ConsumerWidget {
           GlobalCupertinoLocalizations.delegate,
         ],
         debugShowCheckedModeBanner: false,
-      );
+      ),
+    );
   }
 }

+ 51 - 0
lib/core/navigation/host_app_channel.dart

@@ -0,0 +1,51 @@
+import 'package:flutter/services.dart';
+
+/// 从原生宿主 App(Android/iOS)获取 ERP 连接配置。
+///
+/// 对应 Android 端 [HeaderInterceptor] + [WebApiUrl] 的值来源:
+/// - baseUrl: Pub_CompInfo.getWebApiUrl()
+/// - sn: SharedPreferences "MemberNo"
+/// - loginId: SharedPreferences "LoginId"
+///
+/// iOS 调用示例:
+/// ```objc
+/// FlutterMethodChannel *ch = [FlutterMethodChannel
+///     methodChannelWithName:@"com.tboss.oa/config"
+///            binaryMessenger:engine.binaryMessenger];
+/// [ch setMethodCallHandler:^(FlutterMethodCall *call, FlutterResult result) {
+///     result(@"ok"); // 或返回具体值
+/// }];
+/// ```
+class HostAppChannel {
+  static const _channel = MethodChannel('com.tboss.oa/config');
+
+  static String? _baseUrl;
+  static String? _sn;
+  static String? _loginId;
+
+  static String get baseUrl => _baseUrl ?? '';
+  static String get sn => _sn ?? '';
+  static String get loginId => _loginId ?? '';
+
+  static bool _initialized = false;
+  static bool get isInitialized => _initialized;
+
+  /// 从宿主 App 加载配置,需在 runApp 之前调用。
+  static Future<void> initialize() async {
+    if (_initialized) return;
+    try {
+      final result = await _channel.invokeMethod<Map<dynamic, dynamic>>('getConfig');
+      if (result != null) {
+        _baseUrl = result['baseUrl'] as String?;
+        _sn = result['sn'] as String?;
+        _loginId = result['loginId'] as String?;
+      }
+    } catch (_) {
+      // 调试 / Mock 模式回退
+      _baseUrl = 'https://your-api-host.com/api';
+      _sn = '';
+      _loginId = '';
+    }
+    _initialized = true;
+  }
+}

+ 46 - 1
lib/core/network/api_client.dart

@@ -1,4 +1,5 @@
 import 'package:dio/dio.dart';
+import '../navigation/host_app_channel.dart';
 import 'api_exception.dart';
 import 'api_response.dart';
 import 'mock_interceptor.dart';
@@ -9,7 +10,7 @@ class ApiClient {
   final String baseUrl;
   final bool useMock;
 
-  ApiClient({required this.baseUrl, this.useMock = false}) {
+  ApiClient({required this.baseUrl, this.useMock = false, bool logEnabled = true}) {
     _dio = Dio(BaseOptions(
       baseUrl: baseUrl,
       connectTimeout: const Duration(seconds: 15),
@@ -17,10 +18,29 @@ class ApiClient {
       headers: {'Content-Type': 'application/json'},
     ));
 
+    if (logEnabled) {
+      _dio.interceptors.add(LogInterceptor(
+        requestBody: true,
+        responseBody: true,
+        requestHeader: true,
+        responseHeader: false,
+      ));
+    }
+
     _dio.interceptors.add(MockInterceptor(enabled: useMock));
 
     _dio.interceptors.add(InterceptorsWrapper(
       onRequest: (options, handler) {
+        // ═══ 模拟 Android HeaderInterceptor ═══
+        if (HostAppChannel.sn.isNotEmpty) {
+          options.headers['sn'] = HostAppChannel.sn;
+        }
+        if (HostAppChannel.loginId.isNotEmpty) {
+          options.headers['LoginId'] = HostAppChannel.loginId;
+        }
+        options.headers['Connection'] = 'close';
+        options.headers['Accept-Encoding'] = 'identity';
+
         if (_token != null) {
           options.headers['Authorization'] = 'Bearer $_token';
         }
@@ -94,4 +114,29 @@ class ApiClient {
     final response = await _dio.delete(path);
     return ApiResponse.fromJson(response.data, fromJsonT);
   }
+
+  /// 多文件上传(multipart/form-data)
+  Future<ApiResponse<T>> uploadMultipart<T>(
+    String path, {
+    required List<MultipartFile> files,
+    Map<String, dynamic>? extraFields,
+    T Function(dynamic json)? fromJsonT,
+    void Function(int sent, int total)? onSendProgress,
+  }) async {
+    final formData = FormData();
+    for (final file in files) {
+      formData.files.add(MapEntry('files', file));
+    }
+    if (extraFields != null) {
+      for (final entry in extraFields.entries) {
+        formData.fields.add(MapEntry(entry.key, entry.value.toString()));
+      }
+    }
+    final response = await _dio.post(
+      path,
+      data: formData,
+      onSendProgress: onSendProgress,
+    );
+    return ApiResponse.fromJson(response.data, fromJsonT);
+  }
 }

+ 24 - 0
lib/core/network/mock_data.dart

@@ -526,4 +526,28 @@ class MockData {
           'updateTime': '2024-05-05T09:00:00',
         },
       };
+
+  static List<Map<String, dynamic>> get currencies => [
+    {'curId': 'CNY', 'name': '人民币', 'excRto': 1.0, 'nameEng': 'RMB'},
+    {'curId': 'USD', 'name': '美元', 'excRto': 7.25, 'nameEng': 'USD'},
+    {'curId': 'EUR', 'name': '欧元', 'excRto': 7.98, 'nameEng': 'EUR'},
+    {'curId': 'JPY', 'name': '日元', 'excRto': 0.048, 'nameEng': 'JPY'},
+    {'curId': 'HKD', 'name': '港币', 'excRto': 0.93, 'nameEng': 'HKD'},
+  ];
+
+  static Map<String, dynamic> get approvalTimeline => {
+    'bilId': 'BX',
+    'bilNo': 'BX202606001',
+    'bilItm': 0,
+    'isFinalMan': false,
+    'steps': [],
+    'tdSteps': {
+      'current': 1,
+      'items': [
+        {'title': '已提交', 'description': '张三', 'time': '2026-06-29 14:30', 'status': 'finish'},
+        {'title': '审核同意', 'description': '李四', 'time': '2026-06-29 15:00', 'status': 'finish'},
+        {'title': '财务审批', 'description': '审批人', 'time': null, 'status': 'default'},
+      ],
+    },
+  };
 }

+ 14 - 12
lib/core/network/mock_interceptor.dart

@@ -42,36 +42,38 @@ class MockInterceptor extends Interceptor {
     if (path == '/home/summary') return MockData.homeSummary;
 
     // ==================== 费用报销 ====================
-    if (path == '/expense/list') {
+    if (path == '/OA/GetExpenseReports') {
       var list = MockData.expenseList;
       if (status.isNotEmpty) {
         list = list.where((e) => e['status'] == status).toList();
       }
       return _paginate(list, page);
     }
-    if (path.startsWith('/expense/detail/')) {
-      return MockData.expenseDetail(path.split('/').last);
+    if (path == '/OA/GetExpenseReportDetail') {
+      final billNo = params['billNo'] ?? '';
+      return MockData.expenseDetail(billNo);
     }
-    if (path == '/expense/apply' || path == '/expense/draft') {
-      return MockData.success;
-    }
-    if (path == '/expense/write-off') {
+    if (path == '/OA/BillSave' || path == '/OA/ExpenseVerify') {
       return MockData.success;
     }
 
     // ==================== 事前申请 ====================
-    if (path == '/expense-apply/list') {
+    if (path == '/OA/GetExpenseApplies') {
       var list = MockData.expenseApplicationList;
       if (status.isNotEmpty) {
         list = list.where((e) => e['status'] == status).toList();
       }
       return _paginate(list, page);
     }
-    if (path.startsWith('/expense-apply/detail/')) {
-      return MockData.expenseApplicationDetail(path.split('/').last);
+    if (path == '/OA/GetExpenseApplyDetail') {
+      final billNo = params['billNo'] ?? '';
+      return MockData.expenseApplicationDetail(billNo);
     }
-    if (path == '/expense-apply/submit' || path == '/expense-apply/draft') {
-      return MockData.success;
+    if (path == '/OA/GetCurrencies') {
+      return {'code': 0, 'message': 'success', 'data': _paginate(MockData.currencies, page)};
+    }
+    if (path == '/OA/GetApprovalTimeline') {
+      return {'code': 0, 'message': 'success', 'data': MockData.approvalTimeline};
     }
 
     // ==================== 加班 ====================

+ 48 - 0
lib/core/storage/draft_storage.dart

@@ -0,0 +1,48 @@
+import 'dart:convert';
+import 'package:flutter/services.dart';
+
+/// 本地草稿持久化服务,通过 MethodChannel 调用原生 SharedPreferences / NSUserDefaults。
+///
+/// 避免引入 shared_preferences 包,不需要 Kotlin 插件。
+class DraftStorage {
+  static const _channel = MethodChannel('com.tboss.oa/storage');
+  static const _prefix = 'oa_draft_';
+
+  /// 保存草稿
+  static Future<void> save(String key, Map<String, dynamic> data) async {
+    await _channel.invokeMethod('setString', {
+      'key': _prefix + key,
+      'value': jsonEncode(data),
+    });
+  }
+
+  /// 读取草稿,不存在返回 null
+  static Future<Map<String, dynamic>?> load(String key) async {
+    try {
+      final raw = await _channel.invokeMethod<String>('getString', {
+        'key': _prefix + key,
+      });
+      if (raw == null || raw.isEmpty) return null;
+      return jsonDecode(raw) as Map<String, dynamic>;
+    } catch (_) {
+      return null;
+    }
+  }
+
+  /// 是否有草稿
+  static Future<bool> has(String key) async {
+    try {
+      final result = await _channel.invokeMethod<bool>('hasKey', {
+        'key': _prefix + key,
+      });
+      return result ?? false;
+    } catch (_) {
+      return false;
+    }
+  }
+
+  /// 删除草稿
+  static Future<void> delete(String key) async {
+    await _channel.invokeMethod('remove', {'key': _prefix + key});
+  }
+}

+ 42 - 18
lib/features/expense/expense_api.dart

@@ -1,5 +1,6 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import '../../core/network/api_client.dart';
+import '../../core/network/api_response.dart';
 import '../../app.dart';
 import '../../shared/models/pagination_model.dart';
 import 'expense_model.dart';
@@ -12,39 +13,62 @@ class ExpenseApi {
   final ApiClient _client;
   ExpenseApi(this._client);
 
+  /// 费用报销列表(分页)
   Future<PaginatedData<ExpenseModel>> fetchList({
     String status = '',
+    String keyword = '',
+    String startDate = '',
+    String endDate = '',
+    String usr = '',
     int page = 1,
     int size = 20,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
-      '/expense/list',
-      queryParameters: {'status': status, 'page': page, 'size': size},
-    );
-    final data = response.data!;
-    final list = (data['list'] as List<dynamic>)
-        .map((e) => ExpenseModel.fromJson(e as Map<String, dynamic>))
-        .toList();
-    return PaginatedData(
-      list: list,
-      page: data['page'] as int,
-      size: data['size'] as int,
-      total: data['total'] as int,
+      '/OA/GetExpenseReports',
+      queryParameters: {
+        'status': status,
+        'keyword': keyword,
+        'startDate': startDate,
+        'endDate': endDate,
+        'usr': usr,
+        'page': page,
+        'size': size,
+      },
     );
+    return PaginatedData.fromJson(response.data!, ExpenseModel.fromJson);
   }
 
-  Future<ExpenseModel> fetchDetail(String id) async {
+  /// 费用报销详情(主表+明细)
+  Future<ExpenseModel> fetchDetail(String billNo) async {
     final response = await _client.get<Map<String, dynamic>>(
-      '/expense/detail/$id',
+      '/OA/GetExpenseReportDetail',
+      queryParameters: {'billNo': billNo},
     );
     return ExpenseModel.fromJson(response.data!);
   }
 
-  Future<void> submit(ExpenseModel expense) async {
-    await _client.post('/expense/apply', data: expense.toJson());
+  /// 提交审批
+  Future<void> submit(Map<String, dynamic> data) async {
+    await _client.post('/OA/BillSave', data: {
+      'erpCategory': 'MasterService',
+      'billId': 'BX',
+      'procId': '',
+      'data': data,
+    });
+  }
+
+  /// 财务核销
+  Future<void> verify(Map<String, dynamic> data) async {
+    await _client.post('/OA/ExpenseVerify', data: data);
   }
 
-  Future<void> saveDraft(ExpenseModel expense) async {
-    await _client.put('/expense/draft', data: expense.toJson());
+  /// 币别查询
+  Future<ApiResponse<Map<String, dynamic>>> fetchCurrencies({
+    String keyword = '',
+    int page = 1,
+    int size = 50,
+  }) async {
+    return await _client.get('/OA/GetCurrencies',
+        queryParameters: {'keyword': keyword, 'page': page, 'size': size});
   }
 }

+ 35 - 0
lib/features/expense/expense_apply_mapping_model.dart

@@ -0,0 +1,35 @@
+/// 申请↔报销多对多关联,对应 [ExpenseApplyMapping] 表。
+class ExpenseApplyMappingModel {
+  final String id;
+  final String expenseId;
+  final String expenseApplyId;
+  final double importedAmount;
+  final DateTime createTime;
+
+  const ExpenseApplyMappingModel({
+    required this.id,
+    required this.expenseId,
+    required this.expenseApplyId,
+    required this.importedAmount,
+    required this.createTime,
+  });
+
+  factory ExpenseApplyMappingModel.fromJson(Map<String, dynamic> json) {
+    return ExpenseApplyMappingModel(
+      id: json['id'] as String,
+      expenseId: json['expenseId'] as String? ?? '',
+      expenseApplyId: json['expenseApplyId'] as String? ?? '',
+      importedAmount:
+          (json['importedAmount'] as num?)?.toDouble() ?? 0.0,
+      createTime: DateTime.parse(json['createTime'] as String),
+    );
+  }
+
+  Map<String, dynamic> toJson() => {
+        'id': id,
+        'expenseId': expenseId,
+        'expenseApplyId': expenseApplyId,
+        'importedAmount': importedAmount,
+        'createTime': createTime.toIso8601String(),
+      };
+}

+ 24 - 2
lib/features/expense/expense_create_controller.dart

@@ -1,7 +1,10 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../core/storage/draft_storage.dart';
 import 'expense_model.dart';
 import 'expense_api.dart';
 
+const expenseDraftKey = 'expense';
+
 class ExpenseCreateState {
   final ExpenseModel expense;
   final bool isSubmitting;
@@ -71,6 +74,10 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
     state = state.copyWith(expense: state.expense.copyWith(details: details));
   }
 
+  void restoreFromDraft(ExpenseModel draft, ExpenseApi api) {
+    state = ExpenseCreateState(expense: draft);
+  }
+
   void updatePurpose(String purpose) {
     state = state.copyWith(expense: state.expense.copyWith(purpose: purpose));
   }
@@ -103,7 +110,8 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
   Future<bool> submit() async {
     state = state.copyWith(isSubmitting: true);
     try {
-      await _api.submit(state.expense.copyWith(status: 'pending'));
+      await _api.submit(state.expense.copyWith(status: 'pending').toJson());
+      await DraftStorage.delete(expenseDraftKey);
       return true;
     } catch (_) {
       return false;
@@ -115,7 +123,7 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
   Future<bool> saveDraft() async {
     state = state.copyWith(isSubmitting: true);
     try {
-      await _api.saveDraft(state.expense);
+      await DraftStorage.save(expenseDraftKey, state.expense.toJson());
       return true;
     } catch (_) {
       return false;
@@ -123,6 +131,20 @@ class ExpenseCreateController extends StateNotifier<ExpenseCreateState> {
       state = state.copyWith(isSubmitting: false);
     }
   }
+
+  static Future<ExpenseModel?> loadDraft() async {
+    final data = await DraftStorage.load(expenseDraftKey);
+    if (data == null) return null;
+    try {
+      return ExpenseModel.fromJson(data);
+    } catch (_) {
+      return null;
+    }
+  }
+
+  static Future<bool> hasDraft() => DraftStorage.has(expenseDraftKey);
+
+  static Future<void> deleteDraft() => DraftStorage.delete(expenseDraftKey);
 }
 
 final expenseCreateProvider = StateNotifierProvider.autoDispose

+ 134 - 5
lib/features/expense/expense_create_page.dart

@@ -6,6 +6,7 @@ import '../../shared/widgets/nav_bar_config.dart';
 import '../../core/utils/responsive.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
+import 'expense_api.dart';
 import 'expense_create_controller.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'expense_model.dart';
@@ -13,6 +14,7 @@ import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/data/mock_api_data.dart';
 import 'widgets/expense_detail_dialog.dart';
+import 'package:image_picker/image_picker.dart';
 
 class ExpenseApplyPage extends ConsumerStatefulWidget {
   final String? editId;
@@ -24,6 +26,48 @@ class ExpenseApplyPage extends ConsumerStatefulWidget {
 
 class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
   final _purposeController = TextEditingController();
+  final List<String> _attachments = [];
+
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft());
+  }
+
+  Future<void> _checkDraft() async {
+    if (!mounted || widget.editId != null) return;
+    final has = await ExpenseCreateController.hasDraft();
+    if (!has || !mounted) return;
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final yes = await showDialog<bool>(
+      context: context,
+      builder: (ctx) => TDAlertDialog(
+        title: l10n.get('draftFound'),
+        content: l10n.get('draftRestorePrompt'),
+        leftBtn: TDDialogButtonOptions(
+          title: l10n.get('discard'),
+          titleColor: colors.textSecondary,
+          action: () => Navigator.pop(ctx, false),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: l10n.get('restore'),
+          titleColor: colors.primary,
+          action: () => Navigator.pop(ctx, true),
+        ),
+      ),
+    );
+    if (yes == true && mounted) {
+      final draft = await ExpenseCreateController.loadDraft();
+      if (draft != null && mounted) {
+        final api = ref.read(expenseApiProvider);
+        ref.read(expenseCreateProvider(widget.editId).notifier)
+          ..restoreFromDraft(draft, api);
+      }
+    } else {
+      await ExpenseCreateController.deleteDraft();
+    }
+  }
 
   @override
   void dispose() {
@@ -289,15 +333,100 @@ class _ExpenseApplyPageState extends ConsumerState<ExpenseApplyPage> {
       children: [
         Text(l10n.get('maxInvoices'), style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
         const SizedBox(height: 8),
-        GestureDetector(
-          onTap: () => TDToast.showText(l10n.get('expenseApplyImport'), context: context),
-          child: Container(width: 80, height: 80, decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
-              child: Center(child: Icon(Icons.add, size: 24, color: colors.textPlaceholder))),
-        ),
+        if (_attachments.isEmpty)
+          Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPlaceholder))
+        else
+          Wrap(
+            spacing: 8,
+            runSpacing: 8,
+            children: [
+              ..._attachments.asMap().entries.map((entry) {
+                final i = entry.key;
+                final path = entry.value;
+                final name = path.split('/').last.split('\\').last;
+                return SizedBox(
+                  width: 80,
+                  child: Stack(
+                    children: [
+                      Container(
+                        width: 80,
+                        height: 80,
+                        decoration: BoxDecoration(
+                          color: colors.bgPage,
+                          borderRadius: BorderRadius.circular(4),
+                          border: Border.all(color: colors.border),
+                        ),
+                        child: Column(
+                          mainAxisAlignment: MainAxisAlignment.center,
+                          children: [
+                            Icon(Icons.insert_drive_file, size: 28, color: colors.primary),
+                            const SizedBox(height: 2),
+                            Padding(
+                              padding: const EdgeInsets.symmetric(horizontal: 4),
+                              child: Text(
+                                name,
+                                maxLines: 2,
+                                overflow: TextOverflow.ellipsis,
+                                style: TextStyle(fontSize: 10, color: colors.textSecondary),
+                                textAlign: TextAlign.center,
+                              ),
+                            ),
+                          ],
+                        ),
+                      ),
+                      Positioned(
+                        right: 0,
+                        top: 0,
+                        child: GestureDetector(
+                          onTap: () => setState(() => _attachments.removeAt(i)),
+                          child: Container(
+                            width: 20,
+                            height: 20,
+                            decoration: BoxDecoration(
+                              color: colors.bgCard,
+                              shape: BoxShape.circle,
+                              border: Border.all(color: colors.border),
+                            ),
+                            child: Icon(Icons.close, size: 12, color: colors.textSecondary),
+                          ),
+                        ),
+                      ),
+                    ],
+                  ),
+                );
+              }),
+              if (_attachments.length < 9)
+                GestureDetector(
+                  onTap: _pickFiles,
+                  child: Container(
+                    width: 80,
+                    height: 80,
+                    decoration: BoxDecoration(
+                      color: colors.bgPage,
+                      borderRadius: BorderRadius.circular(4),
+                      border: Border.all(color: colors.border),
+                    ),
+                    child: Center(child: Icon(Icons.add, size: 24, color: colors.textPlaceholder)),
+                  ),
+                ),
+            ],
+          ),
       ],
     );
   }
 
+  Future<void> _pickFiles() async {
+    final available = 9 - _attachments.length;
+    if (available <= 0) return;
+    final picker = ImagePicker();
+    final images = await picker.pickMultiImage(limit: available);
+    if (images.isEmpty) return;
+    setState(() {
+      _attachments.addAll(images.map((img) => img.path));
+      if (_attachments.length > 9) _attachments.length = 9;
+    });
+  }
+
   Widget _buildBottomButtons(ExpenseCreateController controller, ExpenseCreateState state) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     final l10n = AppLocalizations.of(context);

+ 230 - 411
lib/features/expense/expense_detail_page.dart

@@ -8,7 +8,6 @@ import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/status_banner.dart';
 import '../../shared/widgets/action_bar.dart';
-import '../../shared/widgets/approval_timeline.dart';
 import 'expense_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'expense_list_controller.dart';
@@ -28,7 +27,6 @@ class ExpenseDetailPage extends ConsumerWidget {
       orElse: () => mockExpenses.first,
     );
     final l10n = AppLocalizations.of(context);
-
     final isFinance = ref.watch(isFinanceProvider);
     final isAdmin = ref.watch(isAdminProvider);
 
@@ -49,24 +47,24 @@ class ExpenseDetailPage extends ConsumerWidget {
             child: Column(
               children: [
                 _buildStatusBanner(expense, l10n, colors),
-                const SizedBox(height: 4),
+                const SizedBox(height: 8),
                 _buildSubmitTime(expense, l10n, colors),
                 const SizedBox(height: 16),
                 _buildBasicInfoSection(expense, l10n, colors),
                 const SizedBox(height: 16),
-                _buildAccountSection(expense, l10n),
-                const SizedBox(height: 16),
-                _buildDetailSection(expense, l10n, colors),
+                _buildExpenseDetailSection(expense, l10n, colors),
                 const SizedBox(height: 16),
                 _buildInvoiceSection(expense, l10n, colors),
+                if (isFinance) ...[
+                  const SizedBox(height: 16),
+                  _buildComplianceSection(expense, l10n, colors),
+                ],
                 const SizedBox(height: 16),
-                if (isFinance) _buildComplianceSection(expense, l10n, colors),
-                const SizedBox(height: 16),
-                if (expense.approvalRecords.isNotEmpty ||
-                    expense.approvalChain.isNotEmpty)
-                  _buildApprovalSection(expense, l10n),
-                const SizedBox(height: 16),
-                if (isFinance || isAdmin) _buildArchiveSection(expense, l10n),
+                _buildApprovalSection(l10n, colors),
+                if (isFinance || isAdmin) ...[
+                  const SizedBox(height: 16),
+                  _buildArchiveSection(expense, l10n, colors),
+                ],
               ],
             ),
           ),
@@ -76,11 +74,7 @@ class ExpenseDetailPage extends ConsumerWidget {
     );
   }
 
-  Widget _buildStatusBanner(
-    ExpenseModel expense,
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
+  Widget _buildStatusBanner(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     final (icon, color, label) = switch (expense.status) {
       'approved' => (Icons.check_circle, colors.success, l10n.get('approved')),
       'rejected' => (Icons.cancel, colors.danger, l10n.get('rejected')),
@@ -88,471 +82,296 @@ class ExpenseDetailPage extends ConsumerWidget {
       _ => (Icons.schedule, colors.warning, l10n.get('pending')),
     };
     final approverText = switch (expense.status) {
-      'approved' when expense.approvalRecords.isNotEmpty =>
-        '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
-      'rejected' when expense.approvalRecords.isNotEmpty =>
-        '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
-      'pending' when expense.currentApproverId.isNotEmpty =>
-        '${l10n.get('currentApprover')}:${expense.currentApproverId}',
+      'approved' when expense.approvalRecords.isNotEmpty => '${l10n.get('approver')}:${expense.approvalRecords.last.approverName}',
+      'rejected' when expense.approvalRecords.isNotEmpty => '${l10n.get('rejecter')}:${expense.approvalRecords.last.approverName}',
+      'pending' when expense.currentApproverId.isNotEmpty => '${l10n.get('currentApprover')}:${expense.currentApproverId}',
       _ => '',
     };
-    return StatusBanner(
-      icon: icon,
-      statusText: label,
-      subText: approverText,
-      color: color,
-    );
+    return StatusBanner(icon: icon, statusText: label, subText: approverText, color: color);
   }
 
-  Widget _buildSubmitTime(
-    ExpenseModel expense,
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
+  Widget _buildSubmitTime(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     return Padding(
-      padding: const EdgeInsets.only(left: 4, top: 4),
-      child: Align(
-        alignment: Alignment.centerLeft,
-        child: Text(
-          '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
-          style: TextStyle(
-            fontSize: AppFontSizes.caption,
-            color: colors.textPlaceholder,
-          ),
-        ),
-      ),
+      padding: const EdgeInsets.only(left: 4),
+      child: Text('${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(expense.createTime)}',
+          style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
     );
   }
 
-  Widget _buildBasicInfoSection(
-    ExpenseModel expense,
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
+  // ═══ 基本信息 + 收款账户 — 对应 create 页 basicInfo + 数据库 Expense 字段 ═══
+  Widget _buildBasicInfoSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
+    var pms = expense.paymentMethod;
+    if (pms == 'bankTransfer') pms = l10n.get('bankTransfer');
+    else if (pms == 'cash') pms = l10n.get('cash');
+    else if (pms == 'alipay') pms = l10n.get('alipay');
+    else if (pms == 'wechat') pms = l10n.get('wechat');
     return FormSection(
       title: l10n.get('basicInfo'),
+      leadingIcon: Icons.info_outline,
       children: [
-        FormFieldRow(
-          label: l10n.get('applicant'),
-          value: expense.applicantName,
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('department'),
-          value: expense.deptName,
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('expenseType'),
-          value: '费用报销',
-          readOnly: true,
-          showArrow: false,
-        ),
-        Container(
-          height: 44,
-          padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
-          child: Row(
-            mainAxisAlignment: MainAxisAlignment.spaceBetween,
-            children: [
-              Text(
-                l10n.get('expenseAmount'),
-                style: TextStyle(
-                  fontSize: AppFontSizes.body,
-                  color: colors.textSecondary,
-                ),
-              ),
-              Text(
-                '¥${expense.totalAmount.toStringAsFixed(2)}',
-                style: TextStyle(
-                  fontSize: AppFontSizes.subtitle,
-                  fontWeight: FontWeight.w700,
-                  color: colors.amountPrimary,
-                ),
+        FormFieldRow(label: l10n.get('expenseNo'), value: expense.expenseNo, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('applicant'), value: expense.applicantName, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('department'), value: expense.deptName, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(expense.createTime), readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('currency'), value: expense.currencyCode, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('feeReason'), value: expense.purpose, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('expenseAmount'), value: '¥${expense.totalAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false),
+        if (expense.approvedAmount > 0) ...[
+          const SizedBox(height: 16),
+          FormFieldRow(label: l10n.get('approvedAmount'), value: '¥${expense.approvedAmount.toStringAsFixed(2)}', readOnly: true, showArrow: false),
+        ],
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('paymentMethod'), value: pms.isNotEmpty ? pms : '-', readOnly: true, showArrow: false),
+        if (expense.details.any((d) => d.bankName.isNotEmpty)) ...[
+          const SizedBox(height: 16),
+          FormSection(title: l10n.get('receiptAccount'), leadingIcon: Icons.account_balance_outlined, children: [
+            for (final d in expense.details.where((d) => d.bankName.isNotEmpty))
+              Padding(
+                padding: const EdgeInsets.only(bottom: 12),
+                child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+                  Text('${d.bankName}', style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
+                  const SizedBox(height: 4),
+                  Text('${d.bankAccountName}  ${d.bankAccount}', style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ]),
               ),
-            ],
-          ),
-        ),
-        FormFieldRow(
-          label: l10n.get('relatedProject'),
-          value: '' as String? ?? '', // moved to detail
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('budgetSubject'),
-          value: ''.isNotEmpty
-              ? ''
-              : null,
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-      ],
-    );
-  }
-
-  Widget _buildAccountSection(ExpenseModel expense, AppLocalizations l10n) {
-    return FormSection(
-      title: l10n.get('receiptAccount'),
-      children: [
-        FormFieldRow(
-          label: l10n.get('bankName'),
-          value: '' as String? ?? '', // moved to detail
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('accountName'),
-          value: '' as String? ?? '', // moved to detail
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('bankAccount'),
-          value: '' as String? ?? '', // moved to detail
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
+          ]),
+        ],
       ],
     );
   }
 
-  Widget _buildDetailSection(
-    ExpenseModel expense,
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
+  // ═══ 费用明细 — 对应 create 页 detailSection + 数据库 ExpenseDetail ═══
+  Widget _buildExpenseDetailSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     return FormSection(
       title: l10n.get('expenseDetails'),
+      leadingIcon: Icons.receipt_long_outlined,
       children: [
-        // Table header
-        Container(
-          height: 36,
-          padding: const EdgeInsets.symmetric(horizontal: 8),
-          decoration: BoxDecoration(
-            color: colors.bgPage,
-            borderRadius: BorderRadius.circular(4),
-          ),
-          child: Row(
-            children: [
-              Expanded(
-                flex: 3,
-                child: Text(
-                  l10n.get('expenseProject'),
-                  style: TextStyle(
-                    fontSize: AppFontSizes.caption,
-                    fontWeight: FontWeight.w500,
-                    color: colors.textSecondary,
-                  ),
-                ),
-              ),
-              Expanded(
-                flex: 2,
-                child: Text(
-                  l10n.get('amount'),
-                  textAlign: TextAlign.right,
-                  style: TextStyle(
-                    fontSize: AppFontSizes.caption,
-                    fontWeight: FontWeight.w500,
-                    color: colors.textSecondary,
-                  ),
-                ),
-              ),
-            ],
-          ),
-        ),
         if (expense.details.isEmpty)
           Padding(
             padding: const EdgeInsets.symmetric(vertical: 8),
-            child: Text(
-              l10n.get('noDetailData'),
-              style: TextStyle(
-                fontSize: AppFontSizes.body,
-                color: colors.textPlaceholder,
-              ),
-            ),
+            child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
           )
         else
-          ...expense.details.map(
-            (d) => SizedBox(
-              height: 28,
-              child: Row(
-                children: [
-                  Expanded(
-                    flex: 3,
-                    child: Text(
-                      d.purpose,
-                      style: TextStyle(
-                        fontSize: AppFontSizes.body,
-                        color: colors.textPrimary,
-                      ),
-                    ),
-                  ),
+          ...expense.details.asMap().entries.map((e) {
+            final d = e.value;
+            return Container(
+              margin: const EdgeInsets.symmetric(vertical: 8),
+              padding: const EdgeInsets.all(12),
+              decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
+              child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+                Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
                   Expanded(
-                    flex: 2,
-                    child: Text(
-                      '¥${d.totalAmount.toStringAsFixed(2)}',
-                      textAlign: TextAlign.right,
-                      style: TextStyle(
-                        fontSize: AppFontSizes.body,
-                        fontWeight: FontWeight.w500,
-                        color: colors.amountPrimary,
-                      ),
-                    ),
+                    child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
+                        style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
                   ),
+                  const SizedBox(width: 16),
+                  Text('¥${d.totalAmount.toStringAsFixed(2)}',
+                      style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
+                ]),
+                const SizedBox(height: 4),
+                Text('¥${d.amount.toStringAsFixed(2)} + 税${d.taxAmount.toStringAsFixed(2)}${d.bankName.isNotEmpty ? ' | ${d.bankName}' : ''}',
+                    style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                if (d.projectName.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text('${l10n.get('relatedProject')}:${d.projectName}  |  ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                 ],
-              ),
-            ),
-          ),
+                if (d.costDeptName.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text('${l10n.get('costDept')}:${d.costDeptName}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+                if (d.customerVendorName.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text('${l10n.get('customerVendor')}:${d.customerVendorName}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+                if (d.approvedAmount > 0) ...[
+                  const SizedBox(height: 4),
+                  Text('${l10n.get('approvedAmount')}:¥${d.approvedAmount.toStringAsFixed(2)}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.success)),
+                ],
+                if (d.remark.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+              ]),
+            );
+          }),
         if (expense.details.isNotEmpty) ...[
-          Container(height: 1, color: colors.border),
-          Container(
-            height: 36,
-            padding: const EdgeInsets.symmetric(vertical: 8),
-            child: Row(
-              mainAxisAlignment: MainAxisAlignment.spaceBetween,
-              children: [
-                Text(
-                  l10n.get('total'),
-                  style: TextStyle(
-                    fontSize: AppFontSizes.body,
-                    fontWeight: FontWeight.w600,
-                    color: colors.textPrimary,
-                  ),
-                ),
-                Text(
-                  '¥${expense.totalAmount.toStringAsFixed(2)}',
-                  style: TextStyle(
-                    fontSize: AppFontSizes.subtitle,
-                    fontWeight: FontWeight.w700,
-                    color: colors.amountPrimary,
-                  ),
-                ),
-              ],
-            ),
-          ),
+          const SizedBox(height: 8),
+          Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
+            Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
+            Text('¥${expense.totalAmount.toStringAsFixed(2)}',
+                style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
+          ]),
         ],
       ],
     );
   }
 
-  Widget _buildInvoiceSection(
-    ExpenseModel expense,
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
-    final hasInvoices = expense.attachments.isNotEmpty;
-
+  // ═══ 发票附件 ═══
+  Widget _buildInvoiceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     return FormSection(
       title: l10n.get('invoiceAttachment'),
+      leadingIcon: Icons.attach_file_outlined,
       children: [
-        if (!hasInvoices)
-          Padding(
-            padding: const EdgeInsets.symmetric(vertical: 8),
-            child: Text(
-              l10n.get('noInvoice'),
-              style: TextStyle(
-                fontSize: AppFontSizes.body,
-                color: colors.textPlaceholder,
-              ),
-            ),
-          )
+        if (expense.attachments.isEmpty)
+          Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
         else
-          Wrap(
-            spacing: 8,
-            runSpacing: 8,
-            children: expense.attachments.map((url) {
-              return Container(
-                width: 80,
-                height: 80,
-                decoration: BoxDecoration(
-                  color: colors.bgPage,
-                  borderRadius: BorderRadius.circular(4),
-                  border: Border.all(
-                    color: colors.border,
-                    strokeAlign: BorderSide.strokeAlignInside,
-                  ),
-                ),
-                child: Center(
-                  child: Icon(
-                    Icons.image_outlined,
-                    size: 24,
-                    color: colors.textPlaceholder,
-                  ),
-                ),
-              );
-            }).toList(),
-          ),
+          Wrap(spacing: 8, runSpacing: 8, children: expense.attachments.map((url) {
+            return Container(width: 80, height: 80,
+                decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
+                child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
+          }).toList()),
       ],
     );
   }
 
-  Widget _buildComplianceSection(
-    ExpenseModel expense,
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
+  // ═══ 财务合规查验 ═══
+  Widget _buildComplianceSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     final checks = [
-      l10n.get('invoiceCheck1'),
-      l10n.get('invoiceCheck2'),
-      l10n.get('invoiceCheck3'),
-      l10n.get('invoiceCheck4'),
+      (expense.isInvoiceVerified, l10n.get('invoiceCheck1')),
+      (expense.isTaxIdMatched, l10n.get('invoiceCheck2')),
+      (expense.isCategoryCompliant, l10n.get('invoiceCheck3')),
     ];
-
     return FormSection(
       title: l10n.get('invoiceCheck'),
-      children: checks.map((text) {
-        return SizedBox(
-          height: 44,
-          child: Row(
-            children: [
-              Icon(Icons.check_circle, size: 16, color: colors.success),
-              const SizedBox(width: 8),
-              Text(
-                text,
-                style: TextStyle(
-                  fontSize: AppFontSizes.body,
-                  color: colors.textPrimary,
-                ),
-              ),
-            ],
-          ),
+      leadingIcon: Icons.verified_outlined,
+      children: checks.asMap().entries.map((e) {
+        final (passed, text) = e.value;
+        return Padding(
+          padding: EdgeInsets.only(top: e.key > 0 ? 12 : 0),
+          child: SizedBox(height: 24, child: Row(children: [
+            Icon(passed ? Icons.check_circle : Icons.radio_button_unchecked, size: 16, color: passed ? colors.success : colors.textPlaceholder),
+            const SizedBox(width: 8),
+            Text(text, style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textPrimary)),
+          ])),
         );
       }).toList(),
     );
   }
 
-  Widget _buildApprovalSection(ExpenseModel expense, AppLocalizations l10n) {
+  // ═══ 审核流程 — 时间线组件 ═══
+  Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) {
+    final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[
+      (title: l10n.get('approvalStepSubmitted'),       desc: l10n.get('approvalDescSubmitted'),      time: '2026-06-29 09:15', icon: Icons.check_circle,     iconColor: colors.success),
+      (title: l10n.get('approvalStepApproved'),        desc: l10n.get('approvalDescApproved'),       time: '2026-06-29 14:30', icon: Icons.check_circle,     iconColor: colors.success),
+      (title: l10n.get('approvalStepInvoice'),         desc: l10n.get('approvalDescInvoice'),        time: null,              icon: Icons.schedule,        iconColor: colors.warning),
+      (title: l10n.get('approvalStepPayment'),         desc: l10n.get('approvalStepPaymentDesc'),    time: null,              icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder),
+    ];
     return FormSection(
       title: l10n.get('approvalFlow'),
+      leadingIcon: Icons.fact_check_outlined,
       children: [
-        ApprovalTimeline(
-          records: expense.approvalRecords,
-          chain: expense.approvalChain,
-          currentApproverId: expense.currentApproverId,
-        ),
+        ...steps.asMap().entries.map((e) {
+          final s = e.value;
+          final isLast = e.key == steps.length - 1;
+          final isActive = e.key <= 2;
+          final iconColor = isActive ? s.iconColor : colors.textPlaceholder;
+          final textColor = isActive ? colors.textPrimary : colors.textPlaceholder;
+          final subColor = isActive ? colors.textSecondary : colors.textPlaceholder;
+          return IntrinsicHeight(
+            child: Row(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                SizedBox(
+                  width: 24,
+                  child: Column(
+                    children: [
+                      Container(
+                        width: 24, height: 24,
+                        decoration: BoxDecoration(
+                          color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled,
+                          shape: BoxShape.circle,
+                        ),
+                        child: Icon(s.icon, size: 14, color: iconColor),
+                      ),
+                      if (!isLast)
+                        Expanded(
+                          child: Container(
+                            width: 2,
+                            margin: const EdgeInsets.symmetric(vertical: 4),
+                            color: isActive ? s.iconColor.withAlpha(60) : colors.border,
+                          ),
+                        ),
+                    ],
+                  ),
+                ),
+                const SizedBox(width: 12),
+                Expanded(
+                  child: Padding(
+                    padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)),
+                        const SizedBox(height: 4),
+                        Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)),
+                        if (s.time != null) ...[
+                          const SizedBox(height: 2),
+                          Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+                        ],
+                      ],
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          );
+        }),
       ],
     );
   }
 
-  Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n) {
+  // ═══ 财务归档 ═══
+  Widget _buildArchiveSection(ExpenseModel expense, AppLocalizations l10n, AppColorsExtension colors) {
     return FormSection(
       title: l10n.get('financialArchive'),
+      leadingIcon: Icons.archive_outlined,
       children: [
-        FormFieldRow(
-          label: l10n.get('voucherNo'),
-          value: expense.voucherNo.isNotEmpty ? expense.voucherNo : null,
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('archiveDate'),
-          value: du.DateUtils.formatDate(expense.updateTime),
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('archiver'),
-          value: l10n.get('financeDept'),
-          readOnly: true,
-          showArrow: false,
-        ),
+        FormFieldRow(label: l10n.get('voucherNo'), value: expense.voucherNo.isNotEmpty ? expense.voucherNo : '-', readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('bankTransferNo'), value: expense.bankTransferNo.isNotEmpty ? expense.bankTransferNo : '-', readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('paymentStatus'), value: expense.paymentStatus == 'paid' ? l10n.get('paid') : l10n.get('unpaid'), readOnly: true, showArrow: false),
       ],
     );
   }
 
-  Widget _buildBottomBar(
-    BuildContext context,
-    ExpenseModel expense, {
-    required bool isFinance,
-    required bool isAdmin,
-  }) {
+  Widget _buildBottomBar(BuildContext context, ExpenseModel expense, {required bool isFinance, required bool isAdmin}) {
     final l10n = AppLocalizations.of(context);
-    final canWithdraw =
-        expense.status == 'pending' || expense.status == 'draft';
-
-    // 财务角色:已审批 + 未付款 → 显示打款归档按钮
-    if (isFinance &&
-        expense.status == 'approved' &&
-        expense.paymentStatus == 'unpaid') {
+    if (isFinance && expense.status == 'approved' && expense.paymentStatus == 'unpaid') {
       return ActionBar(
-        showLeft: true,
-        leftLabel: l10n.get('confirmPaymentAndArchive'),
-        centerLabel: l10n.get('nextPendingPayment'),
-        rightLabel: l10n.get('confirmPaymentAndArchive'),
+        showLeft: true, leftLabel: l10n.get('confirmPaymentAndArchive'),
+        centerLabel: l10n.get('nextPendingPayment'), rightLabel: l10n.get('confirmPaymentAndArchive'),
         onLeftTap: () {
-          showDialog(
-            context: context,
-            builder: (ctx) => TDAlertDialog(
-              title: l10n.get('confirmPaymentAndArchive'),
-              content: l10n.get('confirmPaymentAndArchiveTip'),
-              leftBtn: TDDialogButtonOptions(
-                title: l10n.get('cancel'),
-                action: () => Navigator.pop(ctx),
-              ),
-              rightBtn: TDDialogButtonOptions(
-                title: l10n.get('confirm'),
-                action: () {
-                  Navigator.pop(ctx);
-                  TDToast.showText(
-                    l10n.get('paymentArchiveSuccess'),
-                    context: context,
-                  );
-                  context.pop();
-                },
-              ),
-            ),
-          );
-        },
-        onCenterTap: () {
-          TDToast.showText(
-            l10n.get('allPaymentsProcessed'),
-            context: context,
-          );
+          showDialog(context: context, builder: (ctx) => TDAlertDialog(
+            title: l10n.get('confirmPaymentAndArchive'), content: l10n.get('confirmPaymentAndArchiveTip'),
+            leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
+            rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('paymentArchiveSuccess'), context: context); context.pop(); }),
+          ));
         },
+        onCenterTap: () => TDToast.showText(l10n.get('allPaymentsProcessed'), context: context),
       );
     }
-
-    // 非 employee/manager 角色不显示撤回按钮
-    if (isFinance || isAdmin) {
-      return const SizedBox.shrink();
-    }
-
-    if (!canWithdraw) {
-      return const SizedBox.shrink();
-    }
-
+    if (isFinance || isAdmin) return const SizedBox.shrink();
+    if (expense.status != 'pending' && expense.status != 'draft') return const SizedBox.shrink();
     return ActionBar(
-      showLeft: false,
-      centerLabel: l10n.get('withdrawApplication'),
-      rightLabel: l10n.get('submitApproval'),
+      showLeft: false, centerLabel: l10n.get('withdrawApplication'), rightLabel: l10n.get('submitApproval'),
       onCenterTap: () {
-        showDialog(
-          context: context,
-          builder: (ctx) => TDAlertDialog(
-            title: l10n.get('withdrawConfirm'),
-            content: l10n.get('withdrawConfirmTip'),
-            leftBtn: TDDialogButtonOptions(
-              title: l10n.get('cancel'),
-              action: () => Navigator.pop(ctx),
-            ),
-            rightBtn: TDDialogButtonOptions(
-              title: l10n.get('confirm'),
-              action: () {
-                Navigator.pop(ctx);
-                TDToast.showText(l10n.get('withdrawn'), context: context);
-                context.pop();
-              },
-            ),
-          ),
-        );
+        showDialog(context: context, builder: (ctx) => TDAlertDialog(
+          title: l10n.get('withdrawConfirm'), content: l10n.get('withdrawConfirmTip'),
+          leftBtn: TDDialogButtonOptions(title: l10n.get('cancel'), action: () => Navigator.pop(ctx)),
+          rightBtn: TDDialogButtonOptions(title: l10n.get('confirm'), action: () { Navigator.pop(ctx); TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); }),
+        ));
       },
       onRightTap: null,
     );

+ 94 - 16
lib/features/expense/expense_list_controller.dart

@@ -1,5 +1,4 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../shared/models/approval_status.dart';
 import 'expense_model.dart';
 
 final expenseStatusFilterProvider = StateProvider<String>((ref) => '');
@@ -11,25 +10,104 @@ final expenseRefreshProvider = StateProvider<int>((ref) => 0);
 final now = DateTime.now();
 final mockExpenses = <ExpenseModel>[
   ExpenseModel(
-    id: 'EXP001', expenseNo: 'BX202605001', expenseDate: now, applicantId: 'U001',
-    applicantName: '张三', deptId: 'D001', deptName: '销售部', currencyCode: 'CNY',
-    totalAmount: 2380.0, approvedAmount: 2380.0, purpose: '上海出差拜访客户',
-    paymentStatus: 'unpaid', status: 'pending', createTime: now, updateTime: now,
-    details: [ExpenseDetailModel(id: 'ED001', expenseId: 'EXP001', expenseCategory: 'transport',
-        purpose: '机票', amount: 1200, totalAmount: 1308, taxAmount: 108, taxRate: 0.09, bankName: '招商银行',
-        bankAccountName: '张三', bankAccount: '6222021234567890', createTime: now, updateTime: now)],
+    id: 'EXP001', expenseNo: 'BX202606001', expenseDate: DateTime(2026, 6, 29),
+    applicantId: 'U001', applicantName: '张三', deptId: 'D001', deptName: '销售部',
+    currencyCode: 'CNY', totalAmount: 2380.0, approvedAmount: 2380.0,
+    purpose: '上海出差拜访客户', paymentMethod: 'bankTransfer',
+    paymentStatus: 'unpaid', status: 'pending', version: 1,
+    isInvoiceVerified: false, isTaxIdMatched: false, isCategoryCompliant: false,
+    createTime: DateTime(2026, 6, 29, 10, 0), updateTime: DateTime(2026, 6, 29, 10, 0),
+    details: [
+      ExpenseDetailModel(id: 'ED001', expenseId: 'EXP001', expenseApplyNo: 'BXSQ-20240501-001',
+          expenseCategory: 'transport', purpose: '往返机票',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550201', acctSubjectName: '差旅费-交通',
+          amount: 1200, taxRate: 0.09, taxAmount: 108, totalAmount: 1308, currencyCode: 'CNY',
+          exchangeRate: 1.0, baseAmount: 1308, approvedAmount: 1308,
+          bankName: '招商银行', bankAccountName: '张三', bankAccount: '6222 0212 3456 7890',
+          remark: '经济舱', createTime: now, updateTime: now),
+      ExpenseDetailModel(id: 'ED002', expenseId: 'EXP001',
+          expenseCategory: 'hotel', purpose: '酒店住宿',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550202', acctSubjectName: '差旅费-住宿',
+          amount: 984, taxRate: 0.09, taxAmount: 88, totalAmount: 1072, currencyCode: 'CNY',
+          exchangeRate: 1.0, baseAmount: 1072, approvedAmount: 1072,
+          bankName: '招商银行', bankAccountName: '张三', bankAccount: '6222 0212 3456 7890',
+          remark: '3晚', createTime: now, updateTime: now),
+    ],
+    attachments: ['发票_机票.pdf', '发票_酒店.pdf'],
+    currentApproverId: 'M001',
   ),
   ExpenseModel(
-    id: 'EXP002', expenseNo: 'BX202605002', expenseDate: now, applicantId: 'U001',
-    applicantName: '张三', deptId: 'D001', deptName: '销售部', currencyCode: 'CNY',
-    totalAmount: 156.0, approvedAmount: 156.0, purpose: '办公用品报销',
-    paymentStatus: 'paid', status: 'approved', createTime: now, updateTime: now,
+    id: 'EXP002', expenseNo: 'BX202606002', expenseDate: DateTime(2026, 6, 25),
+    applicantId: 'U001', applicantName: '张三', deptId: 'D001', deptName: '销售部',
+    currencyCode: 'CNY', totalAmount: 156.0, approvedAmount: 156.0,
+    purpose: '办公用品报销', paymentMethod: 'alipay',
+    paymentStatus: 'paid', status: 'approved', version: 1,
+    isInvoiceVerified: true, isTaxIdMatched: true, isCategoryCompliant: true,
+    voucherNo: 'JZ-202606-025', bankTransferNo: '20260628001',
+    createTime: DateTime(2026, 6, 25, 14, 30), updateTime: DateTime(2026, 6, 28, 9, 0),
+    details: [
+      ExpenseDetailModel(id: 'ED003', expenseId: 'EXP002',
+          expenseCategory: 'office', purpose: 'A4打印纸+墨盒',
+          costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550601', acctSubjectName: '办公费',
+          amount: 156, taxRate: 0.13, taxAmount: 20, totalAmount: 176, currencyCode: 'CNY',
+          exchangeRate: 1.0, baseAmount: 176, approvedAmount: 156,
+          bankName: '支付宝', bankAccountName: '张三', bankAccount: 'zhangsan@alipay',
+          createTime: now, updateTime: now),
+    ],
+    attachments: ['发票_办公用品.pdf'],
   ),
   ExpenseModel(
-    id: 'EXP003', expenseNo: 'BX202605003', expenseDate: now, applicantId: 'U002',
-    applicantName: '李四', deptId: 'D002', deptName: '技术部', currencyCode: 'CNY',
-    totalAmount: 890.0, approvedAmount: 0.0, purpose: '服务器配件采购',
-    paymentStatus: 'unpaid', status: 'rejected', createTime: now, updateTime: now,
+    id: 'EXP003', expenseNo: 'BX202606003', expenseDate: DateTime(2026, 6, 20),
+    applicantId: 'U002', applicantName: '李四', deptId: 'D002', deptName: '技术部',
+    currencyCode: 'CNY', totalAmount: 890.0, approvedAmount: 0.0,
+    purpose: '服务器配件采购', paymentMethod: 'bankTransfer',
+    paymentStatus: 'unpaid', status: 'rejected', version: 1,
+    isInvoiceVerified: false, isTaxIdMatched: false, isCategoryCompliant: false,
+    createTime: DateTime(2026, 6, 20, 9, 0), updateTime: DateTime(2026, 6, 21, 16, 0),
+    details: [
+      ExpenseDetailModel(id: 'ED004', expenseId: 'EXP003',
+          expenseCategory: 'equipment', purpose: 'SSD硬盘×2',
+          projectId: 'P002', projectName: '智能硬件研发', costDeptId: 'D002', costDeptName: '技术部',
+          acctSubjectId: '550301', acctSubjectName: '研发费用-设备',
+          amount: 890, taxRate: 0.13, taxAmount: 116, totalAmount: 1006, currencyCode: 'CNY',
+          exchangeRate: 1.0, baseAmount: 1006, approvedAmount: 0,
+          customerVendorName: 'XX电子科技有限公司',
+          bankName: '工商银行', bankAccountName: '李四', bankAccount: '6212 3456 7890 1234',
+          remark: '已收到货物但审批未通过', createTime: now, updateTime: now),
+    ],
+    attachments: ['报价单.pdf', '入库单.pdf'],
+  ),
+  ExpenseModel(
+    id: 'EXP004', expenseNo: 'BX202606004', expenseDate: DateTime(2026, 6, 28),
+    applicantId: 'U003', applicantName: '王五', deptId: 'D003', deptName: '市场部',
+    currencyCode: 'USD', totalAmount: 820.0, approvedAmount: 800.0,
+    purpose: '海外广告投放素材采购', paymentMethod: 'bankTransfer',
+    paymentStatus: 'unpaid', status: 'approved', version: 1,
+    isInvoiceVerified: true, isTaxIdMatched: true, isCategoryCompliant: false,
+    createTime: DateTime(2026, 6, 28, 11, 0), updateTime: DateTime(2026, 6, 28, 18, 0),
+    details: [
+      ExpenseDetailModel(id: 'ED005', expenseId: 'EXP004',
+          expenseCategory: 'service', purpose: '设计外包',
+          projectId: 'P003', projectName: '品牌推广', costDeptId: 'D003', costDeptName: '市场部',
+          acctSubjectId: '550701', acctSubjectName: '广告费',
+          amount: 800, taxRate: 0, taxAmount: 0, totalAmount: 800,
+          currencyCode: 'USD', exchangeRate: 7.25, baseAmount: 5800, approvedAmount: 800,
+          customerVendorName: 'Global Media Ltd.',
+          bankName: '中国银行', bankAccountName: '王五', bankAccount: '6217 8901 2345 6789',
+          createTime: now, updateTime: now),
+      ExpenseDetailModel(id: 'ED006', expenseId: 'EXP004',
+          expenseCategory: 'office', purpose: '印刷物料',
+          projectId: 'P003', projectName: '品牌推广', costDeptId: 'D003', costDeptName: '市场部',
+          acctSubjectId: '550701', acctSubjectName: '广告费',
+          amount: 20, taxRate: 0, taxAmount: 0, totalAmount: 20,
+          currencyCode: 'USD', exchangeRate: 7.25, baseAmount: 145, approvedAmount: 0,
+          bankName: '中国银行', bankAccountName: '王五', bankAccount: '6217 8901 2345 6789',
+          createTime: now, updateTime: now),
+    ],
+    attachments: ['合同_设计外包.pdf', 'invoice.pdf'],
   ),
 ];
 

+ 6 - 0
lib/features/expense/expense_model.dart

@@ -27,6 +27,7 @@ class ExpenseModel {
   final String previousInstanceIds;
   final DateTime? effectiveDate;
   final String auditorId;
+  final int version;
   final DateTime createTime;
   final DateTime updateTime;
   final bool isDeleted;
@@ -66,6 +67,7 @@ class ExpenseModel {
     this.previousInstanceIds = '',
     this.effectiveDate,
     this.auditorId = '',
+    this.version = 1,
     required this.createTime,
     required this.updateTime,
     this.isDeleted = false,
@@ -107,6 +109,7 @@ class ExpenseModel {
           ? DateTime.parse(json['effectiveDate'] as String)
           : null,
       auditorId: json['auditorId'] as String? ?? '',
+      version: json['version'] as int? ?? 1,
       createTime: DateTime.parse(json['createTime'] as String),
       updateTime: DateTime.parse(json['updateTime'] as String),
       isDeleted: json['isDeleted'] as bool? ?? false,
@@ -162,6 +165,7 @@ class ExpenseModel {
     'previousInstanceIds': previousInstanceIds,
     'effectiveDate': effectiveDate?.toIso8601String(),
     'auditorId': auditorId,
+    'version': version,
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),
     'isDeleted': isDeleted,
@@ -198,6 +202,7 @@ class ExpenseModel {
     String? previousInstanceIds,
     DateTime? effectiveDate,
     String? auditorId,
+    int? version,
     DateTime? createTime,
     DateTime? updateTime,
     bool? isDeleted,
@@ -232,6 +237,7 @@ class ExpenseModel {
       previousInstanceIds: previousInstanceIds ?? this.previousInstanceIds,
       effectiveDate: effectiveDate ?? this.effectiveDate,
       auditorId: auditorId ?? this.auditorId,
+      version: version ?? this.version,
       createTime: createTime ?? this.createTime,
       updateTime: updateTime ?? this.updateTime,
       isDeleted: isDeleted ?? this.isDeleted,

+ 28 - 20
lib/features/expense_apply/expense_apply_api.dart

@@ -12,39 +12,47 @@ class ExpenseApplyApi {
   final ApiClient _client;
   ExpenseApplyApi(this._client);
 
+  /// 费用申请列表(分页)
   Future<PaginatedData<ExpenseApplyModel>> fetchList({
     String status = '',
+    String keyword = '',
+    String startDate = '',
+    String endDate = '',
+    String usr = '',
     int page = 1,
     int size = 20,
   }) async {
     final response = await _client.get<Map<String, dynamic>>(
-      '/expense-apply/list',
-      queryParameters: {'status': status, 'page': page, 'size': size},
-    );
-    final data = response.data!;
-    final list = (data['list'] as List<dynamic>)
-        .map((e) => ExpenseApplyModel.fromJson(e as Map<String, dynamic>))
-        .toList();
-    return PaginatedData(
-      list: list,
-      page: data['page'] as int,
-      size: data['size'] as int,
-      total: data['total'] as int,
+      '/OA/GetExpenseApplies',
+      queryParameters: {
+        'status': status,
+        'keyword': keyword,
+        'startDate': startDate,
+        'endDate': endDate,
+        'usr': usr,
+        'page': page,
+        'size': size,
+      },
     );
+    return PaginatedData.fromJson(response.data!, ExpenseApplyModel.fromJson);
   }
 
-  Future<ExpenseApplyModel> fetchDetail(String id) async {
+  /// 费用申请详情(主表+明细)
+  Future<ExpenseApplyModel> fetchDetail(String billNo) async {
     final response = await _client.get<Map<String, dynamic>>(
-      '/expense-apply/detail/$id',
+      '/OA/GetExpenseApplyDetail',
+      queryParameters: {'billNo': billNo},
     );
     return ExpenseApplyModel.fromJson(response.data!);
   }
 
-  Future<void> submit(ExpenseApplyModel model) async {
-    await _client.post('/expense-apply/submit', data: model.toJson());
-  }
-
-  Future<void> saveDraft(ExpenseApplyModel model) async {
-    await _client.put('/expense-apply/draft', data: model.toJson());
+  /// 提交审批
+  Future<void> submit(Map<String, dynamic> data) async {
+    await _client.post('/OA/BillSave', data: {
+      'erpCategory': 'MasterService',
+      'billId': 'AE',
+      'procId': '',
+      'data': data,
+    });
   }
 }

+ 138 - 85
lib/features/expense_apply/expense_apply_create_page.dart

@@ -4,10 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:go_router/go_router.dart';
 import 'package:tdesign_flutter/tdesign_flutter.dart';
 import '../../core/i18n/app_localizations.dart';
+import '../../core/storage/draft_storage.dart';
 import '../../shared/widgets/action_bar.dart';
 import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/nav_bar_config.dart';
+import '../../shared/widgets/attachment_picker.dart';
 import '../../core/theme/app_colors.dart';
 import '../../core/theme/app_colors_extension.dart';
 import '../../core/constants/enums.dart';
@@ -25,6 +27,8 @@ class ExpenseApplyCreatePage extends ConsumerStatefulWidget {
 
 class _ExpenseApplyCreatePageState
     extends ConsumerState<ExpenseApplyCreatePage> {
+  static const _draftKey = 'expense_apply';
+
   // ── 基本信息 ──
   String _urgency = Urgency.normal.value;
   final _purposeController = TextEditingController();
@@ -40,13 +44,16 @@ class _ExpenseApplyCreatePageState
   int _detailIdCounter = 1;
 
   // ── 附件 ──
-  final List<String> _attachments = [];
+  late final AttachmentPickerController _attachmentController;
 
   @override
   void initState() {
     super.initState();
+    _attachmentController = AttachmentPickerController(maxCount: 9)
+      ..addListener(() => setState(() {}));
     _purposeFocus.addListener(() => _ensureVisible(_purposeFocus));
     _remarkFocus.addListener(() => _ensureVisible(_remarkFocus));
+    WidgetsBinding.instance.addPostFrameCallback((_) => _checkDraft());
   }
 
   void _ensureVisible(FocusNode node) {
@@ -72,6 +79,7 @@ class _ExpenseApplyCreatePageState
     _referenceNoController.dispose();
     _remarkController.dispose();
     _remarkFocus.dispose();
+    _attachmentController.dispose();
     _scrollCtrl.dispose();
     super.dispose();
   }
@@ -132,6 +140,106 @@ class _ExpenseApplyCreatePageState
     );
   }
 
+  // ═══ 草稿持久化 ═══
+
+  Future<void> _checkDraft() async {
+    if (!mounted) return;
+    final has = await DraftStorage.has(_draftKey);
+    if (!has || !mounted) return;
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final yes = await showDialog<bool>(
+      context: context,
+      builder: (ctx) => TDAlertDialog(
+        title: l10n.get('draftFound'),
+        content: l10n.get('draftRestorePrompt'),
+        leftBtn: TDDialogButtonOptions(
+          title: l10n.get('discard'),
+          titleColor: colors.textSecondary,
+          action: () => Navigator.pop(ctx, false),
+        ),
+        rightBtn: TDDialogButtonOptions(
+          title: l10n.get('restore'),
+          titleColor: colors.primary,
+          action: () => Navigator.pop(ctx, true),
+        ),
+      ),
+    );
+    if (yes == true && mounted) {
+      await _restoreDraft();
+    } else {
+      await DraftStorage.delete(_draftKey);
+    }
+  }
+
+  Future<void> _restoreDraft() async {
+    final data = await DraftStorage.load(_draftKey);
+    if (data == null) return;
+    setState(() {
+      _urgency = data['urgency'] as String? ?? Urgency.normal.value;
+      _purposeController.text = data['purpose'] as String? ?? '';
+      _validUntil = data['validUntil'] as String? ?? '';
+      _referenceNoController.text = data['referenceNo'] as String? ?? '';
+      _remarkController.text = data['remark'] as String? ?? '';
+      final attData = data['attachments'] as List<dynamic>?;
+      if (attData != null) {
+        _attachmentController.restoreFromPaths(attData.cast<String>());
+      }
+      _details.clear();
+      final detailList = data['details'] as List<dynamic>?;
+      if (detailList != null) {
+        for (final d in detailList) {
+          final m = d as Map<String, dynamic>;
+          _details.add(_DetailItem(
+            id: m['id'] as int? ?? _detailIdCounter++,
+            category: m['category'] as String? ?? '',
+            categoryName: m['categoryName'] as String? ?? '',
+            acctSubjectId: m['acctSubjectId'] as String? ?? '',
+            acctSubjectName: m['acctSubjectName'] as String? ?? '',
+            purpose: m['purpose'] as String? ?? '',
+            projectId: m['projectId'] as int? ?? 0,
+            projectName: m['projectName'] as String? ?? '',
+            costDeptId: m['costDeptId'] as String? ?? '',
+            costDeptName: m['costDeptName'] as String? ?? '',
+            startDate: m['startDate'] as String? ?? '',
+            endDate: m['endDate'] as String? ?? '',
+            estimatedAmount: (m['estimatedAmount'] as num?)?.toDouble() ?? 0,
+            remark: m['remark'] as String? ?? '',
+          ));
+        }
+      }
+      _detailIdCounter = _details.isEmpty ? 1 : _details.map((d) => d.id).reduce((a, b) => a > b ? a : b) + 1;
+    });
+  }
+
+  Future<void> _saveDraftToStorage() async {
+    final detailList = _details.map((d) => {
+      'id': d.id,
+      'category': d.category,
+      'categoryName': d.categoryName,
+      'acctSubjectId': d.acctSubjectId,
+      'acctSubjectName': d.acctSubjectName,
+      'purpose': d.purpose,
+      'projectId': d.projectId,
+      'projectName': d.projectName,
+      'costDeptId': d.costDeptId,
+      'costDeptName': d.costDeptName,
+      'startDate': d.startDate,
+      'endDate': d.endDate,
+      'estimatedAmount': d.estimatedAmount,
+      'remark': d.remark,
+    }).toList();
+    await DraftStorage.save(_draftKey, {
+      'urgency': _urgency,
+      'purpose': _purposeController.text,
+      'validUntil': _validUntil,
+      'referenceNo': _referenceNoController.text,
+      'remark': _remarkController.text,
+      'attachments': _attachmentController.toPathList(),
+      'details': detailList,
+    });
+  }
+
   // ═══ 1. 基本信息 ═══
   Widget _buildBasicInfo(AppLocalizations l10n) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
@@ -446,6 +554,7 @@ class _ExpenseApplyCreatePageState
   }
 
   // ═══ 3. 附件上传 ═══
+
   Widget _buildAttachmentSection(AppLocalizations l10n) {
     final colors = Theme.of(context).extension<AppColorsExtension>()!;
     return FormSection(
@@ -460,80 +569,18 @@ class _ExpenseApplyCreatePageState
           ),
         ),
         const SizedBox(height: 8),
-        Wrap(
-          spacing: 8,
-          runSpacing: 8,
-          children: [
-            ..._attachments.asMap().entries.map(
-              (e) => Stack(
-                clipBehavior: Clip.none,
-                children: [
-                  Container(
-                    width: 80,
-                    height: 80,
-                    decoration: BoxDecoration(
-                      color: colors.primaryLight,
-                      borderRadius: BorderRadius.circular(4),
-                    ),
-                    child: Center(
-                      child: Icon(Icons.image, color: colors.primary, size: 32),
-                    ),
-                  ),
-                  Positioned(
-                    right: -4,
-                    top: -4,
-                    child: GestureDetector(
-                      onTap: () => setState(() => _attachments.removeAt(e.key)),
-                      child: Container(
-                        width: 20,
-                        height: 20,
-                        decoration: BoxDecoration(
-                          color: colors.danger,
-                          shape: BoxShape.circle,
-                        ),
-                        child: const Icon(
-                          Icons.close,
-                          size: 12,
-                          color: Colors.white,
-                        ),
-                      ),
-                    ),
-                  ),
-                ],
-              ),
-            ),
-            if (_attachments.length < 9)
-              GestureDetector(
-                onTap: () {
-                  final exts = ['.jpg', '.png', '.pdf', '.docx', '.xlsx'];
-                  setState(
-                    () => _attachments.add(
-                      '附件_${DateTime.now().millisecondsSinceEpoch}${exts[_attachments.length % exts.length]}',
-                    ),
-                  );
-                  TDToast.showText(
-                    l10n.get('mockAttachmentAdded'),
-                    context: context,
-                  );
-                },
-                child: Container(
-                  width: 80,
-                  height: 80,
-                  decoration: BoxDecoration(
-                    color: colors.bgPage,
-                    borderRadius: BorderRadius.circular(4),
-                    border: Border.all(color: colors.border, width: 1),
-                  ),
-                  child: Center(
-                    child: Icon(
-                      Icons.add,
-                      size: 24,
-                      color: colors.textPlaceholder,
-                    ),
-                  ),
-                ),
-              ),
+        AttachmentPicker(
+          controller: _attachmentController,
+          maxImageSizeMB: 10,
+          maxFileSizeMB: 20,
+          allowedExtensions: const [
+            'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt',
           ],
+          onFileRejected: (file, reason) {
+            if (context.mounted) {
+              TDToast.showText(reason, context: context);
+            }
+          },
         ),
       ],
     );
@@ -556,21 +603,27 @@ class _ExpenseApplyCreatePageState
               _resetAll,
             )
           : null,
-      onCenterTap: () {
-        TDToast.showSuccess(l10n.get('draftSavedToast'), context: context);
-        context.pop();
+      onCenterTap: () async {
+        await _saveDraftToStorage();
+        if (mounted) {
+          TDToast.showSuccess(l10n.get('draftSavedToast'), context: context);
+          context.pop();
+        }
       },
-      onRightTap: () {
+      onRightTap: () async {
         final err = _validate(l10n);
         if (err.isNotEmpty) {
           TDToast.showText(err.first, context: context);
           return;
         }
-        TDToast.showSuccess(
-          l10n.get('submittedAwaitingApproval'),
-          context: context,
-        );
-        context.pop();
+        await DraftStorage.delete(_draftKey);
+        if (mounted) {
+          TDToast.showSuccess(
+            l10n.get('submittedAwaitingApproval'),
+            context: context,
+          );
+          context.pop();
+        }
       },
     );
   }
@@ -591,7 +644,7 @@ class _ExpenseApplyCreatePageState
     _referenceNoController.clear();
     _remarkController.clear();
     _details.clear();
-    _attachments.clear();
+    _attachmentController.clear();
   });
 
   void _doPop() {
@@ -606,7 +659,7 @@ class _ExpenseApplyCreatePageState
   bool _hasUnsaved() =>
       _purposeController.text.isNotEmpty ||
       _details.isNotEmpty ||
-      _attachments.isNotEmpty ||
+      _attachmentController.files.isNotEmpty ||
       _referenceNoController.text.isNotEmpty ||
       _remarkController.text.isNotEmpty;
 

+ 173 - 199
lib/features/expense_apply/expense_apply_detail_page.dart

@@ -8,7 +8,6 @@ import '../../shared/widgets/form_section.dart';
 import '../../shared/widgets/form_field_row.dart';
 import '../../shared/widgets/status_banner.dart';
 import '../../shared/widgets/action_bar.dart';
-import '../../shared/widgets/approval_timeline.dart';
 import 'expense_apply_model.dart';
 import '../../core/i18n/app_localizations.dart';
 import 'expense_apply_list_controller.dart';
@@ -45,19 +44,16 @@ class ExpenseApplyDetailPage extends ConsumerWidget {
             child: Column(
               children: [
                 _buildStatusBanner(context, app, colors),
-                const SizedBox(height: 4),
+                const SizedBox(height: 8),
                 _buildSubmitTime(context, app, colors),
                 const SizedBox(height: 16),
                 _buildBasicInfoSection(app, l10n, colors),
                 const SizedBox(height: 16),
                 _buildExpenseDetailSection(app, l10n, colors),
                 const SizedBox(height: 16),
-                _buildAttachmentSection(l10n, colors),
-                const SizedBox(height: 16),
-                if (app.approvalRecords.isNotEmpty ||
-                    app.approvalChain.isNotEmpty)
-                  _buildApprovalSection(l10n, app),
+                _buildAttachmentSection(app, l10n, colors),
                 const SizedBox(height: 16),
+                _buildApprovalSection(l10n, colors),
               ],
             ),
           ),
@@ -103,20 +99,15 @@ class ExpenseApplyDetailPage extends ConsumerWidget {
   ) {
     final l10n = AppLocalizations.of(context);
     return Padding(
-      padding: const EdgeInsets.only(left: 4, top: 4),
-      child: Align(
-        alignment: Alignment.centerLeft,
-        child: Text(
-          '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(app.createTime)}',
-          style: TextStyle(
-            fontSize: AppFontSizes.caption,
-            color: colors.textPlaceholder,
-          ),
-        ),
+      padding: const EdgeInsets.only(left: 4),
+      child: Text(
+        '${l10n.get('submitTimeText')}:${du.DateUtils.formatDateTime(app.createTime)}',
+        style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary),
       ),
     );
   }
 
+  // ═══ 基本信息 ═══
   Widget _buildBasicInfoSection(
     ExpenseApplyModel app,
     AppLocalizations l10n,
@@ -125,78 +116,52 @@ class ExpenseApplyDetailPage extends ConsumerWidget {
     String urgencyLabel = switch (app.urgency) {
       'urgent' => l10n.get('urgent'),
       'normal' => l10n.get('normal'),
+      'critical' => l10n.get('critical'),
       _ => app.urgency,
     };
-
+    String usageLabel = switch (app.usageStatus) {
+      'unused' => l10n.get('unused'),
+      'partially_used' => l10n.get('partiallyUsed'),
+      'fully_used' => l10n.get('fullyUsed'),
+      _ => app.usageStatus,
+    };
     return FormSection(
       title: l10n.get('basicInfo'),
+      leadingIcon: Icons.info_outline,
       children: [
-        FormFieldRow(
-          label: l10n.get('applicant'),
-          value: app.applicantName,
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('department'),
-          value: app.deptName,
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('expenseType'),
-          value: app.purpose,
-          readOnly: true,
-          showArrow: false,
-        ),
-        Container(
-          height: 44,
-          padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
-          child: Row(
-            mainAxisAlignment: MainAxisAlignment.spaceBetween,
-            children: [
-              Text(
-                l10n.get('expenseAmount'),
-                style: TextStyle(
-                  fontSize: AppFontSizes.body,
-                  color: colors.textSecondary,
-                ),
-              ),
-              Text(
-                '¥${app.estimatedAmount.toStringAsFixed(2)}',
-                style: TextStyle(
-                  fontSize: AppFontSizes.subtitle,
-                  fontWeight: FontWeight.w700,
-                  color: colors.amountPrimary,
-                ),
-              ),
-            ],
-          ),
-        ),
-        FormFieldRow(
-          label: l10n.get('relatedProject'),
-          value: app.purpose.isNotEmpty ? '' : null,
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('budgetSubject'),
-          value: app.purpose.isNotEmpty ? '' : null,
-          hint: '-',
-          readOnly: true,
-          showArrow: false,
-        ),
-        FormFieldRow(
-          label: l10n.get('emergencyLevel'),
-          value: urgencyLabel,
-          readOnly: true,
-          showArrow: false,
+        FormFieldRow(label: l10n.get('expenseApplyNo'), value: app.expenseApplyNo, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('applicant'), value: app.applicantName, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('department'), value: app.deptName, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('date'), value: du.DateUtils.formatDateTime(app.createTime), readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        SizedBox(
+          height: 24,
+          child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
+            Text(l10n.get('emergencyLevel'), style: TextStyle(fontSize: AppFontSizes.subtitle, color: colors.textSecondary)),
+            Text(urgencyLabel, style: TextStyle(
+              fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600,
+              color: app.urgency == 'urgent' || app.urgency == 'critical' ? colors.danger : colors.textPrimary,
+            )),
+          ]),
         ),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('feeReason'), value: app.purpose, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('validUntil'), value: app.validUntil != null ? du.DateUtils.formatDate(app.validUntil!) : '-', readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('relatedContractNo'), value: app.referenceNo.isNotEmpty ? app.referenceNo : '-', readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('usageStatus'), value: usageLabel, readOnly: true, showArrow: false),
+        const SizedBox(height: 16),
+        FormFieldRow(label: l10n.get('remark'), value: app.remark.isNotEmpty ? app.remark : '-', readOnly: true, showArrow: false),
       ],
     );
   }
 
+  // ═══ 费用明细 — 对应 create 页 _buildDetailsSection ═══
   Widget _buildExpenseDetailSection(
     ExpenseApplyModel app,
     AppLocalizations l10n,
@@ -204,158 +169,167 @@ class ExpenseApplyDetailPage extends ConsumerWidget {
   ) {
     return FormSection(
       title: l10n.get('expenseDetails'),
+      leadingIcon: Icons.receipt_long_outlined,
       children: [
-        // Table header
-        Container(
-          height: 36,
-          padding: const EdgeInsets.symmetric(horizontal: 8),
-          decoration: BoxDecoration(
-            color: colors.bgPage,
-            borderRadius: BorderRadius.circular(4),
-          ),
-          child: Row(
-            children: [
-              Expanded(
-                flex: 3,
-                child: Text(
-                  l10n.get('expenseProject'),
-                  style: TextStyle(
-                    fontSize: AppFontSizes.caption,
-                    fontWeight: FontWeight.w500,
-                    color: colors.textSecondary,
-                  ),
-                ),
-              ),
-              Expanded(
-                flex: 2,
-                child: Text(
-                  l10n.get('amount'),
-                  textAlign: TextAlign.right,
-                  style: TextStyle(
-                    fontSize: AppFontSizes.caption,
-                    fontWeight: FontWeight.w500,
-                    color: colors.textSecondary,
-                  ),
-                ),
-              ),
-            ],
-          ),
-        ),
         if (app.details.isEmpty)
           Padding(
-            padding: EdgeInsets.symmetric(vertical: 8),
-            child: Text(
-              l10n.get('noDetailData'),
-              style: TextStyle(
-                fontSize: AppFontSizes.body,
-                color: colors.textPlaceholder,
-              ),
-            ),
+            padding: const EdgeInsets.symmetric(vertical: 8),
+            child: Text(l10n.get('noDetailData'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder)),
           )
         else
-          ...app.details.map(
-            (d) => SizedBox(
-              height: 28,
-              child: Row(
-                children: [
-                  Expanded(
-                    flex: 3,
-                    child: Text(
-                      d.purpose,
-                      style: TextStyle(
-                        fontSize: AppFontSizes.body,
-                        color: colors.textPrimary,
-                      ),
-                    ),
-                  ),
+          ...app.details.asMap().entries.map((e) {
+            final d = e.value;
+            return Container(
+              margin: const EdgeInsets.symmetric(vertical: 8),
+              padding: const EdgeInsets.all(12),
+              decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(8)),
+              child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
+                Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
                   Expanded(
-                    flex: 2,
-                    child: Text(
-                      '¥${d.estimatedAmount.toStringAsFixed(2)}',
-                      textAlign: TextAlign.right,
-                      style: TextStyle(
-                        fontSize: AppFontSizes.body,
-                        fontWeight: FontWeight.w500,
-                        color: colors.amountPrimary,
-                      ),
-                    ),
+                    child: Text(d.purpose.isNotEmpty ? d.purpose : d.expenseCategory,
+                        style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.textPrimary)),
                   ),
+                  const SizedBox(width: 16),
+                  Text('¥${d.estimatedAmount.toStringAsFixed(2)}',
+                      style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: colors.amountPrimary)),
+                ]),
+                if (d.expenseCategory.isNotEmpty && d.purpose != d.expenseCategory) ...[
+                  const SizedBox(height: 4),
+                  Text(d.expenseCategory, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
                 ],
-              ),
-            ),
-          ),
+                if (d.projectName.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text('${l10n.get('relatedProject')}:${d.projectName}  |  ${l10n.get('budgetSubject')}:${d.acctSubjectName}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+                if (d.costDeptName.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text('${l10n.get('costDept')}:${d.costDeptName}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+                if (d.estimatedStartDate != null) ...[
+                  const SizedBox(height: 4),
+                  Text('${du.DateUtils.formatDate(d.estimatedStartDate!)} ~ ${d.estimatedEndDate != null ? du.DateUtils.formatDate(d.estimatedEndDate!) : ''}',
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+                if (d.remark.isNotEmpty) ...[
+                  const SizedBox(height: 4),
+                  Text(d.remark, maxLines: 2, overflow: TextOverflow.ellipsis,
+                      style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textSecondary)),
+                ],
+              ]),
+            );
+          }),
+        if (app.details.isNotEmpty) ...[
+          const SizedBox(height: 8),
+          Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
+            Text(l10n.get('total'), style: TextStyle(fontSize: AppFontSizes.body, fontWeight: FontWeight.w600, color: colors.textPrimary)),
+            Text('¥${app.estimatedAmount.toStringAsFixed(2)}',
+                style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w700, color: colors.amountPrimary)),
+          ]),
+        ],
       ],
     );
   }
 
-  Widget _buildAttachmentSection(
-    AppLocalizations l10n,
-    AppColorsExtension colors,
-  ) {
+  // ═══ 附件 ═══
+  Widget _buildAttachmentSection(ExpenseApplyModel app, AppLocalizations l10n, AppColorsExtension colors) {
     return FormSection(
       title: l10n.get('attachments'),
+      leadingIcon: Icons.attach_file_outlined,
       children: [
-        Wrap(
-          spacing: 8,
-          runSpacing: 8,
-          children: List.generate(3, (i) {
-            return Container(
-              width: 80,
-              height: 80,
-              decoration: BoxDecoration(
-                color: colors.bgPage,
-                borderRadius: BorderRadius.circular(4),
-                border: Border.all(
-                  color: colors.border,
-                  strokeAlign: BorderSide.strokeAlignInside,
-                ),
-              ),
-              child: Center(
-                child: Icon(
-                  Icons.image_outlined,
-                  size: 24,
-                  color: colors.textPlaceholder,
-                ),
-              ),
-            );
-          }),
-        ),
+        if (app.attachments.isEmpty)
+          Text(l10n.get('noAttachment'), style: TextStyle(fontSize: AppFontSizes.body, color: colors.textPlaceholder))
+        else
+          Wrap(spacing: 8, runSpacing: 8, children: app.attachments.map((a) {
+            return Container(width: 80, height: 80,
+                decoration: BoxDecoration(color: colors.bgPage, borderRadius: BorderRadius.circular(4), border: Border.all(color: colors.border)),
+                child: Center(child: Icon(Icons.image_outlined, size: 24, color: colors.textPlaceholder)));
+          }).toList()),
       ],
     );
   }
 
-  Widget _buildApprovalSection(
-    AppLocalizations l10n,
-    ExpenseApplyModel app,
-  ) {
+  // ═══ 审核流程 — 时间线组件 ═══
+  Widget _buildApprovalSection(AppLocalizations l10n, AppColorsExtension colors) {
+    final steps = <({String title, String desc, String? time, IconData icon, Color iconColor})>[
+      (title: l10n.get('approvalStepSubmitted'),       desc: l10n.get('approvalDescSubmitted'),      time: '2026-06-29 09:15', icon: Icons.check_circle,     iconColor: colors.success),
+      (title: l10n.get('approvalStepApproved'),        desc: l10n.get('approvalDescApproved'),       time: '2026-06-29 14:30', icon: Icons.check_circle,     iconColor: colors.success),
+      (title: l10n.get('approvalStepFinanceReview'),   desc: l10n.get('approvalDescFinanceReview'),  time: null,              icon: Icons.schedule,        iconColor: colors.warning),
+      (title: l10n.get('approvalStepArchive'),         desc: l10n.get('approvalStepArchiveDesc'),    time: null,              icon: Icons.hourglass_empty, iconColor: colors.textPlaceholder),
+    ];
     return FormSection(
       title: l10n.get('approvalFlow'),
+      leadingIcon: Icons.fact_check_outlined,
       children: [
-        ApprovalTimeline(
-          records: app.approvalRecords,
-          chain: app.approvalChain,
-          currentApproverId: app.currentApproverId,
-        ),
+        ...steps.asMap().entries.map((e) {
+          final s = e.value;
+          final isLast = e.key == steps.length - 1;
+          final isActive = e.key <= 2;
+          final iconColor = isActive ? s.iconColor : colors.textPlaceholder;
+          final textColor = isActive ? colors.textPrimary : colors.textPlaceholder;
+          final subColor = isActive ? colors.textSecondary : colors.textPlaceholder;
+          return IntrinsicHeight(
+            child: Row(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                SizedBox(
+                  width: 24,
+                  child: Column(
+                    children: [
+                      Container(
+                        width: 24, height: 24,
+                        decoration: BoxDecoration(
+                          color: isActive ? s.iconColor.withAlpha(30) : colors.bgDisabled,
+                          shape: BoxShape.circle,
+                        ),
+                        child: Icon(s.icon, size: 14, color: iconColor),
+                      ),
+                      if (!isLast)
+                        Expanded(
+                          child: Container(
+                            width: 2,
+                            margin: const EdgeInsets.symmetric(vertical: 4),
+                            color: isActive ? s.iconColor.withAlpha(60) : colors.border,
+                          ),
+                        ),
+                    ],
+                  ),
+                ),
+                const SizedBox(width: 12),
+                Expanded(
+                  child: Padding(
+                    padding: EdgeInsets.only(bottom: isLast ? 0 : 16),
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.start,
+                      children: [
+                        Text(s.title, style: TextStyle(fontSize: AppFontSizes.subtitle, fontWeight: FontWeight.w600, color: textColor)),
+                        const SizedBox(height: 4),
+                        Text(s.desc, style: TextStyle(fontSize: AppFontSizes.caption, color: subColor)),
+                        if (s.time != null) ...[
+                          const SizedBox(height: 2),
+                          Text(s.time!, style: TextStyle(fontSize: AppFontSizes.caption, color: colors.textPlaceholder)),
+                        ],
+                      ],
+                    ),
+                  ),
+                ),
+              ],
+            ),
+          );
+        }),
       ],
     );
   }
 
   Widget _buildBottomBar(BuildContext context, ExpenseApplyModel app) {
     final l10n = AppLocalizations.of(context);
-    final canWithdraw = app.status == 'pending' || app.status == 'draft';
-
-    if (!canWithdraw) {
-      return const SizedBox.shrink();
-    }
-
+    if (app.status != 'pending' && app.status != 'draft') return const SizedBox.shrink();
     return ActionBar(
       showLeft: false,
       centerLabel: l10n.get('withdrawApplication'),
       rightLabel: l10n.get('submitApproval'),
-      onCenterTap: () {
-        TDToast.showText(l10n.get('withdrawn'), context: context);
-        context.pop();
-      },
+      onCenterTap: () { TDToast.showText(l10n.get('withdrawn'), context: context); context.pop(); },
       onRightTap: null,
     );
   }

+ 80 - 11
lib/features/expense_apply/expense_apply_list_controller.dart

@@ -1,5 +1,4 @@
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import '../../shared/models/approval_status.dart';
 import 'expense_apply_model.dart';
 
 final expenseApplyStatusFilterProvider = StateProvider<String>((ref) => '');
@@ -16,43 +15,113 @@ final mockExpenseApplies = <ExpenseApplyModel>[
   ExpenseApplyModel(
     id: 'EA001', expenseApplyNo: 'BXSQ-20240501-001', applicantId: 'U001',
     applicantName: '张三', deptId: 'D001', deptName: '销售部', estimatedAmount: 3500.0,
-    purpose: '上海出差拜访客户', status: 'pending', urgency: 'normal',
+    purpose: '上海出差拜访客户', status: 'pending', urgency: 'normal', version: 1,
+    validUntil: DateTime(2024, 6, 30), usageStatus: 'unused',
     createTime: DateTime(2024, 5, 1, 14, 30), updateTime: DateTime(2024, 5, 1, 14, 30),
+    details: [
+      ExpenseApplyDetailModel(id: 'd1-1', expenseCategory: 'transport', purpose: '往返机票',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550201', acctSubjectName: '差旅费-交通', estimatedAmount: 2000.0,
+          estimatedStartDate: DateTime(2024, 5, 10), estimatedEndDate: DateTime(2024, 5, 12),
+          remark: '经济舱往返', createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd1-2', expenseCategory: 'hotel', purpose: '酒店住宿',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550202', acctSubjectName: '差旅费-住宿', estimatedAmount: 1500.0,
+          estimatedStartDate: DateTime(2024, 5, 10), estimatedEndDate: DateTime(2024, 5, 12),
+          remark: '3晚', createTime: now, updateTime: now),
+    ],
+    attachments: ['发票_机票.pdf', '发票_酒店.pdf'],
   ),
   ExpenseApplyModel(
     id: 'EA002', expenseApplyNo: 'BXSQ-20240428-002', applicantId: 'U002',
     applicantName: '李四', deptId: 'D002', deptName: '技术部', estimatedAmount: 1560.0,
-    purpose: '采购开发板及配件', status: 'approved', urgency: 'urgent',
+    purpose: '采购开发板及配件', status: 'approved', urgency: 'urgent', version: 1,
+    validUntil: DateTime(2024, 5, 31), usageStatus: 'partially_used',
     createTime: DateTime(2024, 4, 28, 9, 15), updateTime: DateTime(2024, 4, 29, 10, 0),
+    details: [
+      ExpenseApplyDetailModel(id: 'd2-1', expenseCategory: 'equipment', purpose: 'ARM开发板×2',
+          projectId: 'P002', projectName: '智能硬件研发', costDeptId: 'D002', costDeptName: '技术部',
+          acctSubjectId: '550301', acctSubjectName: '研发费用-设备', estimatedAmount: 1200.0,
+          remark: '树莓派替代方案', createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd2-2', expenseCategory: 'equipment', purpose: '传感器模块',
+          projectId: 'P002', projectName: '智能硬件研发', costDeptId: 'D002', costDeptName: '技术部',
+          acctSubjectId: '550301', acctSubjectName: '研发费用-设备', estimatedAmount: 360.0,
+          createTime: now, updateTime: now),
+    ],
+    attachments: ['采购清单.pdf'],
   ),
   ExpenseApplyModel(
     id: 'EA003', expenseApplyNo: 'BXSQ-20240425-003', applicantId: 'U003',
     applicantName: '王五', deptId: 'D003', deptName: '市场部', estimatedAmount: 5000.0,
-    purpose: '客户答谢晚宴', status: 'rejected', urgency: 'normal',
+    purpose: '客户答谢晚宴', status: 'rejected', urgency: 'normal', version: 1,
+    referenceNo: 'HT-20240420-001', usageStatus: 'unused',
     createTime: DateTime(2024, 4, 25, 16, 0), updateTime: DateTime(2024, 4, 26, 11, 0),
+    details: [
+      ExpenseApplyDetailModel(id: 'd3-1', expenseCategory: 'meals', purpose: '晚宴餐费',
+          projectId: 'P003', projectName: '品牌推广', costDeptId: 'D003', costDeptName: '市场部',
+          acctSubjectId: '550401', acctSubjectName: '业务招待费', estimatedAmount: 4000.0,
+          estimatedStartDate: DateTime(2024, 4, 30), estimatedEndDate: DateTime(2024, 4, 30),
+          createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd3-2', expenseCategory: 'gift', purpose: '伴手礼',
+          projectId: 'P003', projectName: '品牌推广', costDeptId: 'D003', costDeptName: '市场部',
+          acctSubjectId: '550401', acctSubjectName: '业务招待费', estimatedAmount: 1000.0,
+          createTime: now, updateTime: now),
+    ],
+    attachments: ['餐厅预订确认.png', '礼品清单.xlsx'],
   ),
   ExpenseApplyModel(
     id: 'EA004', expenseApplyNo: 'BXSQ-20240601-004', applicantId: 'U001',
     applicantName: '张三', deptId: 'D001', deptName: '销售部', estimatedAmount: 12000.0,
-    purpose: '年度销售总结大会', status: 'draft', urgency: 'normal',
+    purpose: '年度销售总结大会', status: 'draft', urgency: 'normal', version: 1,
+    validUntil: DateTime(2024, 7, 31), remark: '需要提前一周预订场地',
     createTime: DateTime(2024, 6, 1, 9, 0), updateTime: DateTime(2024, 6, 1, 9, 0),
     details: [
-      ExpenseApplyDetailModel(id: 'det-ea004-1', expenseCategory: 'service', purpose: '场地租赁',
-          estimatedAmount: 5000.0, createTime: now, updateTime: now),
-      ExpenseApplyDetailModel(id: 'det-ea004-2', expenseCategory: 'meals', purpose: '茶歇',
-          estimatedAmount: 3000.0, createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd4-1', expenseCategory: 'service', purpose: '场地租赁',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550501', acctSubjectName: '会务费', estimatedAmount: 5000.0,
+          estimatedStartDate: DateTime(2024, 7, 15), estimatedEndDate: DateTime(2024, 7, 16),
+          createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd4-2', expenseCategory: 'meals', purpose: '茶歇及午餐',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550501', acctSubjectName: '会务费', estimatedAmount: 3000.0,
+          createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd4-3', expenseCategory: 'office', purpose: '打印物料',
+          projectId: 'P001', projectName: '华东销售项目', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550601', acctSubjectName: '办公费', estimatedAmount: 4000.0,
+          remark: '会议手册+展架', createTime: now, updateTime: now),
     ],
+    attachments: ['场地合同.pdf', '报价单.xlsx', '设计稿.png'],
   ),
   ExpenseApplyModel(
     id: 'EA005', expenseApplyNo: 'BXSQ-20240602-005', applicantId: 'U001',
     applicantName: '张三', deptId: 'D001', deptName: '销售部', estimatedAmount: 8000.0,
-    purpose: '广州出差并拜访重要客户', status: 'pending', urgency: 'urgent',
+    purpose: '广州出差并拜访重要客户', status: 'pending', urgency: 'urgent', version: 1,
+    validUntil: DateTime(2024, 7, 15), usageStatus: 'unused',
     createTime: DateTime(2024, 6, 2, 11, 0), updateTime: DateTime(2024, 6, 2, 11, 0),
+    currentApproverId: 'M001',
+    details: [
+      ExpenseApplyDetailModel(id: 'd5-1', expenseCategory: 'transport', purpose: '往返高铁',
+          projectId: 'P004', projectName: '华南市场拓展', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550201', acctSubjectName: '差旅费-交通', estimatedAmount: 1800.0,
+          estimatedStartDate: DateTime(2024, 6, 15), estimatedEndDate: DateTime(2024, 6, 18),
+          createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd5-2', expenseCategory: 'hotel', purpose: '酒店住宿',
+          projectId: 'P004', projectName: '华南市场拓展', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550202', acctSubjectName: '差旅费-住宿', estimatedAmount: 2400.0,
+          estimatedStartDate: DateTime(2024, 6, 15), estimatedEndDate: DateTime(2024, 6, 18),
+          createTime: now, updateTime: now),
+      ExpenseApplyDetailModel(id: 'd5-3', expenseCategory: 'meals', purpose: '客户商务宴请',
+          projectId: 'P004', projectName: '华南市场拓展', costDeptId: 'D001', costDeptName: '销售部',
+          acctSubjectId: '550401', acctSubjectName: '业务招待费', estimatedAmount: 3800.0,
+          estimatedStartDate: DateTime(2024, 6, 16), estimatedEndDate: DateTime(2024, 6, 16),
+          remark: '预计6人', createTime: now, updateTime: now),
+    ],
+    attachments: ['行程单.pdf'],
   ),
   ExpenseApplyModel(
     id: 'EA006', expenseApplyNo: 'BXSQ-20240610-006', applicantId: 'U001',
     applicantName: '张三', deptId: 'D001', deptName: '销售部', estimatedAmount: 1500.0,
-    purpose: '部门办公设备采购(已撤回)', status: 'withdrawn', urgency: 'normal',
+    purpose: '部门办公设备采购(已撤回)', status: 'withdrawn', urgency: 'normal', version: 1,
     createTime: DateTime(2024, 6, 10, 9, 0), updateTime: DateTime(2024, 6, 11, 10, 0),
   ),
 ];

+ 6 - 0
lib/features/expense_apply/expense_apply_model.dart

@@ -21,6 +21,7 @@ class ExpenseApplyModel {
   final String referenceNo;
   final String approvalInstanceId;
   final String previousInstanceIds;
+  final int version;
   final DateTime createTime;
   final DateTime updateTime;
   final bool isDeleted;
@@ -54,6 +55,7 @@ class ExpenseApplyModel {
     this.referenceNo = '',
     this.approvalInstanceId = '',
     this.previousInstanceIds = '',
+    this.version = 1,
     required this.createTime,
     required this.updateTime,
     this.isDeleted = false,
@@ -91,6 +93,7 @@ class ExpenseApplyModel {
       referenceNo: json['referenceNo'] as String? ?? '',
       approvalInstanceId: json['approvalInstanceId'] as String? ?? '',
       previousInstanceIds: json['previousInstanceIds'] as String? ?? '',
+      version: json['version'] as int? ?? 1,
       createTime: DateTime.parse(json['createTime'] as String),
       updateTime: DateTime.parse(json['updateTime'] as String),
       isDeleted: json['isDeleted'] as bool? ?? false,
@@ -141,6 +144,7 @@ class ExpenseApplyModel {
     'referenceNo': referenceNo,
     'approvalInstanceId': approvalInstanceId,
     'previousInstanceIds': previousInstanceIds,
+    'version': version,
     'createTime': createTime.toIso8601String(),
     'updateTime': updateTime.toIso8601String(),
     'isDeleted': isDeleted,
@@ -171,6 +175,7 @@ class ExpenseApplyModel {
     String? referenceNo,
     String? approvalInstanceId,
     String? previousInstanceIds,
+    int? version,
     DateTime? createTime,
     DateTime? updateTime,
     bool? isDeleted,
@@ -200,6 +205,7 @@ class ExpenseApplyModel {
       referenceNo: referenceNo ?? this.referenceNo,
       approvalInstanceId: approvalInstanceId ?? this.approvalInstanceId,
       previousInstanceIds: previousInstanceIds ?? this.previousInstanceIds,
+      version: version ?? this.version,
       createTime: createTime ?? this.createTime,
       updateTime: updateTime ?? this.updateTime,
       isDeleted: isDeleted ?? this.isDeleted,

+ 3 - 3
lib/features/expense_apply/widgets/expense_apply_detail_dialog.dart

@@ -388,7 +388,7 @@ class _ExpenseApplyDetailDialogState
                     GestureDetector(
                       onTap: onClear,
                       child: Padding(
-                        padding: const EdgeInsets.only(left: 4),
+                        padding: const EdgeInsets.only(left: 8),
                         child: Icon(
                           Icons.cancel,
                           size: 18,
@@ -603,7 +603,7 @@ class _ExpenseApplyDetailDialogState
                     GestureDetector(
                       onTap: onClear,
                       child: Padding(
-                        padding: const EdgeInsets.only(left: 4),
+                        padding: const EdgeInsets.only(left: 8),
                         child: Icon(
                           Icons.cancel,
                           size: 18,
@@ -725,7 +725,7 @@ class _ExpenseApplyDetailDialogState
                 setState(() {});
               },
               child: Padding(
-                padding: const EdgeInsets.only(left: 4),
+                padding: const EdgeInsets.only(left: 8),
                 child: Icon(
                   Icons.cancel,
                   size: 18,

+ 16 - 1
lib/main.dart

@@ -1,8 +1,23 @@
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'app.dart';
+import 'core/navigation/host_app_channel.dart';
 
-void main() {
+Future<void> main() async {
   WidgetsFlutterBinding.ensureInitialized();
+
+  // 从宿主 App 获取 baseUrl / sn / LoginId
+  await HostAppChannel.initialize();
+
+  // 立即设置状态栏样式,覆盖 Android 原生残留颜色
+  SystemChrome.setSystemUIOverlayStyle(
+    const SystemUiOverlayStyle(
+      statusBarColor: Colors.transparent,
+      statusBarIconBrightness: Brightness.dark,
+      statusBarBrightness: Brightness.light,
+    ),
+  );
+
   runApp(const ProviderScope(child: App()));
 }

+ 126 - 0
lib/shared/models/attachment_file.dart

@@ -0,0 +1,126 @@
+import 'dart:io';
+
+import 'package:dio/dio.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:image_picker/image_picker.dart';
+
+class AttachmentFile {
+  final String path;
+  final String name;
+  final int sizeBytes;
+  final String extension;
+  final String mimeType;
+
+  const AttachmentFile({
+    required this.path,
+    required this.name,
+    required this.sizeBytes,
+    required this.extension,
+    required this.mimeType,
+  });
+
+  /// 根据扩展名判断是否为图片
+  bool get isImage {
+    const imageExts = {'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'heic'};
+    return imageExts.contains(extension);
+  }
+
+  /// 从本地路径创建(读取文件大小)
+  static Future<AttachmentFile> fromPath(String path) async {
+    final name = _extractName(path);
+    final ext = _extractExt(name);
+    final size = await File(path).length();
+    return AttachmentFile(
+      path: path,
+      name: name,
+      sizeBytes: size,
+      extension: ext,
+      mimeType: _inferMimeType(ext),
+    );
+  }
+
+  /// 从 FilePicker 的 PlatformFile 创建
+  factory AttachmentFile.fromPlatformFile(PlatformFile file) {
+    final name = file.name;
+    final ext = _extractExt(name);
+    return AttachmentFile(
+      path: file.path ?? '',
+      name: name,
+      sizeBytes: file.size,
+      extension: ext,
+      mimeType: _inferMimeType(ext),
+    );
+  }
+
+  /// 从 ImagePicker 的 XFile 创建
+  static Future<AttachmentFile> fromXFile(XFile xfile) async {
+    final name = _extractName(xfile.path);
+    final ext = _extractExt(name);
+    final size = await xfile.length();
+    return AttachmentFile(
+      path: xfile.path,
+      name: name,
+      sizeBytes: size,
+      extension: ext,
+      mimeType: _inferMimeType(ext),
+    );
+  }
+
+  /// 转换为 Dio MultipartFile,可直接用于 FormData 上传
+  Future<MultipartFile> toMultipartFile({String fieldName = 'files'}) {
+    return MultipartFile.fromFile(
+      path,
+      filename: name,
+      contentType: DioMediaType.parse(mimeType),
+    );
+  }
+
+  /// 文件大小(MB)
+  double get sizeMB => sizeBytes / (1024 * 1024);
+
+  // ── 私有工具方法 ──
+
+  static String _extractName(String path) {
+    return path.split('/').last.split('\\').last;
+  }
+
+  static String _extractExt(String name) {
+    return name.split('.').last.toLowerCase();
+  }
+
+  static String _inferMimeType(String ext) {
+    switch (ext) {
+      case 'jpg':
+      case 'jpeg':
+        return 'image/jpeg';
+      case 'png':
+        return 'image/png';
+      case 'gif':
+        return 'image/gif';
+      case 'bmp':
+        return 'image/bmp';
+      case 'webp':
+        return 'image/webp';
+      case 'heic':
+        return 'image/heic';
+      case 'pdf':
+        return 'application/pdf';
+      case 'doc':
+        return 'application/msword';
+      case 'docx':
+        return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
+      case 'xls':
+        return 'application/vnd.ms-excel';
+      case 'xlsx':
+        return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
+      case 'ppt':
+        return 'application/vnd.ms-powerpoint';
+      case 'pptx':
+        return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
+      case 'txt':
+        return 'text/plain';
+      default:
+        return 'application/octet-stream';
+    }
+  }
+}

+ 30 - 6
lib/shared/models/pagination_model.dart

@@ -3,10 +3,34 @@ class PaginatedData<T> {
   final int page;
   final int size;
   final int total;
-  const PaginatedData(
-      {required this.list,
-      required this.page,
-      required this.size,
-      required this.total});
-  bool get hasMore => page * size < total;
+  final int totalPages;
+  final bool hasNext;
+  final bool hasPrevious;
+
+  const PaginatedData({
+    required this.list,
+    required this.page,
+    required this.size,
+    required this.total,
+    this.totalPages = 0,
+    this.hasNext = false,
+    this.hasPrevious = false,
+  });
+
+  factory PaginatedData.fromJson(
+    Map<String, dynamic> json,
+    T Function(Map<String, dynamic>) fromJsonT,
+  ) {
+    return PaginatedData(
+      list: (json['list'] as List<dynamic>)
+          .map((e) => fromJsonT(e as Map<String, dynamic>))
+          .toList(),
+      page: json['page'] as int,
+      size: json['size'] as int,
+      total: json['total'] as int,
+      totalPages: json['totalPages'] as int? ?? 0,
+      hasNext: json['hasNext'] as bool? ?? false,
+      hasPrevious: json['hasPrevious'] as bool? ?? false,
+    );
+  }
 }

+ 66 - 9
lib/shared/widgets/app_scaffold.dart

@@ -27,6 +27,71 @@ class AppScaffold extends ConsumerWidget {
     return location == '/' || location == '/messages' || location == '/profile';
   }
 
+  NavBarConfig _pageConfig(String location, AppLocalizations l10n, WidgetRef ref) {
+    // 从 provider 读取页面自定义属性(rightWidget 等),标题由路由决定
+    final custom = ref.watch(navBarConfigProvider);
+    final title = _titleForRoute(location, l10n);
+    if (title == null) return custom;
+    return NavBarConfig(
+      title: title,
+      showBack: custom.showBack,
+      showRight: custom.showRight,
+      rightWidget: custom.rightWidget,
+      leadingIcon: custom.leadingIcon,
+      onBack: custom.onBack,
+    );
+  }
+
+  /// 路由 → 标题映射,新增路由只需在此添加一行
+  String? _titleForRoute(String location, AppLocalizations l10n) {
+    final path = location.split('?').first;
+    if (path == '/') return l10n.get('appName');
+    if (path == '/messages') return l10n.get('tabMessages');
+    if (path == '/profile') return l10n.get('tabProfile');
+
+    // ── 费用 ──
+    if (path.startsWith('/expense/list')) return l10n.get('expenseList');
+    if (path.startsWith('/expense/detail')) return l10n.get('expenseDetail');
+    if (path.startsWith('/expense/create') || path.startsWith('/expense/edit')) return l10n.get('expenseApply');
+
+    // ── 费用申请 ──
+    if (path.startsWith('/expense-apply/list')) return l10n.get('expenseApplyList');
+    if (path.startsWith('/expense-apply/detail')) return l10n.get('expenseApplyDetail');
+    if (path.startsWith('/expense-apply/create')) return l10n.get('expenseApplyRequest');
+
+    // ── 加班 ──
+    if (path.startsWith('/overtime/list')) return l10n.get('overtimeList');
+    if (path.startsWith('/overtime/detail')) return l10n.get('overtimeDetail');
+    if (path.startsWith('/overtime/create')) return l10n.get('overtimeRequest');
+
+    // ── 用车 ──
+    if (path.startsWith('/vehicle/list')) return l10n.get('vehicleList');
+    if (path.startsWith('/vehicle/detail')) return l10n.get('vehicleDetail');
+    if (path.startsWith('/vehicle/create')) return l10n.get('vehicleRequest');
+
+    // ── 外勤日志 ──
+    if (path.startsWith('/outing-log/list')) return l10n.get('outingLogList');
+    if (path.startsWith('/outing-log/detail')) return l10n.get('outingLogDetail');
+    if (path.startsWith('/outing-log/create')) return l10n.get('outingLogCreate');
+
+    // ── 公告 ──
+    if (path.startsWith('/announcement/list')) return l10n.get('announcementList');
+    if (path.startsWith('/announcement/detail')) return l10n.get('announcementDetail');
+    if (path.startsWith('/announcement/create')) return l10n.get('announcementCreate');
+
+    // ── 报表 ──
+    if (path.startsWith('/report/expense-apply')) return l10n.get('expenseApplyReport');
+    if (path.startsWith('/report/expense')) return l10n.get('expenseReport');
+    if (path.startsWith('/report/overtime')) return l10n.get('overtimeReport');
+    if (path.startsWith('/report/vehicle')) return l10n.get('vehicleReport');
+    if (path.startsWith('/report/outing-log')) return l10n.get('outingLogReport');
+
+    // ── 管理 ──
+    if (path.startsWith('/admin/permissions')) return l10n.get('permissionManagement');
+
+    return null; // 未知路由 → 回退到 provider
+  }
+
   NavBarConfig _rootConfig(String location, AppLocalizations l10n) {
     if (location.startsWith('/messages')) {
       return NavBarConfig(
@@ -59,15 +124,7 @@ class AppScaffold extends ConsumerWidget {
     final location = GoRouterState.of(context).uri.toString();
     final config = _isRootTab(location)
         ? _rootConfig(location, l10n)
-        : ref.watch(navBarConfigProvider);
-
-    SystemChrome.setSystemUIOverlayStyle(
-      SystemUiOverlayStyle(
-        statusBarColor: colors.bgCard,
-        statusBarIconBrightness: Brightness.dark,
-        statusBarBrightness: Brightness.light,
-      ),
-    );
+        : _pageConfig(location, l10n, ref);
 
     return Scaffold(
       resizeToAvoidBottomInset: resizeToAvoidBottomInset,

+ 438 - 0
lib/shared/widgets/attachment_picker.dart

@@ -0,0 +1,438 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+import 'package:image_picker/image_picker.dart';
+import 'package:file_picker/file_picker.dart';
+import 'package:tdesign_flutter/tdesign_flutter.dart';
+import '../models/attachment_file.dart';
+import '../../core/i18n/app_localizations.dart';
+import '../../core/theme/app_colors.dart';
+import '../../core/theme/app_colors_extension.dart';
+
+// ═══════════════════════════════════════════════════════════════
+// Controller
+// ═══════════════════════════════════════════════════════════════
+
+class AttachmentPickerController extends ChangeNotifier {
+  final int maxCount;
+
+  final List<AttachmentFile> _files = [];
+  List<AttachmentFile> get files => List.unmodifiable(_files);
+  int get count => _files.length;
+  bool get isFull => _files.length >= maxCount;
+
+  AttachmentPickerController({
+    this.maxCount = 9,
+    List<AttachmentFile>? initialFiles,
+  }) {
+    if (initialFiles != null) {
+      _files.addAll(initialFiles.take(maxCount));
+    }
+  }
+
+  void addFile(AttachmentFile file) {
+    if (_files.length >= maxCount) return;
+    _files.add(file);
+    notifyListeners();
+  }
+
+  void addFiles(List<AttachmentFile> files) {
+    for (final f in files) {
+      if (_files.length >= maxCount) break;
+      _files.add(f);
+    }
+    notifyListeners();
+  }
+
+  void removeFile(int index) {
+    if (index < 0 || index >= _files.length) return;
+    _files.removeAt(index);
+    notifyListeners();
+  }
+
+  void clear() {
+    if (_files.isEmpty) return;
+    _files.clear();
+    notifyListeners();
+  }
+
+  /// 从路径列表恢复(草稿兼容)
+  Future<void> restoreFromPaths(List<String> paths) async {
+    _files.clear();
+    for (final path in paths.take(maxCount)) {
+      _files.add(await AttachmentFile.fromPath(path));
+    }
+    notifyListeners();
+  }
+
+  /// 导出为路径列表(草稿持久化)
+  List<String> toPathList() => _files.map((f) => f.path).toList();
+}
+
+// ═══════════════════════════════════════════════════════════════
+// Widget
+// ═══════════════════════════════════════════════════════════════
+
+class AttachmentPicker extends StatefulWidget {
+  final AttachmentPickerController controller;
+
+  /// 图片大小上限(MB),null 不限制
+  final double? maxImageSizeMB;
+
+  /// 文件大小上限(MB),null 不限制
+  final double? maxFileSizeMB;
+
+  /// 允许的文件扩展名,null 使用默认 pdf/doc/docx/xls/xlsx/ppt/pptx/txt
+  final List<String>? allowedExtensions;
+
+  /// 文件被拒时回调
+  final void Function(AttachmentFile file, String reason)? onFileRejected;
+
+  /// 缩略图尺寸
+  final double thumbnailSize;
+
+  const AttachmentPicker({
+    super.key,
+    required this.controller,
+    this.maxImageSizeMB,
+    this.maxFileSizeMB,
+    this.allowedExtensions,
+    this.onFileRejected,
+    this.thumbnailSize = 80,
+  });
+
+  @override
+  State<AttachmentPicker> createState() => _AttachmentPickerState();
+}
+
+class _AttachmentPickerState extends State<AttachmentPicker> {
+  List<AttachmentFile> get _files => widget.controller.files;
+
+  @override
+  void initState() {
+    super.initState();
+    widget.controller.addListener(_onChanged);
+  }
+
+  @override
+  void dispose() {
+    widget.controller.removeListener(_onChanged);
+    super.dispose();
+  }
+
+  void _onChanged() {
+    if (mounted) setState(() {});
+  }
+
+  // ── 选择入口 ──
+
+  Future<void> _showPicker() async {
+    _unfocus();
+    final l10n = AppLocalizations.of(context);
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+
+    final choice = await showModalBottomSheet<String>(
+      context: context,
+      backgroundColor: colors.bgCard,
+      shape: const RoundedRectangleBorder(
+        borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
+      ),
+      builder: (ctx) => SafeArea(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            ListTile(
+              leading: const Icon(Icons.image_outlined),
+              title: Text(l10n.get('pickImage')),
+              onTap: () => Navigator.pop(ctx, 'image'),
+            ),
+            ListTile(
+              leading: const Icon(Icons.description_outlined),
+              title: Text(l10n.get('pickFile')),
+              onTap: () => Navigator.pop(ctx, 'file'),
+            ),
+          ],
+        ),
+      ),
+    );
+
+    if (!mounted || choice == null) return;
+    if (choice == 'image') {
+      await _pickImages();
+    } else {
+      await _pickDocuments();
+    }
+  }
+
+  Future<void> _pickImages() async {
+    final available = widget.controller.maxCount - widget.controller.count;
+    if (available <= 0) return;
+    final picker = ImagePicker();
+    final images = await picker.pickMultiImage(limit: available);
+    if (!mounted || images.isEmpty) return;
+    for (final img in images) {
+      if (widget.controller.isFull) break;
+      final file = await AttachmentFile.fromXFile(img);
+      if (_checkOversized(file)) continue;
+      widget.controller.addFile(file);
+    }
+  }
+
+  Future<void> _pickDocuments() async {
+    final available = widget.controller.maxCount - widget.controller.count;
+    if (available <= 0) return;
+    final result = await FilePicker.pickFiles(
+      type: FileType.custom,
+      allowedExtensions: widget.allowedExtensions ??
+          const ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'],
+      allowMultiple: true,
+    );
+    if (!mounted || result == null || result.files.isEmpty) return;
+    for (final pf in result.files) {
+      if (widget.controller.isFull) break;
+      if (pf.path == null) continue;
+      final file = AttachmentFile.fromPlatformFile(pf);
+      if (_checkOversized(file)) continue;
+      widget.controller.addFile(file);
+    }
+  }
+
+  /// 返回 true 表示文件超过大小限制
+  bool _checkOversized(AttachmentFile file) {
+    final l10n = AppLocalizations.of(context);
+    final sizeMB = file.sizeMB;
+
+    if (file.isImage && widget.maxImageSizeMB != null && sizeMB > widget.maxImageSizeMB!) {
+      final reason = l10n.getString('imageSizeLimit', args: {'max': widget.maxImageSizeMB!.toStringAsFixed(0)});
+      widget.onFileRejected?.call(file, reason);
+      if (mounted) TDToast.showText(reason, context: context);
+      return true;
+    }
+    if (!file.isImage && widget.maxFileSizeMB != null && sizeMB > widget.maxFileSizeMB!) {
+      final reason = l10n.getString('fileSizeLimit', args: {'max': widget.maxFileSizeMB!.toStringAsFixed(0)});
+      widget.onFileRejected?.call(file, reason);
+      if (mounted) TDToast.showText(reason, context: context);
+      return true;
+    }
+    return false;
+  }
+
+  void _unfocus() => FocusScope.of(context).unfocus();
+
+  // ── UI ──
+
+  @override
+  Widget build(BuildContext context) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+
+    return Column(
+      crossAxisAlignment: CrossAxisAlignment.start,
+      children: [
+        Wrap(
+          spacing: 8,
+          runSpacing: 8,
+          children: [
+            ..._files.asMap().entries.map(
+              (e) => Stack(
+                clipBehavior: Clip.none,
+                children: [
+                  _buildThumbnail(e.value),
+                  Positioned(
+                    right: -4,
+                    top: -4,
+                    child: GestureDetector(
+                      onTap: () => widget.controller.removeFile(e.key),
+                      child: Container(
+                        width: 20,
+                        height: 20,
+                        decoration: BoxDecoration(
+                          color: colors.danger,
+                          shape: BoxShape.circle,
+                        ),
+                        child: const Icon(
+                          Icons.close,
+                          size: 12,
+                          color: Colors.white,
+                        ),
+                      ),
+                    ),
+                  ),
+                ],
+              ),
+            ),
+            if (!widget.controller.isFull)
+              GestureDetector(
+                onTap: () => _showPicker(),
+                child: Container(
+                  width: widget.thumbnailSize,
+                  height: widget.thumbnailSize,
+                  decoration: BoxDecoration(
+                    color: colors.bgPage,
+                    borderRadius: BorderRadius.circular(4),
+                    border: Border.all(color: colors.border, width: 1),
+                  ),
+                  child: Center(
+                    child: Icon(
+                      Icons.add,
+                      size: 24,
+                      color: colors.textPlaceholder,
+                    ),
+                  ),
+                ),
+              ),
+          ],
+        ),
+      ],
+    );
+  }
+
+  Widget _buildThumbnail(AttachmentFile file) {
+    final colors = Theme.of(context).extension<AppColorsExtension>()!;
+    final size = widget.thumbnailSize;
+
+    if (file.isImage) {
+      return Container(
+        width: size,
+        height: size,
+        decoration: BoxDecoration(
+          borderRadius: BorderRadius.circular(4),
+          border: Border.all(color: colors.border, width: 0.5),
+        ),
+        child: ClipRRect(
+          borderRadius: BorderRadius.circular(4),
+          child: Image.file(
+            File(file.path),
+            width: size,
+            height: size,
+            fit: BoxFit.cover,
+            errorBuilder: (_, _, _) => _buildDocTile(file, colors, size),
+          ),
+        ),
+      );
+    }
+
+    return _buildDocTile(file, colors, size);
+  }
+
+  Widget _buildDocTile(AttachmentFile file, AppColorsExtension colors, double size) {
+    return SizedBox(
+      width: size,
+      child: Column(
+        mainAxisSize: MainAxisSize.min,
+        children: [
+          Container(
+            width: size,
+            height: size,
+            decoration: BoxDecoration(
+              color: colors.primaryLight,
+              borderRadius: BorderRadius.circular(4),
+            ),
+            child: Center(
+              child: Icon(
+                _fileTypeIcon(file.extension),
+                color: colors.primary,
+                size: size * 0.4,
+              ),
+            ),
+          ),
+          const SizedBox(height: 4),
+          SizedBox(
+            width: size,
+            height: 16,
+            child: _MarqueeText(
+              text: file.name,
+              style: TextStyle(
+                fontSize: AppFontSizes.caption,
+                color: colors.textSecondary,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  IconData _fileTypeIcon(String ext) {
+    switch (ext) {
+      case 'pdf':
+        return Icons.picture_as_pdf;
+      case 'doc':
+      case 'docx':
+        return Icons.description;
+      case 'xls':
+      case 'xlsx':
+        return Icons.table_chart;
+      case 'ppt':
+      case 'pptx':
+        return Icons.slideshow;
+      default:
+        return Icons.insert_drive_file;
+    }
+  }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// Marquee Text
+// ═══════════════════════════════════════════════════════════════
+
+class _MarqueeText extends StatefulWidget {
+  final String text;
+  final TextStyle style;
+  const _MarqueeText({required this.text, required this.style});
+
+  @override
+  State<_MarqueeText> createState() => _MarqueeTextState();
+}
+
+class _MarqueeTextState extends State<_MarqueeText>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  final ScrollController _scrollController = ScrollController();
+  bool _needsScroll = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      vsync: this,
+      duration: const Duration(milliseconds: 3000),
+    );
+    WidgetsBinding.instance.addPostFrameCallback(_measure);
+  }
+
+  void _measure(_) {
+    if (!mounted || !_scrollController.hasClients) return;
+    if (_scrollController.position.maxScrollExtent > 0) {
+      setState(() => _needsScroll = true);
+      _controller.addListener(_onScroll);
+      _controller.repeat(reverse: true);
+    }
+  }
+
+  void _onScroll() {
+    if (!_scrollController.hasClients) return;
+    final max = _scrollController.position.maxScrollExtent;
+    if (max > 0) {
+      _scrollController.jumpTo(_controller.value * max);
+    }
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SingleChildScrollView(
+      controller: _scrollController,
+      scrollDirection: Axis.horizontal,
+      physics: _needsScroll
+          ? const NeverScrollableScrollPhysics()
+          : const ClampingScrollPhysics(),
+      child: Text(widget.text, style: widget.style, maxLines: 1),
+    );
+  }
+}

+ 4 - 15
lib/shared/widgets/nav_bar_config.dart

@@ -1,6 +1,6 @@
+import 'dart:async';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
-import 'package:flutter/scheduler.dart';
 
 /// NavBar 配置,由各页面在 build 中设置,AppShell 统一渲染
 class NavBarConfig {
@@ -71,22 +71,11 @@ class NavBarConfig {
 class NavBarConfigNotifier extends StateNotifier<NavBarConfig> {
   NavBarConfigNotifier() : super(NavBarConfig.home);
 
-  NavBarConfig? _pendingConfig;
-  bool _scheduled = false;
-
   void update(NavBarConfig config) {
     if (state != config) {
-      _pendingConfig = config;
-      if (!_scheduled) {
-        _scheduled = true;
-        SchedulerBinding.instance.addPostFrameCallback((_) {
-          _scheduled = false;
-          if (mounted && _pendingConfig != null) {
-            state = _pendingConfig!;
-            _pendingConfig = null;
-          }
-        });
-      }
+      Future.microtask(() {
+        if (mounted) state = config;
+      });
     }
   }
 }

+ 12 - 10
lib/shared/widgets/status_banner.dart

@@ -24,14 +24,14 @@ class StatusBanner extends StatelessWidget {
     final backgroundColor = _lighten(color, 0.88);
 
     return Container(
-      height: 120,
-      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
+      width: double.infinity,
+      padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
       decoration: BoxDecoration(
         color: backgroundColor,
         borderRadius: BorderRadius.circular(8),
       ),
       child: Column(
-        mainAxisAlignment: MainAxisAlignment.center,
+        mainAxisSize: MainAxisSize.min,
         children: [
           Icon(icon, size: 40, color: color),
           const SizedBox(height: 8),
@@ -43,14 +43,16 @@ class StatusBanner extends StatelessWidget {
               color: color,
             ),
           ),
-          const SizedBox(height: 4),
-          Text(
-            subText,
-            style: TextStyle(
-              fontSize: 13,
-              color: color,
+          if (subText.isNotEmpty) ...[
+            const SizedBox(height: 4),
+            Text(
+              subText,
+              style: TextStyle(
+                fontSize: 13,
+                color: color,
+              ),
             ),
-          ),
+          ],
         ],
       ),
     );

+ 49 - 1
pubspec.lock

@@ -201,6 +201,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "3.1.1"
+  dbus:
+    dependency: transitive
+    description:
+      name: dbus
+      sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.7.14"
   dio:
     dependency: "direct main"
     description:
@@ -241,6 +249,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.3.3"
+  ffi:
+    dependency: transitive
+    description:
+      name: ffi
+      sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.2.0"
   file:
     dependency: transitive
     description:
@@ -249,6 +265,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "7.0.1"
+  file_picker:
+    dependency: "direct main"
+    description:
+      name: file_picker
+      sha256: f13a03000d942e476bc1ff0a736d2e9de711d2f89a95cd4c1d88f861c3348387
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "11.0.2"
   file_selector_linux:
     dependency: transitive
     description:
@@ -422,7 +446,7 @@ packages:
     source: hosted
     version: "4.1.2"
   image_picker:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: image_picker
       sha256: "021834d9c0c3de46bf0fe40341fa07168407f694d9b2bb18d532dc1261867f7a"
@@ -629,6 +653,14 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "1.1.0"
+  petitparser:
+    dependency: transitive
+    description:
+      name: petitparser
+      sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.0.2"
   plugin_platform_interface:
     dependency: transitive
     description:
@@ -882,6 +914,22 @@ packages:
       url: "https://pub.flutter-io.cn"
     source: hosted
     version: "3.0.3"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "5.15.0"
+  xml:
+    dependency: transitive
+    description:
+      name: xml
+      sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "6.6.1"
   yaml:
     dependency: transitive
     description:

+ 2 - 0
pubspec.yaml

@@ -20,6 +20,8 @@ dependencies:
   easy_refresh: ^3.4.0
   flutter_swiper_null_safety: ^1.0.2
   tdesign_flutter: ^0.2.7
+  image_picker: ^1.1.2
+  file_picker: ^11.0.2
 
 dev_dependencies:
   flutter_test: