chengc před 1 dnem
rodič
revize
bd95d55cde
34 změnil soubory, kde provedl 2275 přidání a 1193 odebrání
  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

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 186 - 303
docs/superpowers/specs/tboss-oa-prd.md


+ 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: