9月20日
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# 2026-09-20
|
||||||
|
|
||||||
|
- 通读 reimburse 应用全部 SwiftUI 视图代码(ContentView/ScanPage/PairPage/MatchedPage/ExpenseSheet/ExpenseTemplateSheet/PayeeProfileSheet/AdminView 等 26 个文件),产出《界面优化方案.md》于项目根目录。
|
||||||
|
- 方案要点:P0 为配对页与已核对页的搜索/排序/低分组过滤;P1 为颜色语义统一、深色模式适配、ScanPage 信息层级重组、MaterialPreview 异步加载、AdminView(1364 行)拆分;P2/P3 为通知组件、快捷键、模板字段可视化点选等。
|
||||||
|
- 未改任何代码。
|
||||||
@@ -36,8 +36,8 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
|
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
|
||||||
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
||||||
|
|
||||||
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
|
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。一对一、一对多、多对一及多对多的已选连线均持续显示,不依赖鼠标悬停。两侧独立滚动时连线跟随材料,屏幕外的已选材料以顶部或底部箭头和数量标记表示;搜索隐藏的选择仍保留并在列底部提示。
|
||||||
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。
|
5. “已核对”以费用类型为固定表头并排分列,以紧凑缩略图、金额、日期和凭证份数展示核对组,各列共用纵向滚动区。不将未经确认的 OCR 商户候选或文件名作为卡片标题;详情按组号标识,商户识别内容仍可搜索。仅显示当前结果中有材料的类型,空列自动隐藏,有材料后自动出现,其余列自动分配可用宽度;搜索和筛选也遵循此规则,无结果时只显示空状态,窄窗口支持横向滚动。点击凭证进入详情,可放大原件、切换前后组、修改类型或撤销配对。可逐组勾选、通过类型表头全选本列当前结果,或使用底部“全选当前结果”,再点击“导出 PPT”或“报销单”。选择数量和发票金额在底部汇总。跨类型及搜索筛选保留勾选,取消一列的全选不影响其他列或筛选外的勾选。修改类型后材料移动至对应列。未勾选时不能导出这两类文件。其他导出菜单中的行程 Excel 仍使用全部已核对材料;列内排序不改变导出顺序。自动核对结果不代表已人工复核。
|
||||||
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
|
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
|
||||||
|
|
||||||
## 自定义个人报销单模板
|
## 自定义个人报销单模板
|
||||||
@@ -56,7 +56,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
|
|
||||||
### 导出前预览
|
### 导出前预览
|
||||||
|
|
||||||
在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。
|
在“已核对”勾选材料 → “报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。
|
||||||
|
|
||||||
跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 WPS 逐像素相同。预览限制不改变导出的模板样式。
|
跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 WPS 逐像素相同。预览限制不改变导出的模板样式。
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
|
|
||||||
修改所选数据、用途、收款资料或模板后需要重新预览。保存前核对输入及文件指纹,防止保存与预览不一致的内容;返回或关闭预览时清理临时文件。临时报销单仅存于应用本地沙盒,包含完整收款信息,不上传。
|
修改所选数据、用途、收款资料或模板后需要重新预览。保存前核对输入及文件指纹,防止保存与预览不一致的内容;返回或关闭预览时清理临时文件。临时报销单仅存于应用本地沙盒,包含完整收款信息,不上传。
|
||||||
|
|
||||||
已核对列表的“匹配”列会显示简要核对项,点击百分比查看每项实际得分、未得分原因及核对建议。证据评分采用真正的 **100 分制**:单张发票与付款截图的权重为金额 40 分、商户 20 分、日期 10 分、单号 15 分、批次金额唯一性 15 分;满分之和为 100,实际得分之和就是百分比,各项未得分之和就是差额。例如仅金额一致且批次唯一时为 55/100 分,即 55%。日期相差不超过 3 天得 10 分,4–7 天得 5 分;商户达到 35% 相似度后按相似度 × 20 分四舍五入。其他组合规则的满分原本就是 100 分,仍保留;多笔付款固定 95 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。
|
已核对页面的凭证详情中显示简要核对项,点击自动核对评分查看每项实际得分、未得分原因及核对建议。证据评分采用真正的 **100 分制**:单张发票与付款截图的权重为金额 40 分、商户 20 分、日期 10 分、单号 15 分、批次金额唯一性 15 分;满分之和为 100,实际得分之和就是百分比,各项未得分之和就是差额。例如仅金额一致且批次唯一时为 55/100 分,即 55%。日期相差不超过 3 天得 10 分,4–7 天得 5 分;商户达到 35% 相似度后按相似度 × 20 分四舍五入。其他组合规则的满分原本就是 100 分,仍保留;多笔付款固定 95 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。
|
||||||
|
|
||||||
旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore`(`version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。
|
旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore`(`version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# 界面优化实施记录
|
||||||
|
|
||||||
|
依据 `界面优化方案.md` 实施,仅调整 macOS 客户端。未修改后端、小程序、Python 引擎及导出模板。
|
||||||
|
|
||||||
|
## 已实施
|
||||||
|
|
||||||
|
- 默认进入本地贴票;统一语义色、通知、搜索框、徽章和统计组件。
|
||||||
|
- 人工配对支持两栏独立搜索、日期/金额/文件名排序、三档缩略图、方向键导航、空格勾选及预览内勾选。单击卡片立即勾选或取消,不等待双击判定;放大预览使用卡片右上角按钮。已选连线持续显示,兼容一对一、一对多、多对一及多对多,不依赖悬停或焦点。同组材料通过中间主线和独立分支相连,分支沿网格间隙绕开相邻卡片,避免多对多交叉线遮挡票据。左右滚动实时更新连线位置,滚出视野的已选材料归并到上下边缘数量标记;连线限制在材料区内,不覆盖搜索栏和操作栏。
|
||||||
|
- 已核对按费用类型并排分列:固定类型表头,共用纵向滚动区,以紧凑凭证缩略图、金额、日期及份数展示每组材料。不再将未经确认的 OCR 商户候选或文件名作为卡片标题,详情标题使用组号,原始 OCR 数据保留用于搜索和核对。仅显示当前结果中有材料的类型,空列自动隐藏,有材料后自动出现,其余列自动分配可用宽度;窄窗口横向滚动。点击凭证打开详情,支持大图预览、前后组切换、修改类型及撤销配对。保留搜索、列内排序、需关注过滤、整列勾选、类型金额小计及筛选外勾选计数,选择汇总和导出集中到底部。
|
||||||
|
- 扫描页空工作区隐藏统计;待配对入口前移,提醒可关闭/折叠,统计与金额汇总紧凑化,分类金额增加占比条。
|
||||||
|
- 材料预览异步解码、先显示缩略图,支持取消和重试;没有缓存预览的 PDF 仍只显示首页。预览外围适配深色模式,纸张本身保留白底。
|
||||||
|
- 报销单、材料和模板窗口按屏幕可用范围限制尺寸。报销单正文可滚动,签字岗位支持快选与移除后撤销,空用途与重复用途提供反馈。
|
||||||
|
- 模板填写位置支持预览单元格点选、原文行悬停高亮、格式/范围/重复位置实时校验;窄预览使用紧凑缩放菜单。最终模板合法性仍交由原引擎验证。
|
||||||
|
- 收款资料保存反馈及尾号复制;成功与错误通知可分别显示、关闭,成功提示自动消失,导出成功提供访达入口。
|
||||||
|
- 项目管理拆为审批、成员、组别、权限、类型规则及操作记录六个页面,共享基础组件;增加审批列排序、已加载数量、横向导航、成功服务地址历史与扫码重试按钮。
|
||||||
|
- Store 缓存已核对排序、全局序号、分类汇总及需关注数量;扫描处理在 Dock 图标显示进度。
|
||||||
|
|
||||||
|
## 交互约定
|
||||||
|
|
||||||
|
- `Command-1/2/3` 切换本地页面,`Command-F` 聚焦搜索,`Command-,` 打开模板,原有 `Command-O` 导入保留。
|
||||||
|
- 撤销最近核对组采用 `Command-Option-Z`,避免覆盖文本编辑的标准 `Command-Z`。
|
||||||
|
- 搜索支持空格分隔多个条件。筛选不清空勾选,全选/取消全选只作用于当前结果。
|
||||||
|
- “需关注”包括自动证据分低于 90、缺少可验证评分,或包含识别失败材料的组;不修改配对结论。
|
||||||
|
- 表格排序不改变导出顺序。PPT 与报销单仍只导出所选组,行程表仍使用全部已核对材料。
|
||||||
|
- 用途重复仅提示,不跨费用类型重新合并;签字全部删除时仍沿用原默认岗位。金额口径、PPT 版式、模板指纹及预览/保存一致性校验不变。
|
||||||
|
- 再次导入替换工作区与危险操作的原确认流程保留。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
运行 Swift 回归测试:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/run-ui-checks.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
运行上述测试以及隔离的原生界面检查:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/run-ui-checks.mjs --render
|
||||||
|
```
|
||||||
|
|
||||||
|
仅重跑原生界面检查:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node tests/run-ui-checks.mjs --render-only
|
||||||
|
```
|
||||||
|
|
||||||
|
测试使用临时工作区、生成的样例材料与内存收款存储,不读取真实工作区或收款钥匙串。截图目录由脚本输出。
|
||||||
|
|
||||||
|
- 12 组 Swift 回归:搜索排序、需关注、隐藏勾选保留、金额汇总、证据评分、模板持久化与指纹、预览一致性、导入校验、收款存储、附件兼容、审批导出范围及规则编辑状态。
|
||||||
|
- 原生检查:1280×800、1024×640、900×560 的浅色/深色渲染、弹窗、窄表格预览;搜索焦点、不同缩放的单元格点击、异步图像缩放、PDF 首页及加载取消。
|
||||||
|
- 原有 `SpreadsheetLayoutTests.swift` 的排版与像素回归另行通过。
|
||||||
|
- 完整 Xcode Debug 构建及本地引擎指纹检查通过。
|
||||||
|
|
||||||
|
本轮未使用真实服务执行微信登录、在线审批或真实材料 OCR 验收。Dock 进度、VoiceOver 朗读及不同 macOS 版本的系统材质效果仍需交互验收。未引入无限历史通知队列,也未改变扫码轮询为自动重复生成二维码。
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminAttachmentRow: View {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
let file: AdminFile
|
||||||
|
@State private var image: NSImage?
|
||||||
|
@State private var loading = false
|
||||||
|
@State private var showPreview = false
|
||||||
|
|
||||||
|
private var isImage: Bool { file.mime.hasPrefix("image/") }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if isImage {
|
||||||
|
Button {
|
||||||
|
if image != nil { showPreview = true }
|
||||||
|
else { Task { await loadImage(openWhenReady: true) } }
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 6).fill(.quaternary.opacity(0.35))
|
||||||
|
if let image {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
} else if loading {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "photo").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 112, height: 76)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("点击预览大图")
|
||||||
|
} else {
|
||||||
|
Image(systemName: file.mime == "application/pdf" ? "doc.richtext" : "doc")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
.frame(width: 44)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(file.name).lineLimit(2)
|
||||||
|
Text(isImage ? "点击图片直接预览" : "文件附件")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if isImage {
|
||||||
|
Button {
|
||||||
|
if image != nil { showPreview = true }
|
||||||
|
else { Task { await loadImage(openWhenReady: true) } }
|
||||||
|
} label: { Image(systemName: "eye") }
|
||||||
|
.help("预览图片")
|
||||||
|
}
|
||||||
|
Button { Task { await model.perform { try await model.download(file) } } } label: {
|
||||||
|
Image(systemName: "arrow.down.to.line")
|
||||||
|
}.help("下载凭证")
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
.task(id: file.id) { await loadImage(openWhenReady: false) }
|
||||||
|
.sheet(isPresented: $showPreview) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text(file.name).font(.headline).lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Button { showPreview = false } label: { Image(systemName: "xmark") }.help("关闭预览")
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
Divider()
|
||||||
|
if let image {
|
||||||
|
Image(nsImage: image)
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.padding(20)
|
||||||
|
.frame(minWidth: 720, minHeight: 520)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func loadImage(openWhenReady: Bool) async {
|
||||||
|
guard isImage, image == nil, !loading else {
|
||||||
|
if openWhenReady && image != nil { showPreview = true }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading = true
|
||||||
|
defer { loading = false }
|
||||||
|
do {
|
||||||
|
let data = try await model.fileData(file)
|
||||||
|
guard let loaded = NSImage(data: data) else {
|
||||||
|
throw AdminFailure(message: "图片格式无法预览,请下载后查看")
|
||||||
|
}
|
||||||
|
image = loaded
|
||||||
|
if openWhenReady { showPreview = true }
|
||||||
|
} catch is CancellationError {
|
||||||
|
} catch {
|
||||||
|
model.error = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminAuditTab: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
|
||||||
|
@State private var auditSearch = ""
|
||||||
|
var body: some View {
|
||||||
|
audit
|
||||||
|
}
|
||||||
|
private var filteredAudit: [AdminAudit] {
|
||||||
|
model.audit.filter { AdminDisplayFilter.matches(auditSearch, fields: [$0.actorId, $0.action, $0.resourceId, $0.detail, $0.date]) }
|
||||||
|
}
|
||||||
|
private var audit: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
searchField("搜索账户、操作、资源或详情", text: $auditSearch).frame(maxWidth: 400)
|
||||||
|
Spacer()
|
||||||
|
Text("已加载 \(model.audit.count) 条 · 匹配 \(filteredAudit.count) 条").font(.callout).foregroundStyle(.secondary)
|
||||||
|
Button { run { try await model.loadAudit() } } label: { Label("刷新记录", systemImage: "arrow.clockwise") }
|
||||||
|
}
|
||||||
|
ZStack {
|
||||||
|
Table(filteredAudit) {
|
||||||
|
TableColumn("时间", value: \.date)
|
||||||
|
TableColumn("账户", value: \.actorId)
|
||||||
|
TableColumn("操作", value: \.action)
|
||||||
|
TableColumn("资源", value: \.resourceId)
|
||||||
|
TableColumn("详情") { Text($0.detail).lineLimit(2).help($0.detail).textSelection(.enabled) }
|
||||||
|
}
|
||||||
|
if filteredAudit.isEmpty {
|
||||||
|
emptyState("暂无匹配的操作记录", icon: "clock.arrow.circlepath", message: "可清空搜索或刷新记录;搜索仅针对已加载的数据。")
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
Text("操作记录用于追溯管理行为;此页面不改变任何权限或业务数据。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor protocol AdminPage: View {
|
||||||
|
var model: AdminStore { get }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AdminPage {
|
||||||
|
func run(_ work: @escaping () async throws -> Void) { Task { await model.perform(work) } }
|
||||||
|
func searchField(_ placeholder: String, text: Binding<String>) -> some View {
|
||||||
|
AppSearchField(placeholder: placeholder, text: text)
|
||||||
|
}
|
||||||
|
func emptyState(_ title: String, icon: String, message: String) -> some View {
|
||||||
|
EmptyPanel(symbol: icon, title: title, message: message)
|
||||||
|
}
|
||||||
|
func statusBadge(_ text: String, positive: Bool = false, pending: Bool = false) -> some View {
|
||||||
|
AppBadge(title: text, symbol: positive ? "checkmark.circle" : pending ? "clock" : "minus.circle",
|
||||||
|
tone: positive ? .success : pending ? .warning : .information)
|
||||||
|
}
|
||||||
|
func sectionCard<Content: View>(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View {
|
||||||
|
AppSection(title: title, symbol: icon, content: content)
|
||||||
|
}
|
||||||
|
func memberState(_ state: String) -> String {
|
||||||
|
["pending": "待审核", "active": "已加入", "rejected": "已拒绝", "disabled": "已停用"][state] ?? state
|
||||||
|
}
|
||||||
|
func groupOption(_ group: AdminGroup) -> String {
|
||||||
|
group.canManage ? group.label : "\(group.label)(未授权)"
|
||||||
|
}
|
||||||
|
func saveQR(_ image: NSImage, filename: String = "入组二维码.png") {
|
||||||
|
let panel = NSSavePanel()
|
||||||
|
panel.nameFieldStringValue = filename
|
||||||
|
if panel.runModal() == .OK, let url = panel.url, let data = image.tiffRepresentation,
|
||||||
|
let bitmap = NSBitmapImageRep(data: data), let png = bitmap.representation(using: .png, properties: [:]) {
|
||||||
|
do { try png.write(to: url, options: .atomic) } catch { model.error = error.localizedDescription }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdminAction: Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
let title: String
|
||||||
|
let perform: () -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func adminConfirmation(_ action: Binding<AdminAction?>) -> some View {
|
||||||
|
confirmationDialog(action.wrappedValue?.title ?? "", isPresented: Binding(
|
||||||
|
get: { action.wrappedValue != nil }, set: { if !$0 { action.wrappedValue = nil } }
|
||||||
|
), titleVisibility: .visible) {
|
||||||
|
Button("确认") {
|
||||||
|
let pending = action.wrappedValue
|
||||||
|
action.wrappedValue = nil
|
||||||
|
pending?.perform()
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) { action.wrappedValue = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminExpensesTab: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
|
||||||
|
@State private var expenseSearch = ""
|
||||||
|
@State private var expenseState = "all"
|
||||||
|
@State private var selectedExpenses: Set<String> = []
|
||||||
|
@State private var showDateExport = false
|
||||||
|
@State private var showPPTExport = false
|
||||||
|
@State private var exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
|
||||||
|
@State private var exportEndDate = Date()
|
||||||
|
@State private var reason = ""
|
||||||
|
@State private var approvalConfirmation: AdminAction?
|
||||||
|
@State private var sortOrder = [KeyPathComparator(\AdminExpense.date, order: .reverse)]
|
||||||
|
var body: some View {
|
||||||
|
expenses
|
||||||
|
.onChange(of: filteredExpenses.map(\.id)) { _, ids in selectedExpenses.formIntersection(Set(ids)) }
|
||||||
|
.sheet(item: $model.selected) { detail($0) }
|
||||||
|
.sheet(isPresented: $showDateExport) { dateExport }
|
||||||
|
.sheet(isPresented: $showPPTExport) { AdminPPTExportSheet(model: model, initialSelection: selectedExpenses) }
|
||||||
|
}
|
||||||
|
private var filteredExpenses: [AdminExpense] {
|
||||||
|
AdminDisplayFilter.expenses(model.displayedExpenses, query: expenseSearch, state: expenseState).sorted(using: sortOrder)
|
||||||
|
}
|
||||||
|
private func metric(_ title: String, value: String, icon: String) -> some View {
|
||||||
|
StatNumber(title: title, value: value, symbol: icon).padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
private var expenses: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
metric("已加载申请", value: "\(model.displayedExpenses.count) 笔", icon: "doc.on.doc")
|
||||||
|
metric("已加载待审核", value: "\(model.displayedExpenses.filter { $0.state == "pending" }.count) 笔", icon: "clock")
|
||||||
|
metric("已加载申请金额", value: "¥" + String(format: "%.2f", Double(model.displayedExpenses.reduce(Int64(0)) { $0 + $1.amountCents }) / 100), icon: "yensign.circle")
|
||||||
|
}
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
searchField("搜索姓名、项目、类型或申请编号", text: $expenseSearch).frame(maxWidth: 340)
|
||||||
|
Picker("状态", selection: $expenseState) {
|
||||||
|
Text("全部状态").tag("all")
|
||||||
|
Text("待审核").tag("pending")
|
||||||
|
Text("已批准").tag("approved")
|
||||||
|
Text("已退回").tag("returned")
|
||||||
|
}.frame(width: 180)
|
||||||
|
Spacer()
|
||||||
|
Button { showPPTExport = true } label: { Label("导出 PPT", systemImage: "doc.richtext") }
|
||||||
|
.help("筛选已批准的申请,按组别、人员和提交日期批量导出贴票 PPT")
|
||||||
|
Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") }
|
||||||
|
}
|
||||||
|
ZStack {
|
||||||
|
Table(filteredExpenses, selection: $selectedExpenses, sortOrder: $sortOrder) {
|
||||||
|
TableColumn("申请人", value: \.name)
|
||||||
|
TableColumn("项目", value: \.projectName)
|
||||||
|
TableColumn("组别", value: \.team)
|
||||||
|
TableColumn("类型", value: \.type)
|
||||||
|
TableColumn("金额", value: \.amountCents) { Text("¥\($0.amountText)").monospacedDigit() }
|
||||||
|
TableColumn("状态") { statusBadge($0.status, positive: $0.state == "approved", pending: $0.state == "pending") }
|
||||||
|
TableColumn("提交日期", value: \.date)
|
||||||
|
TableColumn("操作") { record in
|
||||||
|
Button { reason = ""; run { try await model.detail(record) } } label: {
|
||||||
|
Label("查看详情", systemImage: "doc.text.magnifyingglass")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.help("查看申请、备注和凭证")
|
||||||
|
}.width(min: 120, ideal: 130)
|
||||||
|
}
|
||||||
|
if filteredExpenses.isEmpty {
|
||||||
|
emptyState(model.displayedExpenses.isEmpty ? "暂无报销申请" : "没有匹配的申请", icon: "tray", message: "可调整范围、搜索或状态;搜索仅针对已加载记录。")
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
HStack {
|
||||||
|
Text("已加载 \(model.expenses.count) 笔\(model.more ? "(还有更多)" : "(已到底)") · 显示 \(filteredExpenses.count) 笔 · 已选 \(selectedExpenses.count) 笔").foregroundStyle(.secondary)
|
||||||
|
if !selectedExpenses.isEmpty { Button("清空选择") { selectedExpenses = [] }.buttonStyle(.borderless) }
|
||||||
|
Spacer()
|
||||||
|
if model.more { Button("加载更多申请") { run { try await model.refreshExpenses(loadMore: true) } } }
|
||||||
|
Button { run { try await model.export(selectedExpenses) } } label: { Label("下载所选凭证", systemImage: "arrow.down.to.line") }
|
||||||
|
.buttonStyle(.borderedProminent).disabled(selectedExpenses.isEmpty || selectedExpenses.count > 20)
|
||||||
|
.help("每次最多选择 20 笔申请")
|
||||||
|
}.font(.callout)
|
||||||
|
Text("统计与搜索仅包含已加载数据,并非项目全量总账;凭证下载每次最多 20 笔。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
private var dateExport: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "calendar.badge.arrow.down")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.teal)
|
||||||
|
Text("按时间下载报销数据").font(.title3.bold())
|
||||||
|
}
|
||||||
|
Text("导出范围:\(exportScopeText)")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 14) {
|
||||||
|
GridRow {
|
||||||
|
Text("开始日期")
|
||||||
|
DatePicker("开始日期", selection: $exportStartDate, displayedComponents: .date)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
GridRow {
|
||||||
|
Text("结束日期")
|
||||||
|
DatePicker("结束日期", selection: $exportEndDate, displayedComponents: .date)
|
||||||
|
.labelsHidden()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Label("压缩包目录:日期-xxx组 / 姓名-报销类型", systemImage: "folder")
|
||||||
|
.font(.callout)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("取消") { showDateExport = false }
|
||||||
|
Button {
|
||||||
|
let start = exportStartDate
|
||||||
|
let end = exportEndDate
|
||||||
|
let project = model.projectID
|
||||||
|
let group = model.groupID
|
||||||
|
showDateExport = false
|
||||||
|
run { try await model.exportRange(startDate: start, endDate: end, projectID: project, groupID: group) }
|
||||||
|
} label: {
|
||||||
|
Label("选择位置并下载", systemImage: "arrow.down.to.line")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(Calendar.current.startOfDay(for: exportStartDate) > Calendar.current.startOfDay(for: exportEndDate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 470)
|
||||||
|
}
|
||||||
|
private var exportScopeText: String {
|
||||||
|
if let group = model.selectedGroup { return group.label }
|
||||||
|
if let project = model.access?.projects.first(where: { $0.id == model.projectID }) { return "\(project.name) / 全部可见组" }
|
||||||
|
return "全部授权项目 / 全部可见组"
|
||||||
|
}
|
||||||
|
private func detail(_ record: AdminExpense) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(alignment: .top) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("\(record.name) · \(record.type)").font(.title3.weight(.semibold))
|
||||||
|
Text("申请 #\(record.id) · \(record.date)").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
statusBadge(record.status, positive: record.state == "approved", pending: record.state == "pending")
|
||||||
|
Button { model.selected = nil } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("关闭详情")
|
||||||
|
}.padding(20)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("所属项目 / 组别").font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text("\(record.projectName) / \(record.team)").textSelection(.enabled)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("¥\(record.amountText)").font(.title.weight(.semibold)).monospacedDigit()
|
||||||
|
}
|
||||||
|
if !record.note.isEmpty {
|
||||||
|
sectionCard("申请备注", icon: "text.alignleft") {
|
||||||
|
Text(record.note).textSelection(.enabled).fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Label("凭证附件(\(record.files.values.flatMap { $0.files }.count))", systemImage: "paperclip").font(.headline)
|
||||||
|
Text("图片可预览;PDF 等文件可下载后查看。").font(.caption).foregroundStyle(.secondary)
|
||||||
|
LazyVStack(spacing: 8) {
|
||||||
|
ForEach(record.files.values.flatMap { $0.files }.sorted(by: { ($0.kind, $0.id) < ($1.kind, $1.id) })) { file in
|
||||||
|
AdminAttachmentRow(model: model, file: file)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if record.files.isEmpty { Text("此申请没有附件").foregroundStyle(.secondary) }
|
||||||
|
}
|
||||||
|
DisclosureGroup("处理记录(\(record.events.count))") {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
ForEach(Array(record.events.enumerated()), id: \.offset) { _, event in
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(event.detail.isEmpty ? event.action : event.detail).textSelection(.enabled)
|
||||||
|
Text("\(event.date) · 账户 \(event.actorId)").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(.top, 12).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
if record.state == "pending", record.userId != model.access?.userId || model.access?.platform == true {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("审批操作").font(.headline)
|
||||||
|
TextField("审批原因(退回 / 拒绝时必填)", text: $reason, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.lineLimit(2...4)
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
approvalConfirmation = AdminAction(title: "退回 / 拒绝申请 #\(record.id)?") {
|
||||||
|
run { try await model.approve("returned", reason: reason) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("退回 / 拒绝", systemImage: "arrow.uturn.backward.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
.disabled(reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
approvalConfirmation = AdminAction(title: "批准申请 #\(record.id),金额 ¥\(record.amountText)?") {
|
||||||
|
run { try await model.approve("approved", reason: reason) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("批准报销", systemImage: "checkmark.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(.quaternary.opacity(0.35))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
} else if record.state == "pending" {
|
||||||
|
Label("不能审批本人提交的报销申请", systemImage: "person.crop.circle.badge.exclamationmark")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) }
|
||||||
|
}.padding(16).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}.frame(width: AppTheme.sheetSize(width: 740, height: 700).width,
|
||||||
|
height: AppTheme.sheetSize(width: 740, height: 700).height).disabled(model.busy)
|
||||||
|
.confirmationDialog(approvalConfirmation?.title ?? "", isPresented: Binding(
|
||||||
|
get: { approvalConfirmation != nil },
|
||||||
|
set: { if !$0 { approvalConfirmation = nil } }
|
||||||
|
), titleVisibility: .visible) {
|
||||||
|
Button("确认") {
|
||||||
|
let action = approvalConfirmation
|
||||||
|
approvalConfirmation = nil
|
||||||
|
action?.perform()
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) { approvalConfirmation = nil }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminGroupsTab: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
@Binding var tab: String
|
||||||
|
@Binding var memberSection: String
|
||||||
|
@Binding var permissionSection: String
|
||||||
|
@State private var groupSearch = ""
|
||||||
|
@State private var groupName = ""
|
||||||
|
@State private var renameTarget: AdminManagedGroup?
|
||||||
|
@State private var renameValue = ""
|
||||||
|
@State private var confirmation: AdminAction?
|
||||||
|
var body: some View {
|
||||||
|
groupManagement.adminConfirmation($confirmation)
|
||||||
|
.sheet(item: $renameTarget) { group in
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("重命名组别").font(.title3.bold())
|
||||||
|
TextField("组别名称", text: $renameValue).textFieldStyle(.roundedBorder)
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("取消") { renameTarget = nil }
|
||||||
|
Button("保存") {
|
||||||
|
let value = renameValue
|
||||||
|
renameTarget = nil
|
||||||
|
run { try await model.renameGroup(group, name: value) }
|
||||||
|
}.buttonStyle(.borderedProminent).disabled(renameValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
}
|
||||||
|
}.padding(24).frame(width: 360)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
private var filteredGroups: [AdminManagedGroup] {
|
||||||
|
model.visibleManagedGroups.filter { AdminDisplayFilter.matches(groupSearch, fields: [$0.name, $0.projectName, String($0.id)]) }
|
||||||
|
}
|
||||||
|
private var groupManagement: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
searchField("搜索组别、项目或组 ID", text: $groupSearch).frame(maxWidth: 340)
|
||||||
|
Spacer()
|
||||||
|
Picker("所属项目", selection: $model.projectID) {
|
||||||
|
Text("全部项目").tag(Int64(0))
|
||||||
|
ForEach((model.access?.projects ?? []).filter(\.active)) { project in
|
||||||
|
Text(project.name).tag(project.id)
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: 340)
|
||||||
|
}
|
||||||
|
if model.editableProjects.contains(where: { $0.id == model.projectID }) {
|
||||||
|
HStack {
|
||||||
|
TextField("新组别名称", text: $groupName).textFieldStyle(.roundedBorder).frame(maxWidth: 360)
|
||||||
|
Button {
|
||||||
|
let value = groupName
|
||||||
|
run { try await model.createGroup(value); groupName = "" }
|
||||||
|
} label: { Label("创建组别", systemImage: "plus") }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(groupName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || groupName.count > 80)
|
||||||
|
Spacer()
|
||||||
|
Text("在当前项目下新增组别").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if model.projectID == 0, !model.editableProjects.isEmpty {
|
||||||
|
Menu {
|
||||||
|
ForEach(model.editableProjects) { project in
|
||||||
|
Button(project.name) { model.projectID = project.id }
|
||||||
|
}
|
||||||
|
} label: { Label("选择项目并新建组", systemImage: "plus") }
|
||||||
|
}
|
||||||
|
if filteredGroups.isEmpty {
|
||||||
|
emptyState("暂无匹配组别", icon: "rectangle.3.group", message: "选择项目后可创建组别,或清空搜索查看已有组别。")
|
||||||
|
if model.access?.projects.isEmpty == true, model.access?.platform == true {
|
||||||
|
Button("创建项目") { tab = "项目与授权"; permissionSection = "项目管理" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Table(filteredGroups) {
|
||||||
|
TableColumn("组别", value: \.name)
|
||||||
|
TableColumn("所属项目", value: \.projectName)
|
||||||
|
TableColumn("组 ID") { Text("#\($0.id)").monospacedDigit() }.width(80)
|
||||||
|
TableColumn("状态") { statusBadge($0.active ? "已启用" : "已停用", positive: $0.active) }.width(80)
|
||||||
|
TableColumn("操作") { group in
|
||||||
|
HStack {
|
||||||
|
if group.canEdit {
|
||||||
|
Button {
|
||||||
|
renameValue = group.name
|
||||||
|
renameTarget = group
|
||||||
|
} label: { Label("重命名", systemImage: "pencil") }.help("重命名组别")
|
||||||
|
Button {
|
||||||
|
confirm("\(group.active ? "停用" : "恢复") \(group.name)?") { try await model.toggleGroup(group) }
|
||||||
|
} label: { Text(group.active ? "停用" : "恢复") }
|
||||||
|
.help(group.active ? "停用组别" : "恢复组别")
|
||||||
|
}
|
||||||
|
if model.availableGroups.contains(where: { $0.id == group.id }) {
|
||||||
|
Button {
|
||||||
|
model.groupID = group.id
|
||||||
|
memberSection = "成员列表"
|
||||||
|
tab = "成员与邀请"
|
||||||
|
} label: { Label("成员", systemImage: "person.3") }.help("管理本组成员")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.width(240)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("显示 \(filteredGroups.count) 个组别 · 修改和成员管理操作仅在已有权限范围内提供。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
|
||||||
|
confirmation = AdminAction(title: title) { run(work) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminLoginView: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
|
||||||
|
@State private var loginMode = "password"
|
||||||
|
@State private var loginUsername = ""
|
||||||
|
@State private var loginPassword = ""
|
||||||
|
var body: some View {
|
||||||
|
login.onChange(of: loginMode) { _, _ in model.cancelLogin(); loginPassword = "" }
|
||||||
|
}
|
||||||
|
private var login: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "lock.shield").font(.system(size: 40)).foregroundStyle(.teal)
|
||||||
|
Text("登录工作台").font(.title2.bold())
|
||||||
|
Text("使用管理员账号或微信扫码,继续处理项目报销。")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
Text("服务地址").font(.caption).foregroundStyle(.secondary).frame(width: 400, alignment: .leading)
|
||||||
|
HStack {
|
||||||
|
TextField("HTTPS 服务地址", text: $model.server).textFieldStyle(.roundedBorder)
|
||||||
|
if !model.serverHistory.isEmpty {
|
||||||
|
Menu {
|
||||||
|
ForEach(model.serverHistory, id: \.self) { address in
|
||||||
|
Button(address) { model.server = address }
|
||||||
|
}
|
||||||
|
} label: { Image(systemName: "clock.arrow.circlepath") }
|
||||||
|
.menuStyle(.borderlessButton).frame(width: 24).help("最近使用的服务地址")
|
||||||
|
}
|
||||||
|
}.frame(width: 400).disabled(model.busy)
|
||||||
|
Picker("登录方式", selection: $loginMode) {
|
||||||
|
Text("账号密码").tag("password")
|
||||||
|
Text("微信扫码").tag("wechat")
|
||||||
|
}.pickerStyle(.segmented).frame(width: 400)
|
||||||
|
Group {
|
||||||
|
if loginMode == "password" {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
Text("用户名").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
TextField("用户名", text: $loginUsername)
|
||||||
|
.textContentType(.username).textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityLabel("用户名")
|
||||||
|
Text("密码").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
SecureField("密码", text: $loginPassword)
|
||||||
|
.textContentType(.password).textFieldStyle(.roundedBorder)
|
||||||
|
.accessibilityLabel("密码").onSubmit { submitPassword() }
|
||||||
|
Button(action: submitPassword) { Text("登录管理端").frame(maxWidth: .infinity).padding(.vertical, 5) }
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || loginPassword.isEmpty)
|
||||||
|
}.frame(width: 400).disabled(model.busy)
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if let image = model.qr {
|
||||||
|
Image(nsImage: image).resizable().interpolation(.none).scaledToFit()
|
||||||
|
.frame(width: 240, height: 240).background(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: "qrcode").font(.system(size: 64)).foregroundStyle(.secondary)
|
||||||
|
.frame(width: 240, height: 240)
|
||||||
|
}
|
||||||
|
Button(model.busy ? "等待微信确认" : model.qr == nil ? "生成微信登录码" : "重新生成登录码") { model.startLogin() }
|
||||||
|
.buttonStyle(.borderedProminent).disabled(model.busy)
|
||||||
|
if model.busy { Button("取消扫码", action: model.cancelLogin) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(height: 320)
|
||||||
|
}.padding(24).background(AppTheme.surface).clipShape(RoundedRectangle(cornerRadius: AppTheme.radius))
|
||||||
|
.padding(.vertical, 24).frame(maxWidth: .infinity)
|
||||||
|
.frame(minHeight: geometry.size.height)
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func submitPassword() {
|
||||||
|
guard !model.busy, !loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !loginPassword.isEmpty else { return }
|
||||||
|
let password = loginPassword
|
||||||
|
let username = loginUsername
|
||||||
|
loginPassword = ""
|
||||||
|
Task { await model.passwordLogin(username: username, password: password) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminMembersTab: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
@Binding var tab: String
|
||||||
|
@Binding var memberSection: String
|
||||||
|
@Binding var role: String
|
||||||
|
@State private var memberSearch = ""
|
||||||
|
@State private var hours = 168
|
||||||
|
@State private var uses = 50
|
||||||
|
@State private var confirmation: AdminAction?
|
||||||
|
var body: some View {
|
||||||
|
members.adminConfirmation($confirmation)
|
||||||
|
}
|
||||||
|
private var filteredMembers: [AdminMember] {
|
||||||
|
model.members.filter { AdminDisplayFilter.matches(memberSearch, fields: [$0.name, $0.userId, memberState($0.status)]) }
|
||||||
|
}
|
||||||
|
private var members: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Picker("成员与邀请", selection: $memberSection) {
|
||||||
|
Text("成员列表").tag("成员列表")
|
||||||
|
Text("入组邀请").tag("入组邀请")
|
||||||
|
}.pickerStyle(.segmented).frame(width: 260)
|
||||||
|
Spacer()
|
||||||
|
if let group = model.selectedGroup { Text(group.label).font(.callout).foregroundStyle(.secondary) }
|
||||||
|
}
|
||||||
|
if model.groupID == 0 {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
emptyState(model.availableGroups.isEmpty ? "当前项目暂无可见组" : "请选择一个管理组", icon: "person.3", message: "在上方选择组别后,即可审核成员或发送入组邀请。")
|
||||||
|
if model.projectID != 0 {
|
||||||
|
Button("查看全部项目的组") { model.projectID = 0 }
|
||||||
|
}
|
||||||
|
if !model.editableProjects.isEmpty {
|
||||||
|
Button("组别管理") { tab = "组别管理" }
|
||||||
|
}
|
||||||
|
if model.access?.platform == true {
|
||||||
|
Button("项目与授权") { tab = "项目与授权" }
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if let group = model.selectedGroup, !group.canManage {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
emptyState("\(group.label) · 未授权", icon: "lock", message: "当前账户没有本组成员管理权限")
|
||||||
|
if model.access?.platform == true {
|
||||||
|
Button("项目负责人授权") {
|
||||||
|
model.projectID = group.projectId
|
||||||
|
role = "leader"
|
||||||
|
tab = "项目与授权"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
if memberSection == "成员列表" { memberList } else { invitationPanel }
|
||||||
|
}
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
private var memberList: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
searchField("搜索姓名、账户或状态", text: $memberSearch).frame(maxWidth: 340)
|
||||||
|
Spacer()
|
||||||
|
Text("已加载 \(model.members.count) 人 · 待审核 \(model.members.filter { $0.status == "pending" }.count) 人")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
Button { memberSection = "入组邀请" } label: { Label("邀请成员", systemImage: "person.badge.plus") }.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
ZStack {
|
||||||
|
Table(filteredMembers) {
|
||||||
|
TableColumn("姓名", value: \.name)
|
||||||
|
TableColumn("账户 ID", value: \.userId)
|
||||||
|
TableColumn("状态") { statusBadge(memberState($0.status), positive: $0.status == "active", pending: $0.status == "pending") }
|
||||||
|
TableColumn("操作") { m in
|
||||||
|
HStack {
|
||||||
|
if m.status == "pending" {
|
||||||
|
Button("批准入组") { run { try await model.reviewMember(m, status: "active") } }
|
||||||
|
Button("拒绝") { confirm("拒绝 \(m.name) 的入组申请?") { try await model.reviewMember(m, status: "rejected") } }
|
||||||
|
} else if m.status == "active" {
|
||||||
|
Button("停用") { confirm("停用 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "disabled") } }
|
||||||
|
} else {
|
||||||
|
Button("恢复") { confirm("恢复 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "active") } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.width(min: 200)
|
||||||
|
}.frame(minHeight: 180).clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
if filteredMembers.isEmpty {
|
||||||
|
emptyState("暂无匹配成员", icon: "person.2", message: "可以清空搜索,或通过邀请二维码让成员申请入组。")
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text("成员资格与管理员权限独立;停用、恢复等操作仅作用于当前组。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var invitationPanel: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
sectionCard("创建入组邀请", icon: "qrcode") {
|
||||||
|
Text("设置有效期和人数上限,生成海报后发给成员。扫码申请仍需管理员审核。")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
Stepper("有效期 \(hours) 小时", value: $hours, in: 1...720).frame(width: 240)
|
||||||
|
Stepper("最多 \(uses) 人", value: $uses, in: 1...1000).frame(width: 200)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Button { run { try await model.createInvite(hours: hours, uses: uses) } } label: { Label("生成邀请海报", systemImage: "qrcode") }.buttonStyle(.borderedProminent)
|
||||||
|
Button { run { try await model.retryInviteQR() } } label: { Label("重试加载二维码", systemImage: "arrow.clockwise") }
|
||||||
|
}
|
||||||
|
if let poster = model.invitationPoster {
|
||||||
|
HStack {
|
||||||
|
Image(nsImage: poster).resizable().interpolation(.high).scaledToFit().frame(width: 210, height: 280)
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("邀请海报已生成").font(.headline)
|
||||||
|
Text("二维码和有效期已经排版到海报中,可直接保存后发送给成员。").foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||||
|
Button { saveQR(poster, filename: "报销组入组邀请海报.png") } label: { Label("保存邀请海报", systemImage: "square.and.arrow.down") }.buttonStyle(.borderedProminent)
|
||||||
|
Button { run { try await model.retryInviteQR() } } label: { Label("重新生成海报", systemImage: "arrow.clockwise") }
|
||||||
|
}.frame(maxWidth: 380, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sectionCard("邀请记录", icon: "clock") {
|
||||||
|
if model.invites.isEmpty { Text("还没有邀请记录").foregroundStyle(.secondary) }
|
||||||
|
ForEach(model.invites) { invite in
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text("邀请 #\(invite.id) · 已使用 \(invite.uses) / \(invite.maxUses) 人").font(.callout.weight(.medium))
|
||||||
|
Text("有效期至 \(invite.expiresAt)").font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if invite.active {
|
||||||
|
Button("撤销邀请", role: .destructive) { confirm("撤销邀请 #\(invite.id)?") { try await model.revoke(invite) } }
|
||||||
|
} else { statusBadge("已撤销") }
|
||||||
|
}.padding(.vertical, 6)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: 1060).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
|
||||||
|
confirmation = AdminAction(title: title) { run(work) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminPermissionsTab: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
@Binding var role: String
|
||||||
|
@Binding var permissionSection: String
|
||||||
|
@State private var name = ""
|
||||||
|
@State private var selectedCandidate = ""
|
||||||
|
@State private var selectedGroups: Set<Int64> = []
|
||||||
|
@State private var confirmation: AdminAction?
|
||||||
|
var body: some View {
|
||||||
|
permissions.adminConfirmation($confirmation)
|
||||||
|
.onChange(of: model.projectID) { _, _ in selectedGroups = [] }
|
||||||
|
}
|
||||||
|
private var permissions: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Picker("项目与授权", selection: $permissionSection) {
|
||||||
|
Text("授权管理").tag("授权管理")
|
||||||
|
Text("现有授权").tag("现有授权")
|
||||||
|
if model.access?.platform == true { Text("项目管理").tag("项目管理") }
|
||||||
|
}.pickerStyle(.segmented).frame(maxWidth: 420)
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
if permissionSection == "项目管理", model.access?.platform == true {
|
||||||
|
sectionCard("项目管理", icon: "building.2") {
|
||||||
|
Text("创建项目,或调整项目启用状态。停用前请确认对成员业务的影响。")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
TextField("新项目名称", text: $name).textFieldStyle(.roundedBorder)
|
||||||
|
Button { run { try await model.createProject(name); name = "" } } label: { Label("创建项目", systemImage: "plus") }.disabled(name.isEmpty)
|
||||||
|
}
|
||||||
|
ForEach(model.access?.projects ?? []) { p in
|
||||||
|
HStack { Text(p.name); Text("#\(p.id)").foregroundStyle(.secondary); Spacer(); Button(p.active ? "停用项目" : "恢复项目") { confirm("确认\(p.active ? "停用" : "恢复")项目 \(p.name)?") { try await model.toggleProject(p) } } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if permissionSection == "现有授权" {
|
||||||
|
sectionCard("现有授权", icon: "person.crop.rectangle.badge.checkmark") {
|
||||||
|
Text("显示当前账号可查看的授权;撤销仅影响所选角色及范围。列表会同时显示姓名和账户 ID,便于准确识别成员。")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
if model.grants.isEmpty { Text("暂无可显示的授权记录").foregroundStyle(.secondary) }
|
||||||
|
ForEach(model.grants) { g in
|
||||||
|
let roleName = g.role == "leader" ? "项目负责人" : g.role == "group" ? "组管理员" : "平台管理员"
|
||||||
|
let scopeName = grantScopeName(g)
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: g.role == "platform" ? "crown" : "person.crop.circle")
|
||||||
|
.foregroundStyle(g.role == "platform" ? .orange : .accentColor)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(g.name.isEmpty ? "未设置姓名" : g.name).font(.headline)
|
||||||
|
Text(roleName).font(.caption.weight(.medium)).padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
|
.background(Color.accentColor.opacity(0.12)).clipShape(Capsule())
|
||||||
|
}
|
||||||
|
Text("账户 ID:\(g.userId) · \(scopeName)").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if g.role != "platform" {
|
||||||
|
Button("撤销授权") { confirm("撤销“\(g.name.isEmpty ? "账户 \(g.userId)" : g.name)”的\(roleName)授权?") { try await model.grant(userID: g.userId, role: g.role, scope: g.scopeId, revoke: true) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sectionCard("1 · 选择角色和范围", icon: "person.badge.key") {
|
||||||
|
Text(model.projectID == 0 ? "请先在上方选择项目;组管理员可勾选多个授权组。" : "当前项目:" + (model.access?.projects.first { $0.id == model.projectID }?.name ?? ""))
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
Picker("角色", selection: $role) {
|
||||||
|
if model.access?.platform == true { Text("项目负责人").tag("leader") }
|
||||||
|
Text("组管理员").tag("group")
|
||||||
|
}.pickerStyle(.segmented).frame(maxWidth: 400)
|
||||||
|
if role == "leader" {
|
||||||
|
Button {
|
||||||
|
let projectID = model.projectID
|
||||||
|
confirm("生成当前项目的负责人授权码?首位扫码并确认的账户将获得权限,有效期 5 分钟。") {
|
||||||
|
try await model.createGrantCode(role: "leader", scopes: [projectID])
|
||||||
|
}
|
||||||
|
} label: { Label("生成负责人授权码", systemImage: "qrcode") }
|
||||||
|
.disabled(model.access?.platform != true || !(model.access?.projects.contains(where: { $0.id == model.projectID && $0.active }) ?? false))
|
||||||
|
} else {
|
||||||
|
ForEach(model.availableGroups.filter { group in
|
||||||
|
group.memberStatus != "disabled" &&
|
||||||
|
(model.access?.platform == true || model.access?.projects.contains(where: { $0.id == group.projectId && $0.canLead }) == true)
|
||||||
|
}) { g in
|
||||||
|
Toggle(g.label, isOn: Binding(get: { selectedGroups.contains(g.id) }, set: { if $0 { selectedGroups.insert(g.id) } else { selectedGroups.remove(g.id) } }))
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
let scopes = selectedGroups.sorted()
|
||||||
|
confirm("生成 \(scopes.count) 个组的管理员授权码?首位扫码并确认的账户将获得权限,有效期 5 分钟。") {
|
||||||
|
try await model.createGrantCode(role: "group", scopes: scopes)
|
||||||
|
selectedGroups = []
|
||||||
|
}
|
||||||
|
} label: { Label("生成组管理员授权码", systemImage: "qrcode") }
|
||||||
|
.disabled(selectedGroups.isEmpty || selectedGroups.count > 50)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sectionCard("2 · 或直接授权给已有账户", icon: "person.crop.circle.badge.checkmark") {
|
||||||
|
Text("使用上面选择的角色和范围;也可使用上面的二维码,由接收者扫码确认。")
|
||||||
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
|
HStack {
|
||||||
|
Picker("授权用户", selection: $selectedCandidate) {
|
||||||
|
Text("请选择已登录微信用户").tag("")
|
||||||
|
ForEach(model.candidates) { user in
|
||||||
|
Text("\(user.name) · 账户 \(user.id)").tag(user.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 360)
|
||||||
|
Button {
|
||||||
|
let userID = selectedCandidate
|
||||||
|
let selectedRole = role
|
||||||
|
let scope = selectedRole == "leader" ? model.projectID : selectedGroups.sorted().first ?? 0
|
||||||
|
confirm("直接授权给账户 \(userID)?") {
|
||||||
|
if selectedRole == "group" {
|
||||||
|
for groupID in selectedGroups.sorted() {
|
||||||
|
try await model.grant(userID: userID, role: selectedRole, scope: groupID)
|
||||||
|
}
|
||||||
|
selectedGroups = []
|
||||||
|
} else {
|
||||||
|
try await model.grant(userID: userID, role: selectedRole, scope: scope)
|
||||||
|
}
|
||||||
|
selectedCandidate = ""
|
||||||
|
}
|
||||||
|
} label: { Label("直接授权", systemImage: "person.badge.key") }
|
||||||
|
.disabled(selectedCandidate.isEmpty ||
|
||||||
|
(role == "leader" ? model.projectID == 0 : selectedGroups.isEmpty))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let ticket = model.grantTicket {
|
||||||
|
sectionCard("二维码授权进度", icon: "qrcode") { grantCode(ticket) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: 1060).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}.padding(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func grantScopeName(_ grant: AdminGrant) -> String {
|
||||||
|
if grant.role == "platform" { return "全部项目与范围" }
|
||||||
|
if grant.role == "leader" {
|
||||||
|
return "项目:" + (model.access?.projects.first { $0.id == grant.scopeId }?.name ?? "范围 #\(grant.scopeId)")
|
||||||
|
}
|
||||||
|
return "组:" + (model.access?.groups.first { $0.id == grant.scopeId }?.label ?? "范围 #\(grant.scopeId)")
|
||||||
|
}
|
||||||
|
private func grantCode(_ ticket: AdminGrantTicket) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
Group {
|
||||||
|
if let image = model.grantImage, model.grantState == "pending" {
|
||||||
|
Image(nsImage: image).resizable().interpolation(.none).scaledToFit().background(.white)
|
||||||
|
} else {
|
||||||
|
Image(systemName: model.grantState == "confirmed" ? "checkmark.shield" : "qrcode")
|
||||||
|
.font(.system(size: 48)).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}.frame(width: 210, height: 210)
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(ticket.role == "leader" ? "项目负责人授权" : "组管理员授权").font(.headline)
|
||||||
|
ForEach(ticket.scopes) { scope in
|
||||||
|
Text(ticket.role == "leader" ? scope.projectName : "\(scope.projectName) / \(scope.name)")
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
Text(["pending": "等待微信扫码确认", "confirmed": "授权成功 · 账户 \(model.grantRecipient)", "expired": "授权码已过期", "revoked": "授权码已撤销"][model.grantState] ?? "")
|
||||||
|
if let expiry = ticket.expiryDate {
|
||||||
|
Text("到期:\(expiry.formatted(date: .omitted, time: .standard))").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
if model.grantState == "pending" {
|
||||||
|
Button { run { try await model.retryGrantImage() } } label: { Image(systemName: "qrcode") }.help("重新加载授权码")
|
||||||
|
if let image = model.grantImage {
|
||||||
|
Button { saveQR(image, filename: "管理员授权码.png") } label: { Image(systemName: "square.and.arrow.down") }.help("保存授权二维码")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
confirm("撤销此授权码?已保存的二维码也将失效。") { try await model.revokeGrantCode() }
|
||||||
|
} label: { Image(systemName: "xmark.circle") }.help("撤销授权码")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
|
||||||
|
confirmation = AdminAction(title: title) { run(work) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct AdminPoliciesTab: AdminPage {
|
||||||
|
@ObservedObject var model: AdminStore
|
||||||
|
|
||||||
|
@State private var selectedPolicyType = ""
|
||||||
|
@State private var policySearch = ""
|
||||||
|
@State private var showMaterialEditor = false
|
||||||
|
@State private var lastSavedPolicy: AdminExpensePolicy?
|
||||||
|
@State private var showTypeEditor = false
|
||||||
|
@State private var previousTypeName: String?
|
||||||
|
@State private var editedTypeName = ""
|
||||||
|
@State private var newPolicyMaterialName = ""
|
||||||
|
@State private var newPolicyMaterialHint = ""
|
||||||
|
@State private var newPolicyMaterialAllowsImage = true
|
||||||
|
@State private var newPolicyMaterialAllowsPDF = true
|
||||||
|
@State private var editingPolicyMaterialKind = ""
|
||||||
|
@State private var confirmation: AdminAction?
|
||||||
|
var body: some View {
|
||||||
|
policies.adminConfirmation($confirmation)
|
||||||
|
.onChange(of: model.policies.map(\.type), initial: true) { _, types in
|
||||||
|
if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" }
|
||||||
|
}
|
||||||
|
.onChange(of: selectedPolicyType) { _, _ in resetPolicyMaterialEditor() }
|
||||||
|
.sheet(isPresented: $showTypeEditor) { expenseTypeEditor }
|
||||||
|
.sheet(isPresented: $showMaterialEditor) {
|
||||||
|
if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) {
|
||||||
|
policyMaterialEditor(policy: $model.policies[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var expenseTypeEditor: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text(previousTypeName == nil ? "新增报销类型" : "重命名报销类型").font(.title3.bold())
|
||||||
|
TextField("类型名称,最多 40 字", text: $editedTypeName).textFieldStyle(.roundedBorder)
|
||||||
|
Text(previousTypeName == nil ? "新类型默认停用。创建后配置说明、提示和材料要求,再勾选启用并保存规则。" : "材料规则保持不变,历史报销记录保留提交时的类型名称。未提交的草稿需重新选择新名称。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
if let error = model.error { Text(error).foregroundStyle(.red) }
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("取消") { showTypeEditor = false }
|
||||||
|
Button("保存") {
|
||||||
|
let name = editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let previous = previousTypeName
|
||||||
|
run {
|
||||||
|
try await model.saveExpenseType(name: name, previous: previous)
|
||||||
|
policySearch = ""
|
||||||
|
selectedPolicyType = name
|
||||||
|
resetPolicyMaterialEditor()
|
||||||
|
showTypeEditor = false
|
||||||
|
}
|
||||||
|
}.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars.count > 40)
|
||||||
|
}
|
||||||
|
}.padding(24).frame(width: 440).disabled(model.busy).interactiveDismissDisabled(model.busy)
|
||||||
|
}
|
||||||
|
private var filteredPolicies: [AdminExpensePolicy] {
|
||||||
|
let query = policySearch.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return model.policies.filter { query.isEmpty || $0.type.localizedCaseInsensitiveContains(query) }
|
||||||
|
}
|
||||||
|
private func beginTypeEditor(_ policy: AdminExpensePolicy? = nil) {
|
||||||
|
model.error = nil
|
||||||
|
previousTypeName = policy?.type
|
||||||
|
editedTypeName = policy?.type ?? ""
|
||||||
|
showTypeEditor = true
|
||||||
|
}
|
||||||
|
private var expenseTypeManagement: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack {
|
||||||
|
Text("报销类型").font(.headline)
|
||||||
|
Text("\(model.policies.count)").font(.caption).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||||
|
TextField("搜索类型", text: $policySearch).textFieldStyle(.plain)
|
||||||
|
if !policySearch.isEmpty {
|
||||||
|
Button { policySearch = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("清空搜索")
|
||||||
|
}
|
||||||
|
}.padding(10).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 6) {
|
||||||
|
ForEach(filteredPolicies) { policy in
|
||||||
|
policySidebarRow(policy)
|
||||||
|
}
|
||||||
|
if filteredPolicies.isEmpty {
|
||||||
|
Text(model.policies.isEmpty ? "还没有报销类型" : "没有匹配的类型")
|
||||||
|
.font(.callout).foregroundStyle(.secondary).padding(.vertical, 24)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button { beginTypeEditor() } label: {
|
||||||
|
Label("新增报销类型", systemImage: "plus").frame(maxWidth: .infinity).padding(.vertical, 5)
|
||||||
|
}.buttonStyle(.borderedProminent).disabled(model.access?.platform != true)
|
||||||
|
Text("类型配置对所有项目生效")
|
||||||
|
.font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity)
|
||||||
|
}.padding(16).frame(width: 244).frame(maxHeight: .infinity)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}
|
||||||
|
private func policySidebarRow(_ policy: AdminExpensePolicy) -> some View {
|
||||||
|
let selected = policy.type == selectedPolicyType
|
||||||
|
return Button {
|
||||||
|
selectedPolicyType = policy.type
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: "doc.text").foregroundStyle(selected ? Color.accentColor : Color.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(policy.type).font(.system(size: 13, weight: selected ? .semibold : .regular))
|
||||||
|
.lineLimit(2).multilineTextAlignment(.leading)
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Circle().fill(policy.active ? Color.green : Color.secondary.opacity(0.45)).frame(width: 5, height: 5)
|
||||||
|
Text(policy.active ? "启用" : "停用").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if selected { Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(Color.accentColor) }
|
||||||
|
}.padding(12).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(selected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 9))
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}.buttonStyle(.plain).accessibilityLabel("\(policy.type),\(policy.active ? "启用" : "停用")")
|
||||||
|
.accessibilityAddTraits(selected ? [.isSelected] : [])
|
||||||
|
}
|
||||||
|
private var policies: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
expenseTypeManagement
|
||||||
|
Divider()
|
||||||
|
if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) {
|
||||||
|
policyDetail(policy: $model.policies[index])
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
Image(systemName: "doc.badge.plus").font(.system(size: 34)).foregroundStyle(Color.accentColor)
|
||||||
|
Text("从一个报销类型开始").font(.title3.weight(.semibold))
|
||||||
|
Text("新增类型后,设置说明和上传材料要求。")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("新增报销类型") { beginTypeEditor() }
|
||||||
|
.buttonStyle(.borderedProminent).disabled(model.access?.platform != true)
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
}.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.12)))
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
private func policyDetail(policy: Binding<AdminExpensePolicy>) -> some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(policy.wrappedValue.type).font(.title2.weight(.semibold))
|
||||||
|
Text("设置申请人看到的说明与材料要求").font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Toggle("启用此类型", isOn: policy.active).toggleStyle(.switch)
|
||||||
|
.disabled(model.access?.platform != true)
|
||||||
|
Menu {
|
||||||
|
Button("重命名类型") { beginTypeEditor(policy.wrappedValue) }
|
||||||
|
Divider()
|
||||||
|
Button("删除类型…", role: .destructive) {
|
||||||
|
let type = policy.wrappedValue.type
|
||||||
|
confirm("删除报销类型“\(type)”?将删除该类型及材料规则。历史报销记录和附件保留。") {
|
||||||
|
try await model.deleteExpenseType(type)
|
||||||
|
if selectedPolicyType == type { selectedPolicyType = model.policies.first?.type ?? "" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: { Image(systemName: "ellipsis.circle").font(.title3) }
|
||||||
|
.menuStyle(.borderlessButton).frame(width: 28)
|
||||||
|
.help("类型操作:重命名、删除").disabled(model.access?.platform != true)
|
||||||
|
}.padding(24)
|
||||||
|
Divider()
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
if model.access?.platform != true {
|
||||||
|
Label("只读 · 仅平台管理员可修改", systemImage: "lock").font(.callout).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
policyBasicInformation(policy: policy)
|
||||||
|
policyMaterialsSection(policy: policy)
|
||||||
|
}.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if lastSavedPolicy == policy.wrappedValue {
|
||||||
|
Label("规则已保存", systemImage: "checkmark.circle.fill").foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Text(model.access?.platform == true ? "修改后请保存 · 切换类型不会自动保存" : "当前为只读模式")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if model.access?.platform == true {
|
||||||
|
Button {
|
||||||
|
let value = policy.wrappedValue
|
||||||
|
run {
|
||||||
|
try await model.updatePolicy(value)
|
||||||
|
lastSavedPolicy = model.policies.first { $0.type == value.type }
|
||||||
|
}
|
||||||
|
} label: { Label("保存规则", systemImage: "checkmark").padding(.horizontal, 10) }
|
||||||
|
.buttonStyle(.borderedProminent).controlSize(.large)
|
||||||
|
}
|
||||||
|
}.font(.callout).padding(.horizontal, 24).padding(.vertical, 16)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}
|
||||||
|
private func policyBasicInformation(policy: Binding<AdminExpensePolicy>) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Label("基本信息", systemImage: "text.alignleft").font(.headline)
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
Text("报销内容说明").font(.callout.weight(.medium))
|
||||||
|
TextField("例如:项目期间产生的交通与出行费用", text: policy.description, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder).lineLimit(2...4)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
Text("申请提示").font(.callout.weight(.medium))
|
||||||
|
TextField("告诉申请人需要补充哪些信息", text: policy.tips, axis: .vertical)
|
||||||
|
.textFieldStyle(.roundedBorder).lineLimit(2...4)
|
||||||
|
}
|
||||||
|
}.disabled(model.access?.platform != true)
|
||||||
|
}
|
||||||
|
private func policyMaterialsSection(policy: Binding<AdminExpensePolicy>) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Label("上传材料", systemImage: "paperclip").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
resetPolicyMaterialEditor()
|
||||||
|
model.error = nil
|
||||||
|
showMaterialEditor = true
|
||||||
|
} label: { Label("添加材料", systemImage: "plus") }
|
||||||
|
.disabled(model.access?.platform != true)
|
||||||
|
}
|
||||||
|
Text("不使用的材料不会出现在申请表中;必填材料至少上传一份。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(model.uploadKinds.filter(\.active)) { kind in
|
||||||
|
policyMaterialRow(policy: policy, kind: kind)
|
||||||
|
}
|
||||||
|
if model.uploadKinds.filter(\.active).isEmpty {
|
||||||
|
Text("还没有材料类型,点击“添加材料”开始配置。")
|
||||||
|
.foregroundStyle(.secondary).padding(20).frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}.background(Color(nsColor: .windowBackgroundColor).opacity(0.55))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private func policyMaterialRow(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Image(systemName: kind.allowPDF ? "doc.text" : "photo")
|
||||||
|
.foregroundStyle(Color.accentColor).frame(width: 32, height: 36)
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(kind.name).font(.callout.weight(.medium))
|
||||||
|
Text(kind.hint.isEmpty ? materialFormats(kind) : kind.hint)
|
||||||
|
.font(.caption).foregroundStyle(.secondary).lineLimit(2).help(kind.hint)
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
materialModeButtons(policy: policy, kind: kind)
|
||||||
|
.disabled(model.access?.platform != true)
|
||||||
|
Menu {
|
||||||
|
Button("编辑材料…") {
|
||||||
|
editingPolicyMaterialKind = kind.kind
|
||||||
|
newPolicyMaterialName = kind.name
|
||||||
|
newPolicyMaterialHint = kind.hint
|
||||||
|
newPolicyMaterialAllowsImage = kind.allowImage
|
||||||
|
newPolicyMaterialAllowsPDF = kind.allowPDF
|
||||||
|
model.error = nil
|
||||||
|
showMaterialEditor = true
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("删除材料类型…", role: .destructive) {
|
||||||
|
confirm("删除材料类型“\(kind.name)”?将从所有报销类型中移除,历史附件保留。仅暂停使用请选择“不使用”并保存规则。") {
|
||||||
|
try await model.deleteUploadKind(kind)
|
||||||
|
if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: { Image(systemName: "ellipsis").frame(width: 20, height: 24) }
|
||||||
|
.menuStyle(.borderlessButton).frame(width: 28).help("材料操作:编辑、删除")
|
||||||
|
.disabled(model.access?.platform != true)
|
||||||
|
}.padding(14)
|
||||||
|
}
|
||||||
|
private func policyMaterialEditor(policy: Binding<AdminExpensePolicy>) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text(editingPolicyMaterialKind.isEmpty ? "添加上传材料" : "编辑上传材料").font(.title3.bold())
|
||||||
|
Text("用于“\(policy.wrappedValue.type)” · 保存时会一并保存当前类型的规则。")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("材料名称").font(.callout.weight(.medium))
|
||||||
|
TextField("例如:租赁合同", text: $newPolicyMaterialName).textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("上传提示").font(.callout.weight(.medium))
|
||||||
|
TextField("例如:请上传完整合同,包含签字页", text: $newPolicyMaterialHint).textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
Text("允许格式").font(.callout.weight(.medium))
|
||||||
|
Toggle("图片", isOn: $newPolicyMaterialAllowsImage)
|
||||||
|
Toggle("PDF", isOn: $newPolicyMaterialAllowsPDF)
|
||||||
|
}
|
||||||
|
if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) }
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button("取消") { showMaterialEditor = false; resetPolicyMaterialEditor() }
|
||||||
|
Button("保存材料") {
|
||||||
|
let name = newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let hint = newPolicyMaterialHint.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let kind = editingPolicyMaterialKind.isEmpty
|
||||||
|
? "custom-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased())"
|
||||||
|
: editingPolicyMaterialKind
|
||||||
|
let nextKind = AdminUploadKind(kind: kind, name: name, hint: hint,
|
||||||
|
allowImage: newPolicyMaterialAllowsImage, allowPDF: newPolicyMaterialAllowsPDF,
|
||||||
|
active: true, sortOrder: (model.uploadKinds.map(\.sortOrder).max() ?? 0) + 10)
|
||||||
|
var nextPolicy = policy.wrappedValue
|
||||||
|
if let index = nextPolicy.materials.firstIndex(where: { $0.kind == kind }) {
|
||||||
|
let current = nextPolicy.materials[index]
|
||||||
|
nextPolicy.materials[index] = AdminExpenseMaterial(kind: kind, name: name, hint: hint,
|
||||||
|
required: current.required, allowImage: nextKind.allowImage, allowPDF: nextKind.allowPDF,
|
||||||
|
active: true, sortOrder: current.sortOrder)
|
||||||
|
} else {
|
||||||
|
nextPolicy.materials.append(AdminExpenseMaterial(kind: kind, name: name, hint: hint,
|
||||||
|
required: false, allowImage: nextKind.allowImage, allowPDF: nextKind.allowPDF,
|
||||||
|
active: true, sortOrder: nextKind.sortOrder))
|
||||||
|
}
|
||||||
|
nextPolicy.requiredKinds = nextPolicy.materials.filter(\.required).map(\.kind)
|
||||||
|
run {
|
||||||
|
try await model.updateUploadKind(nextKind)
|
||||||
|
try await model.updatePolicy(nextPolicy)
|
||||||
|
lastSavedPolicy = model.policies.first { $0.type == nextPolicy.type }
|
||||||
|
showMaterialEditor = false
|
||||||
|
resetPolicyMaterialEditor()
|
||||||
|
}
|
||||||
|
}.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (!newPolicyMaterialAllowsImage && !newPolicyMaterialAllowsPDF))
|
||||||
|
}
|
||||||
|
}.padding(24).frame(width: 480).disabled(model.busy || model.access?.platform != true)
|
||||||
|
.interactiveDismissDisabled(model.busy)
|
||||||
|
}
|
||||||
|
private func materialFormats(_ kind: AdminUploadKind) -> String {
|
||||||
|
[kind.allowImage ? "图片" : nil, kind.allowPDF ? "PDF" : nil].compactMap { $0 }.joined(separator: " + ")
|
||||||
|
}
|
||||||
|
private func materialModeButtons(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind) -> some View {
|
||||||
|
let current = policy.wrappedValue.materials.first(where: { $0.kind == kind.kind })
|
||||||
|
let mode = current == nil ? "none" : (current?.required == true ? "required" : "optional")
|
||||||
|
return HStack(spacing: 0) {
|
||||||
|
materialModeButton("不使用", mode: "none", selected: mode == "none") {
|
||||||
|
setMaterialMode(policy: policy, kind: kind, mode: "none")
|
||||||
|
}
|
||||||
|
materialModeButton("选填", mode: "optional", selected: mode == "optional") {
|
||||||
|
setMaterialMode(policy: policy, kind: kind, mode: "optional")
|
||||||
|
}
|
||||||
|
materialModeButton("必填", mode: "required", selected: mode == "required") {
|
||||||
|
setMaterialMode(policy: policy, kind: kind, mode: "required")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 250, height: 28)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.separator))
|
||||||
|
}
|
||||||
|
private func materialModeButton(_ title: String, mode: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(title)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(selected ? Color.accentColor : Color.clear)
|
||||||
|
.foregroundStyle(selected ? .white : .primary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.help(mode == "none" ? "当前类型不需要此材料" : mode == "optional" ? "当前类型可以不上传此材料" : "当前类型必须上传此材料")
|
||||||
|
}
|
||||||
|
private func setMaterialMode(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind, mode: String) {
|
||||||
|
policy.wrappedValue.materials.removeAll { $0.kind == kind.kind }
|
||||||
|
if mode != "none" {
|
||||||
|
policy.wrappedValue.materials.append(AdminExpenseMaterial(
|
||||||
|
kind: kind.kind, name: kind.name, hint: kind.hint, required: mode == "required",
|
||||||
|
allowImage: kind.allowImage, allowPDF: kind.allowPDF, active: kind.active, sortOrder: kind.sortOrder
|
||||||
|
))
|
||||||
|
}
|
||||||
|
policy.wrappedValue.requiredKinds = policy.wrappedValue.materials.filter(\.required).map(\.kind)
|
||||||
|
}
|
||||||
|
private func resetPolicyMaterialEditor() {
|
||||||
|
editingPolicyMaterialKind = ""
|
||||||
|
newPolicyMaterialName = ""
|
||||||
|
newPolicyMaterialHint = ""
|
||||||
|
newPolicyMaterialAllowsImage = true
|
||||||
|
newPolicyMaterialAllowsPDF = true
|
||||||
|
}
|
||||||
|
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
|
||||||
|
confirmation = AdminAction(title: title) { run(work) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
|
|||||||
@Published var session: AdminSession?
|
@Published var session: AdminSession?
|
||||||
@Published var access: AdminAccess?
|
@Published var access: AdminAccess?
|
||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
|
@Published var notice: String?
|
||||||
|
@Published private(set) var serverHistory = UserDefaults.standard.stringArray(forKey: "admin.serverHistory") ?? []
|
||||||
@Published var busy = false
|
@Published var busy = false
|
||||||
@Published var qr: NSImage?
|
@Published var qr: NSImage?
|
||||||
@Published var invitationImage: NSImage?
|
@Published var invitationImage: NSImage?
|
||||||
@@ -115,6 +117,8 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
|
|||||||
do {
|
do {
|
||||||
try keychainSave(try JSONEncoder().encode(value))
|
try keychainSave(try JSONEncoder().encode(value))
|
||||||
try await reload()
|
try await reload()
|
||||||
|
serverHistory = Array(([origin] + serverHistory.filter { $0 != origin }).prefix(5))
|
||||||
|
UserDefaults.standard.set(serverHistory, forKey: "admin.serverHistory")
|
||||||
} catch {
|
} catch {
|
||||||
clearSession()
|
clearSession()
|
||||||
throw error
|
throw error
|
||||||
@@ -200,6 +204,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
|
|||||||
guard let r = selected else { return }
|
guard let r = selected else { return }
|
||||||
let _: Bool = try await request("/api/admin/expenses/\(r.id)/review", method: "POST", body: ["state": state, "reason": reason, "version": r.version])
|
let _: Bool = try await request("/api/admin/expenses/\(r.id)/review", method: "POST", body: ["state": state, "reason": reason, "version": r.version])
|
||||||
try await refreshExpenses()
|
try await refreshExpenses()
|
||||||
|
notice = state == "approved" ? "报销申请已批准" : "报销申请已退回"
|
||||||
}
|
}
|
||||||
func createInvite(hours: Int, uses: Int) async throws {
|
func createInvite(hours: Int, uses: Int) async throws {
|
||||||
let i: AdminInvite = try await request("/api/admin/invites", method: "POST", body: ["groupId": groupID, "hours": hours, "maxUses": uses])
|
let i: AdminInvite = try await request("/api/admin/invites", method: "POST", body: ["groupId": groupID, "hours": hours, "maxUses": uses])
|
||||||
@@ -398,6 +403,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
|
|||||||
throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。")
|
throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。")
|
||||||
}
|
}
|
||||||
mergeSavedPolicy(saved)
|
mergeSavedPolicy(saved)
|
||||||
|
notice = "“\(saved.type)”规则已保存"
|
||||||
}
|
}
|
||||||
func mergeSavedPolicy(_ saved: AdminExpensePolicy) {
|
func mergeSavedPolicy(_ saved: AdminExpensePolicy) {
|
||||||
if let index = policies.firstIndex(where: { $0.type == saved.type }) {
|
if let index = policies.firstIndex(where: { $0.type == saved.type }) {
|
||||||
@@ -463,6 +469,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
|
|||||||
throw AdminFailure(message: error?["msg"] as? String ?? "导出失败")
|
throw AdminFailure(message: error?["msg"] as? String ?? "导出失败")
|
||||||
}
|
}
|
||||||
try data.write(to: destination, options: .atomic)
|
try data.write(to: destination, options: .atomic)
|
||||||
|
notice = "凭证已保存至 \(destination.path)"
|
||||||
}
|
}
|
||||||
func download(_ file: AdminFile) async throws {
|
func download(_ file: AdminFile) async throws {
|
||||||
let panel = NSSavePanel(); panel.nameFieldStringValue = file.name
|
let panel = NSSavePanel(); panel.nameFieldStringValue = file.name
|
||||||
@@ -495,6 +502,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
|
|||||||
grantTask?.cancel(); grantTicket = nil; grantImage = nil; grantState = ""; grantRecipient = ""
|
grantTask?.cancel(); grantTicket = nil; grantImage = nil; grantState = ""; grantRecipient = ""
|
||||||
generation += 1; loginTask?.cancel(); session = nil; access = nil; qr = nil; selected = nil
|
generation += 1; loginTask?.cancel(); session = nil; access = nil; qr = nil; selected = nil
|
||||||
busy = false
|
busy = false
|
||||||
|
notice = nil
|
||||||
expenses = []; members = []; grants = []; candidates = []; managedGroups = []; invites = []; audit = []; policies = []; uploadKinds = []; invitationImage = nil; invitationPoster = nil
|
expenses = []; members = []; grants = []; candidates = []; managedGroups = []; invites = []; audit = []; policies = []; uploadKinds = []; invitationImage = nil; invitationPoster = nil
|
||||||
inviteScene = nil; invitationExpiresAt = nil; invitationMaxUses = 0
|
inviteScene = nil; invitationExpiresAt = nil; invitationMaxUses = 0
|
||||||
SecItemDelete(keychainQuery() as CFDictionary)
|
SecItemDelete(keychainQuery() as CFDictionary)
|
||||||
|
|||||||
+34
-1284
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,127 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum AppTheme {
|
||||||
|
static let accent = Color.teal
|
||||||
|
static let success = Color.green
|
||||||
|
static let warning = Color.orange
|
||||||
|
static let danger = Color.red
|
||||||
|
static let surface = Color(nsColor: .controlBackgroundColor)
|
||||||
|
static let canvas = Color(nsColor: .underPageBackgroundColor)
|
||||||
|
static let border = Color.primary.opacity(0.12)
|
||||||
|
static let page: CGFloat = 24
|
||||||
|
static let gap: CGFloat = 16
|
||||||
|
static let compact: CGFloat = 8
|
||||||
|
static let radius: CGFloat = 8
|
||||||
|
|
||||||
|
@MainActor static func sheetSize(width: CGFloat, height: CGFloat) -> CGSize {
|
||||||
|
let screen = NSApp.keyWindow?.screen ?? NSScreen.main
|
||||||
|
let available = screen?.visibleFrame.size ?? CGSize(width: 1280, height: 800)
|
||||||
|
return CGSize(width: min(width, max(320, available.width - 64)),
|
||||||
|
height: min(height, max(300, available.height - 80)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum NoticeTone {
|
||||||
|
case success, warning, danger, information
|
||||||
|
var color: Color {
|
||||||
|
switch self {
|
||||||
|
case .success: AppTheme.success
|
||||||
|
case .warning: AppTheme.warning
|
||||||
|
case .danger: AppTheme.danger
|
||||||
|
case .information: AppTheme.accent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var symbol: String {
|
||||||
|
switch self {
|
||||||
|
case .success: "checkmark.circle.fill"
|
||||||
|
case .warning: "exclamationmark.triangle.fill"
|
||||||
|
case .danger: "exclamationmark.octagon.fill"
|
||||||
|
case .information: "info.circle.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppNotice: View {
|
||||||
|
let text: String
|
||||||
|
var tone: NoticeTone = .information
|
||||||
|
var actionTitle: String?
|
||||||
|
var action: (() -> Void)?
|
||||||
|
var dismiss: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
Image(systemName: tone.symbol).foregroundStyle(tone.color)
|
||||||
|
.symbolEffect(.bounce, value: text)
|
||||||
|
Text(text).font(.callout).textSelection(.enabled)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
if let actionTitle, let action { Button(actionTitle, action: action).buttonStyle(.borderless) }
|
||||||
|
if let dismiss {
|
||||||
|
Button(action: dismiss) { Image(systemName: "xmark") }
|
||||||
|
.buttonStyle(.plain).help("关闭提示").accessibilityLabel("关闭提示")
|
||||||
|
}
|
||||||
|
}.padding(12).background(tone.color.opacity(0.08))
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppBadge: View {
|
||||||
|
let title: String
|
||||||
|
var symbol: String
|
||||||
|
var tone: NoticeTone = .information
|
||||||
|
var body: some View {
|
||||||
|
Label(title, systemImage: symbol)
|
||||||
|
.font(.caption.weight(.medium)).monospacedDigit()
|
||||||
|
.padding(.horizontal, 8).padding(.vertical, 4)
|
||||||
|
.foregroundStyle(tone.color)
|
||||||
|
.background(tone.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppSearchField: View {
|
||||||
|
let placeholder: String
|
||||||
|
@Binding var text: String
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||||
|
TextField(placeholder, text: $text).textFieldStyle(.plain)
|
||||||
|
.accessibilityLabel(placeholder)
|
||||||
|
Button { text = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
|
.buttonStyle(.plain).foregroundStyle(.secondary)
|
||||||
|
.opacity(text.isEmpty ? 0 : 1).disabled(text.isEmpty).help("清空搜索")
|
||||||
|
.accessibilityLabel("清空搜索")
|
||||||
|
}.padding(9).background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(AppTheme.border))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatNumber: View {
|
||||||
|
let title: String
|
||||||
|
let value: String
|
||||||
|
let symbol: String
|
||||||
|
var detail = ""
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Label(title, systemImage: symbol).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(value).font(.system(.title2, design: .rounded).weight(.semibold))
|
||||||
|
.monospacedDigit().lineLimit(1).minimumScaleFactor(0.7)
|
||||||
|
if !detail.isEmpty { Text(detail).font(.caption).foregroundStyle(.secondary) }
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let symbol: String
|
||||||
|
@ViewBuilder var content: Content
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: AppTheme.gap) {
|
||||||
|
Label(title, systemImage: symbol).font(.headline)
|
||||||
|
content
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkspaceCommand {
|
||||||
|
static let find = Notification.Name("ReceiptDesk.find")
|
||||||
|
}
|
||||||
+39
-22
@@ -3,7 +3,6 @@ import SwiftUI
|
|||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
@State private var confirmClear = false
|
@State private var confirmClear = false
|
||||||
@State private var showPayeeProfile = false
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
@@ -23,7 +22,13 @@ struct ContentView: View {
|
|||||||
Text(page.rawValue)
|
Text(page.rawValue)
|
||||||
Spacer()
|
Spacer()
|
||||||
if page == .pair { count(store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count) }
|
if page == .pair { count(store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count) }
|
||||||
if page == .matched { count(store.state.matches.count) }
|
if page == .matched {
|
||||||
|
count(store.state.matches.count)
|
||||||
|
if store.presentation.attentionCount > 0 {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(AppTheme.warning)
|
||||||
|
.help("\(store.presentation.attentionCount) 组需关注")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} icon: { Image(systemName: page.symbol) }
|
} icon: { Image(systemName: page.symbol) }
|
||||||
.padding(.vertical, 7).tag(page)
|
.padding(.vertical, 7).tag(page)
|
||||||
@@ -50,11 +55,13 @@ struct ContentView: View {
|
|||||||
}.padding().background(.teal.opacity(0.06))
|
}.padding().background(.teal.opacity(0.06))
|
||||||
}
|
}
|
||||||
if let notice = store.notice {
|
if let notice = store.notice {
|
||||||
HStack {
|
AppNotice(text: notice, tone: notice.hasPrefix("已取消") ? .information : .success,
|
||||||
Label(notice, systemImage: "checkmark.circle.fill").foregroundStyle(.teal)
|
actionTitle: store.lastExportURL == nil ? nil : "在访达中显示",
|
||||||
Spacer()
|
action: { if let url = store.lastExportURL { NSWorkspace.shared.activateFileViewerSelecting([url]) } },
|
||||||
Button { store.notice = nil } label: { Image(systemName: "xmark") }.buttonStyle(.plain)
|
dismiss: { store.notice = nil })
|
||||||
}.font(.callout).padding(12).background(.teal.opacity(0.06))
|
}
|
||||||
|
if let error = store.errorMessage {
|
||||||
|
AppNotice(text: error, tone: .danger, dismiss: { store.errorMessage = nil })
|
||||||
}
|
}
|
||||||
Group {
|
Group {
|
||||||
switch store.page ?? .scan {
|
switch store.page ?? .scan {
|
||||||
@@ -67,7 +74,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.tint(.teal)
|
.tint(.teal)
|
||||||
.frame(minWidth: 1050, minHeight: 700)
|
.frame(minWidth: 900, minHeight: 560)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: {
|
Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: {
|
||||||
@@ -75,7 +82,7 @@ struct ContentView: View {
|
|||||||
}.disabled(store.busy)
|
}.disabled(store.busy)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button { showPayeeProfile = true } label: {
|
Button { store.showPayeeProfile = true } label: {
|
||||||
Label("收款信息", systemImage: "person.crop.rectangle")
|
Label("收款信息", systemImage: "person.crop.rectangle")
|
||||||
}.disabled(store.busy)
|
}.disabled(store.busy)
|
||||||
}
|
}
|
||||||
@@ -84,9 +91,6 @@ struct ContentView: View {
|
|||||||
.disabled(store.busy).keyboardShortcut("o")
|
.disabled(store.busy).keyboardShortcut("o")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("处理未完成", isPresented: Binding(get: { store.errorMessage != nil }, set: { if !$0 { store.errorMessage = nil } })) {
|
|
||||||
Button("知道了") { store.errorMessage = nil }
|
|
||||||
} message: { Text(store.errorMessage ?? "") }
|
|
||||||
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
|
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
|
||||||
Button("仅清空本次报销数据(保留模板)", role: .destructive) { store.clear() }
|
Button("仅清空本次报销数据(保留模板)", role: .destructive) { store.clear() }
|
||||||
if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil {
|
if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil {
|
||||||
@@ -96,26 +100,36 @@ struct ContentView: View {
|
|||||||
} message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除。") }
|
} message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除。") }
|
||||||
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
|
.sheet(item: $store.preview) { item in MaterialPreview(item: item) }
|
||||||
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
|
.sheet(isPresented: $store.showExpense) { ExpenseSheet() }
|
||||||
.sheet(isPresented: $showPayeeProfile) { PayeeProfileSheet(profileStore: store.payeeProfile) }
|
.sheet(isPresented: $store.showPayeeProfile) {
|
||||||
|
PayeeProfileSheet(profileStore: store.payeeProfile, onSave: { store.notice = "收款信息已保存" })
|
||||||
|
}
|
||||||
.sheet(isPresented: $store.showExpenseTemplate) { ExpenseTemplateManager() }
|
.sheet(isPresented: $store.showExpenseTemplate) { ExpenseTemplateManager() }
|
||||||
|
.task(id: store.notice) {
|
||||||
|
guard let notice = store.notice else { return }
|
||||||
|
do { try await Task.sleep(for: .seconds(8)) } catch { return }
|
||||||
|
if store.notice == notice { store.notice = nil }
|
||||||
|
}
|
||||||
|
.onChange(of: store.notice) { _, notice in
|
||||||
|
if notice == nil || notice?.contains("已导出") != true { store.lastExportURL = nil }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var topbar: some View {
|
private var topbar: some View {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text("RECEIPT DESK").font(.caption2.weight(.semibold)).tracking(2).foregroundStyle(.secondary)
|
Text("本地贴票").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||||||
Text((store.page ?? .scan).rawValue).font(.title.bold())
|
Text((store.page ?? .scan).rawValue).font(.title2.bold())
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
VStack(alignment: .trailing, spacing: 5) {
|
VStack(alignment: .trailing, spacing: 5) {
|
||||||
Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对")
|
Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
HStack(spacing: 18) {
|
HStack(spacing: 18) {
|
||||||
Text("发票 \(currency(store.state.invoiceTotal))")
|
Label(currency(store.state.invoiceTotal), systemImage: "doc.text")
|
||||||
Text("付款 \(currency(store.state.paymentTotal))").foregroundStyle(.teal)
|
Label(currency(store.state.paymentTotal), systemImage: "creditcard")
|
||||||
}.font(.callout.weight(.semibold)).monospacedDigit()
|
}.font(.callout.weight(.semibold)).monospacedDigit()
|
||||||
}
|
}
|
||||||
}.padding(.horizontal, 28).padding(.vertical, 20)
|
}.padding(.horizontal, AppTheme.page).padding(.vertical, 16)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func count(_ number: Int) -> some View {
|
private func count(_ number: Int) -> some View {
|
||||||
@@ -128,8 +142,8 @@ struct Panel<Content: View>: View {
|
|||||||
@ViewBuilder var content: Content
|
@ViewBuilder var content: Content
|
||||||
var body: some View {
|
var body: some View {
|
||||||
content.padding(20).frame(maxWidth: .infinity, alignment: .leading)
|
content.padding(20).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 16))
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 16).stroke(.primary.opacity(0.06)))
|
.overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(AppTheme.border))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +151,14 @@ struct EmptyPanel: View {
|
|||||||
var symbol: String
|
var symbol: String
|
||||||
var title: String
|
var title: String
|
||||||
var message: String
|
var message: String
|
||||||
|
var actionTitle: String? = nil
|
||||||
|
var action: (() -> Void)? = nil
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
Image(systemName: symbol).font(.system(size: 42, weight: .light)).foregroundStyle(.teal)
|
Image(systemName: symbol).font(.system(size: 32, weight: .light)).foregroundStyle(.secondary)
|
||||||
Text(title).font(.title3.bold())
|
Text(title).font(.title3.bold())
|
||||||
Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
|
if !message.isEmpty { Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) }
|
||||||
|
if let actionTitle, let action { Button(actionTitle, action: action).buttonStyle(.borderedProminent) }
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding(40)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding(40)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor final class DockProgressView: NSView {
|
||||||
|
var progress: Double = 0
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
NSApp.applicationIconImage.draw(in: bounds)
|
||||||
|
let track = NSRect(x: bounds.width * 0.12, y: bounds.height * 0.1,
|
||||||
|
width: bounds.width * 0.76, height: bounds.height * 0.08)
|
||||||
|
NSColor.black.withAlphaComponent(0.7).setFill()
|
||||||
|
NSBezierPath(roundedRect: track, xRadius: 4, yRadius: 4).fill()
|
||||||
|
NSColor.systemTeal.setFill()
|
||||||
|
let fill = NSRect(x: track.minX + 2, y: track.minY + 2,
|
||||||
|
width: max(0, track.width - 4) * min(1, max(0, progress)), height: max(1, track.height - 4))
|
||||||
|
NSBezierPath(roundedRect: fill, xRadius: 2, yRadius: 2).fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspaceDockProgress: ViewModifier {
|
||||||
|
let busy: Bool
|
||||||
|
let progress: Double
|
||||||
|
@State private var dockView: DockProgressView?
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onChange(of: busy) { _, _ in update() }
|
||||||
|
.onChange(of: progress) { _, _ in update() }
|
||||||
|
.onDisappear { NSApp.dockTile.contentView = nil; NSApp.dockTile.display() }
|
||||||
|
}
|
||||||
|
private func update() {
|
||||||
|
guard busy else {
|
||||||
|
NSApp.dockTile.contentView = nil
|
||||||
|
NSApp.dockTile.display()
|
||||||
|
dockView = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let view = dockView ?? DockProgressView(frame: NSRect(origin: .zero, size: NSApp.dockTile.size))
|
||||||
|
view.progress = progress
|
||||||
|
view.needsDisplay = true
|
||||||
|
dockView = view
|
||||||
|
NSApp.dockTile.contentView = view
|
||||||
|
NSApp.dockTile.display()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,20 +16,21 @@ struct ExpenseTemplateSummary: View {
|
|||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
AppBadge(title: "模板", symbol: "doc.text", tone: .information)
|
||||||
Button(store.expenseTemplate == nil ? "选择模板…" : "查看 / 更换…", action: manage)
|
Button(store.expenseTemplate == nil ? "选择模板…" : "查看 / 更换…", action: manage)
|
||||||
.disabled(store.busy)
|
.disabled(store.busy)
|
||||||
}
|
}
|
||||||
.padding(14).background(.teal.opacity(targeted && !store.busy ? 0.12 : 0.05), in: RoundedRectangle(cornerRadius: 10))
|
.padding(16).background(targeted && !store.busy ? AppTheme.accent.opacity(0.12) : AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 10)
|
RoundedRectangle(cornerRadius: AppTheme.radius)
|
||||||
.strokeBorder(.teal.opacity(targeted && !store.busy ? 1 : 0.2), style: StrokeStyle(lineWidth: 1, dash: [5, 4]))
|
.strokeBorder(targeted && !store.busy ? AppTheme.accent : AppTheme.border, lineWidth: 1)
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
.dropDestination(for: URL.self) { sources, _ in
|
.dropDestination(for: URL.self) { sources, _ in
|
||||||
guard !store.busy else { return false }
|
guard !store.busy else { return false }
|
||||||
manage()
|
manage()
|
||||||
return store.importExpenseTemplates(sources)
|
return store.importExpenseTemplates(sources)
|
||||||
} isTargeted: { targeted = $0 }
|
} isTargeted: { value in withAnimation(.easeInOut(duration: 0.15)) { targeted = value } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ struct ExpenseTemplateManager: View {
|
|||||||
}
|
}
|
||||||
.dropDestination(for: URL.self) { sources, _ in
|
.dropDestination(for: URL.self) { sources, _ in
|
||||||
store.importExpenseTemplates(sources)
|
store.importExpenseTemplates(sources)
|
||||||
} isTargeted: { targeted = $0 }
|
} isTargeted: { value in withAnimation(.easeInOut(duration: 0.15)) { targeted = value } }
|
||||||
if store.busy {
|
if store.busy {
|
||||||
HStack { ProgressView().controlSize(.small); Text(store.status) }.font(.callout)
|
HStack { ProgressView().controlSize(.small); Text(store.status) }.font(.callout)
|
||||||
}
|
}
|
||||||
@@ -92,12 +93,12 @@ struct ExpenseTemplateManager: View {
|
|||||||
description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。"))
|
description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。"))
|
||||||
}
|
}
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(.white, in: RoundedRectangle(cornerRadius: 10))
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
Label("当前模板", systemImage: "checkmark.circle.fill").foregroundStyle(.teal)
|
Label("当前模板", systemImage: "checkmark.circle.fill").foregroundStyle(AppTheme.success)
|
||||||
Text(store.expenseTemplateName).font(.headline).textSelection(.enabled)
|
Text(store.expenseTemplateName).font(.headline).textSelection(.enabled)
|
||||||
if let template = store.expenseTemplate {
|
if let template = store.expenseTemplate {
|
||||||
TemplateMappingSummary(mapping: template.mapping)
|
TemplateMappingSummary(mapping: template.mapping)
|
||||||
@@ -120,10 +121,11 @@ struct ExpenseTemplateManager: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(22).frame(width: 1000, height: 700)
|
.padding(AppTheme.page).frame(width: AppTheme.sheetSize(width: 1100, height: 760).width,
|
||||||
|
height: AppTheme.sheetSize(width: 1100, height: 760).height)
|
||||||
.interactiveDismissDisabled(store.busy || store.templateDraft != nil)
|
.interactiveDismissDisabled(store.busy || store.templateDraft != nil)
|
||||||
.confirmationDialog("恢复内置报销单模板?", isPresented: $confirmRemove) {
|
.confirmationDialog("恢复内置报销单模板?", isPresented: $confirmRemove) {
|
||||||
Button("恢复内置模板", role: .destructive, action: store.removeExpenseTemplate)
|
Button("移除本机模板副本", role: .destructive, action: store.removeExpenseTemplate)
|
||||||
Button("取消", role: .cancel) {}
|
Button("取消", role: .cancel) {}
|
||||||
} message: { Text("仅移除本机模板副本,不影响本次报销数据、收款资料或原始 Excel。") }
|
} message: { Text("仅移除本机模板副本,不影响本次报销数据、收款资料或原始 Excel。") }
|
||||||
.task(id: store.expenseTemplate?.fileName) { savedFile = store.expenseTemplateFile }
|
.task(id: store.expenseTemplate?.fileName) { savedFile = store.expenseTemplateFile }
|
||||||
@@ -163,7 +165,7 @@ private struct ExpenseTemplateEditor: View {
|
|||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
Text("原始模板预览").font(.caption).foregroundStyle(.secondary)
|
Text("原始模板预览").font(.caption).foregroundStyle(.secondary)
|
||||||
SpreadsheetFilePreview(url: draft.source, preferredSheet: mapping.sheetName).id(draft.source)
|
SpreadsheetFilePreview(url: draft.source, preferredSheet: mapping.sheetName).id(draft.source)
|
||||||
.background(.white).clipShape(RoundedRectangle(cornerRadius: 10))
|
.background(AppTheme.surface).clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。")
|
Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
@@ -184,9 +186,9 @@ private struct ExpenseTemplateEditor: View {
|
|||||||
Divider()
|
Divider()
|
||||||
Text("请核对上面的填写位置。使用后,明细范围内的对应列及收款字段会被替换;未指定位置的内容会保留。")
|
Text("请核对上面的填写位置。使用后,明细范围内的对应列及收款字段会被替换;未指定位置的内容会保留。")
|
||||||
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
||||||
if mapping.columns["purpose"] == nil || mapping.columns["amount"] == nil {
|
if !TemplateMappingValidation.issues(mapping).isEmpty {
|
||||||
Label("尚未识别完整,请先调整填写位置。", systemImage: "exclamationmark.triangle")
|
AppNotice(text: TemplateMappingValidation.issues(mapping).joined(separator: ";"), tone: .warning,
|
||||||
.font(.caption).foregroundStyle(.orange)
|
actionTitle: "调整", action: { showFields = true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
@@ -194,12 +196,12 @@ private struct ExpenseTemplateEditor: View {
|
|||||||
Divider()
|
Divider()
|
||||||
Button("确认位置,使用此模板") { store.saveExpenseTemplate(mapping) }
|
Button("确认位置,使用此模板") { store.saveExpenseTemplate(mapping) }
|
||||||
.buttonStyle(.borderedProminent).controlSize(.large)
|
.buttonStyle(.borderedProminent).controlSize(.large)
|
||||||
.disabled(mapping.columns["purpose", default: ""].isEmpty || mapping.columns["amount", default: ""].isEmpty)
|
.disabled(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
Button("放弃更换") { store.discardTemplateDraft(); store.templateMessage = nil }
|
Button("放弃更换") { store.discardTemplateDraft(); store.templateMessage = nil }
|
||||||
}.frame(width: 285, alignment: .leading)
|
}.frame(width: 285, alignment: .leading)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showFields) {
|
.sheet(isPresented: $showFields) {
|
||||||
ExpenseTemplateFields(mapping: $mapping, inspection: draft.inspection)
|
ExpenseTemplateFields(mapping: $mapping, inspection: draft.inspection, source: draft.source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -207,7 +209,12 @@ private struct ExpenseTemplateEditor: View {
|
|||||||
private struct ExpenseTemplateFields: View {
|
private struct ExpenseTemplateFields: View {
|
||||||
@Binding var mapping: ExpenseTemplateMapping
|
@Binding var mapping: ExpenseTemplateMapping
|
||||||
let inspection: ExpenseTemplateInspection
|
let inspection: ExpenseTemplateInspection
|
||||||
|
let source: URL
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@FocusState private var focusedField: String?
|
||||||
|
@State private var activeField = "column:purpose"
|
||||||
|
@State private var selectedReference: String?
|
||||||
|
@State private var hoveredRow: Int?
|
||||||
private let detailFields = [("purpose", "用途 *"), ("amount", "金额 *"), ("count", "单据数量"),
|
private let detailFields = [("purpose", "用途 *"), ("amount", "金额 *"), ("count", "单据数量"),
|
||||||
("invoiceType", "票据类型"), ("sequence", "序号"), ("category", "费用类别"), ("remarks", "备注(清空旧值)")]
|
("invoiceType", "票据类型"), ("sequence", "序号"), ("category", "费用类别"), ("remarks", "备注(清空旧值)")]
|
||||||
private let cells = [("recipient", "收款人"), ("bankName", "开户行"), ("accountNumber", "账号"),
|
private let cells = [("recipient", "收款人"), ("bankName", "开户行"), ("accountNumber", "账号"),
|
||||||
@@ -218,47 +225,67 @@ private struct ExpenseTemplateFields: View {
|
|||||||
HStack {
|
HStack {
|
||||||
Text("调整填写位置").font(.title2.bold())
|
Text("调整填写位置").font(.title2.bold())
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Button("关闭") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
Button("完成") { dismiss() }.keyboardShortcut(.defaultAction)
|
Button("完成") { dismiss() }.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
}
|
}
|
||||||
Text("仅格式识别不正确时需要调整。列填字母(如 C),单元格填地址(如 C23),合并区域填左上角。")
|
Text("仅格式识别不正确时需要调整。列填字母(如 C),单元格填地址(如 C23),合并区域填左上角。")
|
||||||
.font(.callout).foregroundStyle(.secondary)
|
.font(.callout).foregroundStyle(.secondary)
|
||||||
ScrollView {
|
HStack(alignment: .top, spacing: 20) {
|
||||||
|
SpreadsheetFilePreview(url: source, preferredSheet: mapping.sheetName,
|
||||||
|
selectedReference: selectedReference, highlightedRow: hoveredRow,
|
||||||
|
onSelectCell: selectCell)
|
||||||
|
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("明细开始行")
|
Text("明细开始行")
|
||||||
TextField("9", value: $mapping.startRow, format: .number.grouping(.never)).frame(width: 70)
|
TextField("9", value: $mapping.startRow, format: .number.grouping(.never)).frame(width: 55).focused($focusedField, equals: "start")
|
||||||
Text("结束行")
|
Text("结束行")
|
||||||
TextField("21", value: $mapping.endRow, format: .number.grouping(.never)).frame(width: 70)
|
TextField("21", value: $mapping.endRow, format: .number.grouping(.never)).frame(width: 55).focused($focusedField, equals: "end")
|
||||||
}
|
}
|
||||||
Text("包括全部旧明细,不包含表头及合计;容量不足时自动复制报销工作表分页。")
|
Text("包括全部旧明细,不包含表头及合计;容量不足时自动复制报销工作表分页。")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Text("明细列").font(.headline)
|
Text("明细列").font(.headline)
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(detailFields, id: \.0) { key, title in
|
ForEach(detailFields, id: \.0) { key, title in
|
||||||
HStack {
|
HStack {
|
||||||
Text(title).frame(width: 120, alignment: .leading)
|
Text(title).frame(width: 120, alignment: .leading)
|
||||||
|
.foregroundStyle(activeField == "column:" + key ? AppTheme.accent : .primary)
|
||||||
TextField("不填充", text: Binding(get: { mapping.columns[key] ?? "" },
|
TextField("不填充", text: Binding(get: { mapping.columns[key] ?? "" },
|
||||||
set: { mapping.columns[key] = $0.uppercased() }))
|
set: { mapping.columns[key] = $0.uppercased() }))
|
||||||
.accessibilityLabel(title + "列")
|
.accessibilityLabel(title + "列")
|
||||||
|
.focused($focusedField, equals: "column:" + key)
|
||||||
|
if (key == "purpose" || key == "amount" || !(mapping.columns[key] ?? "").isEmpty) &&
|
||||||
|
!TemplateMappingValidation.column(mapping.columns[key, default: ""]) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(AppTheme.warning).help("请输入 A–IV 之间的列字母")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("收款与合计").font(.headline)
|
Text("收款与合计").font(.headline)
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
ForEach(cells, id: \.0) { key, title in
|
ForEach(cells, id: \.0) { key, title in
|
||||||
HStack {
|
HStack {
|
||||||
Text(title).frame(width: 120, alignment: .leading)
|
Text(title).frame(width: 120, alignment: .leading)
|
||||||
|
.foregroundStyle(activeField == "cell:" + key ? AppTheme.accent : .primary)
|
||||||
TextField("不填充", text: Binding(get: { mapping.cells[key] ?? "" },
|
TextField("不填充", text: Binding(get: { mapping.cells[key] ?? "" },
|
||||||
set: { mapping.cells[key] = $0.uppercased() }))
|
set: { mapping.cells[key] = $0.uppercased() }))
|
||||||
.accessibilityLabel(title + "单元格")
|
.accessibilityLabel(title + "单元格")
|
||||||
|
.focused($focusedField, equals: "cell:" + key)
|
||||||
|
if let value = mapping.cells[key], !value.isEmpty, !TemplateMappingValidation.address(value) {
|
||||||
|
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(AppTheme.warning).help("请输入有效单元格地址,例如 C23")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LabeledContent("签字岗位") {
|
LabeledContent("签字岗位") {
|
||||||
TextField("例如 A30,D30,A31;最多 6 项", text: $mapping.signatureCells)
|
TextField("例如 A30,D30,A31;最多 6 项", text: $mapping.signatureCells)
|
||||||
|
.focused($focusedField, equals: "signature")
|
||||||
}
|
}
|
||||||
LabeledContent("额外清空") {
|
LabeledContent("额外清空") {
|
||||||
TextField("例如 C27,I27;多个地址用逗号分隔", text: $mapping.clearCells)
|
TextField("例如 C27,I27;多个地址用逗号分隔", text: $mapping.clearCells)
|
||||||
|
.focused($focusedField, equals: "clear")
|
||||||
}
|
}
|
||||||
Text("收款位置用本机资料替换,未保存资料则清空;签字岗位按导出弹窗中的顺序填入。留空的配置不改动模板原文。")
|
Text("收款位置用本机资料替换,未保存资料则清空;签字岗位按导出弹窗中的顺序填入。留空的配置不改动模板原文。")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
@@ -266,10 +293,40 @@ private struct ExpenseTemplateFields: View {
|
|||||||
ForEach(Array((inspection.sheets.first(where: { $0.name == mapping.sheetName })?.preview ?? []).enumerated()), id: \.offset) { _, row in
|
ForEach(Array((inspection.sheets.first(where: { $0.name == mapping.sheetName })?.preview ?? []).enumerated()), id: \.offset) { _, row in
|
||||||
Text(row).font(.caption.monospaced()).textSelection(.enabled)
|
Text(row).font(.caption.monospaced()).textSelection(.enabled)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 3)
|
.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 3)
|
||||||
|
.onHover { inside in
|
||||||
|
hoveredRow = inside ? Int(row.prefix(while: \.isNumber)) : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.padding(.trailing, 8)
|
}.padding(.trailing, 8)
|
||||||
|
}.frame(width: 360)
|
||||||
}
|
}
|
||||||
}.padding(24).frame(width: 750, height: 600).textFieldStyle(.roundedBorder)
|
if !TemplateMappingValidation.issues(mapping).isEmpty {
|
||||||
|
AppNotice(text: "还需调整 \(TemplateMappingValidation.issues(mapping).count) 项:" +
|
||||||
|
TemplateMappingValidation.issues(mapping).joined(separator: ";"), tone: .warning)
|
||||||
|
}
|
||||||
|
}.padding(AppTheme.page)
|
||||||
|
.frame(width: AppTheme.sheetSize(width: 1200, height: 760).width,
|
||||||
|
height: AppTheme.sheetSize(width: 1200, height: 760).height).textFieldStyle(.roundedBorder)
|
||||||
|
.onChange(of: focusedField) { _, field in if let field { activeField = field } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func selectCell(_ reference: String) {
|
||||||
|
selectedReference = reference
|
||||||
|
if activeField.hasPrefix("column:") {
|
||||||
|
mapping.columns[String(activeField.dropFirst(7))] = String(reference.prefix(while: \.isLetter))
|
||||||
|
} else if activeField.hasPrefix("cell:") {
|
||||||
|
mapping.cells[String(activeField.dropFirst(5))] = reference
|
||||||
|
} else if activeField == "start" {
|
||||||
|
mapping.startRow = Int(reference.filter(\.isNumber)) ?? mapping.startRow
|
||||||
|
} else if activeField == "end" {
|
||||||
|
mapping.endRow = Int(reference.filter(\.isNumber)) ?? mapping.endRow
|
||||||
|
} else {
|
||||||
|
let previous = activeField == "signature" ? mapping.signatureCells : mapping.clearCells
|
||||||
|
var values = TemplateMappingValidation.references(previous)
|
||||||
|
if !values.contains(reference) { values.append(reference) }
|
||||||
|
if activeField == "signature" { mapping.signatureCells = values.joined(separator: ",") }
|
||||||
|
else { mapping.clearCells = values.joined(separator: ",") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MatchedCategoryBoard: View {
|
||||||
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
let columns: [MatchCategoryGroup]
|
||||||
|
@Binding var selection: MatchedPPTSelection
|
||||||
|
@State private var inspected: MatchGroup?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let width = MatchedBoardLayout.columnWidth(available: geometry.size.width - MatchedBoardLayout.scrollGutter,
|
||||||
|
count: columns.count)
|
||||||
|
let contentWidth = width * CGFloat(columns.count) + CGFloat(max(0, columns.count - 1)) * MatchedBoardLayout.gap
|
||||||
|
let rowCount = columns.map(\.matches.count).max() ?? 0
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack(spacing: MatchedBoardLayout.gap) {
|
||||||
|
ForEach(columns) { column in
|
||||||
|
categoryHeader(column).frame(width: width, height: MatchedBoardLayout.headerHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
ScrollView(.vertical) {
|
||||||
|
LazyVStack(alignment: .leading, spacing: MatchedBoardLayout.gap) {
|
||||||
|
ForEach(0..<rowCount, id: \.self) { row in
|
||||||
|
HStack(alignment: .top, spacing: MatchedBoardLayout.gap) {
|
||||||
|
ForEach(columns) { column in
|
||||||
|
Group {
|
||||||
|
if column.matches.indices.contains(row) {
|
||||||
|
let match = column.matches[row]
|
||||||
|
MatchedReceiptTile(match: match, selection: $selection) { inspected = match }
|
||||||
|
} else if row == 0 {
|
||||||
|
Text("暂无").font(.caption).foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||||
|
.padding(.top, 24)
|
||||||
|
} else {
|
||||||
|
Color.clear
|
||||||
|
}
|
||||||
|
}.frame(width: width, height: MatchedBoardLayout.tileHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.padding(.vertical, 10)
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
}.frame(width: contentWidth + MatchedBoardLayout.scrollGutter, height: max(1, geometry.size.height - 14))
|
||||||
|
}.scrollIndicators(.visible)
|
||||||
|
}.frame(minHeight: 220)
|
||||||
|
.overlay(alignment: .bottom) { Divider() }
|
||||||
|
.sheet(item: $inspected) { match in
|
||||||
|
MatchedGroupInspector(matchID: match.id, orderedIDs: columns.flatMap(\.matches).map(\.id),
|
||||||
|
selection: $selection) { id in
|
||||||
|
inspected = store.state.matches.first { $0.id == id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func categoryHeader(_ column: MatchCategoryGroup) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Toggle("全选\(column.title)列当前结果", isOn: Binding(
|
||||||
|
get: { selection.containsAll(column.matches) },
|
||||||
|
set: { selection.setVisible(column.matches, selected: $0) }
|
||||||
|
)).toggleStyle(.checkbox).labelsHidden().disabled(column.matches.isEmpty)
|
||||||
|
.help("全选或取消本列当前结果")
|
||||||
|
Text(column.title).font(.callout.weight(.semibold)).lineLimit(1).help(column.title)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Text("\(column.matches.count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(currency(column.invoiceTotal)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.8)
|
||||||
|
.help("发票合计 \(currency(column.invoiceTotal)) · 付款合计 \(currency(column.paymentTotal))")
|
||||||
|
}.padding(.horizontal, 6).frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityLabel("\(column.title),\(column.matches.count) 组")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct MatchedReceiptTile: View {
|
||||||
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
let match: MatchGroup
|
||||||
|
@Binding var selection: MatchedPPTSelection
|
||||||
|
let inspect: () -> Void
|
||||||
|
@State private var hovered = false
|
||||||
|
private var selected: Bool { selection.ids.contains(match.id) }
|
||||||
|
private var number: Int { store.presentation.matchNumbers[match.id, default: 1] }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack(spacing: 5) {
|
||||||
|
Toggle("选择第 \(number) 组", isOn: Binding(
|
||||||
|
get: { selected },
|
||||||
|
set: { if $0 { selection.ids.insert(match.id) } else { selection.ids.remove(match.id) } }
|
||||||
|
)).toggleStyle(.checkbox).labelsHidden()
|
||||||
|
Text(String(format: "%02d", number)).font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: match.needsAttention ? "exclamationmark.circle.fill" :
|
||||||
|
match.matchType == "auto" ? "checkmark.seal" : "person.crop.circle")
|
||||||
|
.font(.caption).foregroundStyle(match.needsAttention ? AppTheme.warning : .secondary)
|
||||||
|
.help(match.needsAttention ? "需关注,点击凭证查看核对依据" : match.matchType == "auto" ? "自动核对 \(match.evidenceLabel)" : "人工确认")
|
||||||
|
}
|
||||||
|
Button(action: inspect) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
thumbnail(match.invoices.first, symbol: "doc.text")
|
||||||
|
thumbnail(match.payments.first, symbol: "creditcard")
|
||||||
|
}.frame(height: 52).accessibilityHidden(true)
|
||||||
|
Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal))
|
||||||
|
.font(.headline).monospacedDigit().foregroundStyle(.primary)
|
||||||
|
.lineLimit(1).minimumScaleFactor(0.75)
|
||||||
|
Text(match.date.isEmpty || match.date == "9999-12-31" ? "未识别日期" : match.date)
|
||||||
|
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Label("\(match.invoices.count)", systemImage: "doc.text")
|
||||||
|
Label("\(match.payments.count)", systemImage: "creditcard")
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
|
}.font(.caption2).foregroundStyle(.secondary)
|
||||||
|
}.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle())
|
||||||
|
}.buttonStyle(.plain).help("查看本组凭证")
|
||||||
|
.accessibilityLabel("第 \(number) 组,\(match.category.isEmpty ? "未分类" : match.category),\(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)),查看 \(match.invoices.count) 张发票和 \(match.payments.count) 张付款凭证")
|
||||||
|
}.padding(8).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.background(selected ? AppTheme.accent.opacity(0.08) : hovered ? Color.primary.opacity(0.035) : AppTheme.surface,
|
||||||
|
in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(selected ? AppTheme.accent : AppTheme.border, lineWidth: selected ? 1.5 : 1))
|
||||||
|
.onHover { hovered = $0 }
|
||||||
|
.contextMenu {
|
||||||
|
Button("查看凭证", systemImage: "arrow.up.left.and.arrow.down.right", action: inspect)
|
||||||
|
Menu("修改类型") {
|
||||||
|
ForEach(expenseCategories, id: \.self) { category in
|
||||||
|
Button(category) { store.updateCategory(match, category: category) }
|
||||||
|
}
|
||||||
|
}.disabled(store.busy)
|
||||||
|
Button("撤销配对", systemImage: "arrow.uturn.backward") { store.undo(match) }.disabled(store.busy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder private func thumbnail(_ item: Material?, symbol: String) -> some View {
|
||||||
|
if let item {
|
||||||
|
MaterialThumbnail(item: item, height: 52, pixels: 240)
|
||||||
|
} else {
|
||||||
|
Image(systemName: symbol).foregroundStyle(.tertiary)
|
||||||
|
.frame(maxWidth: .infinity).frame(height: 52).background(AppTheme.canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchedGroupInspector: View {
|
||||||
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let matchID: String
|
||||||
|
let orderedIDs: [String]
|
||||||
|
@Binding var selection: MatchedPPTSelection
|
||||||
|
let navigate: (String) -> Void
|
||||||
|
@State private var preview: Material?
|
||||||
|
@State private var hoveredMaterialID: String?
|
||||||
|
|
||||||
|
private var match: MatchGroup? { store.state.matches.first { $0.id == matchID } }
|
||||||
|
private var position: Int? { orderedIDs.firstIndex(of: matchID) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
if let match {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("第 \(store.presentation.matchNumbers[match.id, default: 1]) 组凭证").font(.title2.bold())
|
||||||
|
Text(match.date.isEmpty || match.date == "9999-12-31" ? "未识别日期" : match.date)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button { move(-1) } label: { Image(systemName: "chevron.left") }
|
||||||
|
.disabled(position == nil || position == 0).help("上一组")
|
||||||
|
Button { move(1) } label: { Image(systemName: "chevron.right") }
|
||||||
|
.disabled(position == nil || position == orderedIDs.count - 1).help("下一组")
|
||||||
|
Button { dismiss() } label: { Image(systemName: "xmark") }
|
||||||
|
.keyboardShortcut(.cancelAction).help("关闭凭证详情")
|
||||||
|
}
|
||||||
|
HStack(spacing: 24) {
|
||||||
|
amount("发票合计", value: match.invoiceTotal)
|
||||||
|
amount("付款合计", value: match.paymentTotal)
|
||||||
|
Spacer()
|
||||||
|
MatchConfidenceCell(match: match)
|
||||||
|
Picker("类型", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
|
||||||
|
ForEach(expenseCategories + (expenseCategories.contains(match.category) ? [] : [match.category]), id: \.self) {
|
||||||
|
Text($0.isEmpty ? "未分类" : $0).tag($0)
|
||||||
|
}
|
||||||
|
}.frame(width: 170).disabled(store.busy)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
HStack(alignment: .top, spacing: 20) {
|
||||||
|
materials("发票", items: match.invoices, symbol: "doc.text")
|
||||||
|
Divider()
|
||||||
|
materials("付款凭证", items: match.payments, symbol: "creditcard")
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Button {
|
||||||
|
dismiss()
|
||||||
|
store.undo(match)
|
||||||
|
} label: { Label("撤销配对", systemImage: "arrow.uturn.backward") }.disabled(store.busy)
|
||||||
|
Spacer()
|
||||||
|
Toggle("加入导出选择", isOn: Binding(
|
||||||
|
get: { selection.ids.contains(match.id) },
|
||||||
|
set: { if $0 { selection.ids.insert(match.id) } else { selection.ids.remove(match.id) } }
|
||||||
|
)).toggleStyle(.checkbox)
|
||||||
|
Button("完成") { dismiss() }.buttonStyle(.borderedProminent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyPanel(symbol: "doc.badge.exclamationmark", title: "此组材料已撤销", message: "",
|
||||||
|
actionTitle: "关闭") { dismiss() }
|
||||||
|
}
|
||||||
|
}.padding(AppTheme.page)
|
||||||
|
.frame(width: AppTheme.sheetSize(width: 1040, height: 740).width,
|
||||||
|
height: AppTheme.sheetSize(width: 1040, height: 740).height)
|
||||||
|
.sheet(item: $preview) { MaterialPreview(item: $0) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func move(_ offset: Int) {
|
||||||
|
guard let position, orderedIDs.indices.contains(position + offset) else { return }
|
||||||
|
navigate(orderedIDs[position + offset])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func amount(_ title: String, value: Double) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title).font(.caption).foregroundStyle(.secondary)
|
||||||
|
Text(currency(value)).font(.headline).monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func materials(_ title: String, items: [Material], symbol: String) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Label(title, systemImage: symbol).font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(items.count) 份").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 16) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
Button { preview = item } label: {
|
||||||
|
MaterialThumbnail(item: item, height: 240)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}.buttonStyle(.plain)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if hoveredMaterialID == item.id {
|
||||||
|
Text(item.name)
|
||||||
|
.font(.caption).foregroundStyle(.primary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.padding(.horizontal, 10).padding(.vertical, 6)
|
||||||
|
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppTheme.border))
|
||||||
|
.padding(8)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onHover { hovering in
|
||||||
|
if hovering {
|
||||||
|
hoveredMaterialID = item.id
|
||||||
|
} else if hoveredMaterialID == item.id {
|
||||||
|
hoveredMaterialID = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
if hoveredMaterialID == item.id { hoveredMaterialID = nil }
|
||||||
|
}
|
||||||
|
.accessibilityLabel("放大预览 \(item.name)")
|
||||||
|
}
|
||||||
|
if items.isEmpty {
|
||||||
|
Text("没有\(title)").font(.callout).foregroundStyle(.secondary).padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
-152
@@ -2,165 +2,112 @@ import SwiftUI
|
|||||||
|
|
||||||
struct MatchedPage: View {
|
struct MatchedPage: View {
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
@State private var selectedGroup = "全部"
|
|
||||||
@State private var pptSelection = MatchedPPTSelection()
|
@State private var pptSelection = MatchedPPTSelection()
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var attentionOnly = false
|
||||||
|
@State private var order: MatchOrder = .date
|
||||||
|
@State private var descending = false
|
||||||
|
@State private var showExportHelp = false
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
private var selectedMatches: [MatchGroup] {
|
private var selectedMatches: [MatchGroup] {
|
||||||
store.state.matches.filter { pptSelection.ids.contains($0.id) }
|
store.state.matches.filter { pptSelection.ids.contains($0.id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var groups: [String] {
|
private var visibleMatches: [MatchGroup] {
|
||||||
["全部"] + Array(Set(store.state.matches.map(\.category))).sorted()
|
WorkspaceFilter.matches(store.presentation.sortedMatches, query: query, category: "全部",
|
||||||
|
attentionOnly: attentionOnly, order: order, descending: descending)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 18) {
|
let matches = visibleMatches
|
||||||
HStack {
|
let selected = selectedMatches
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
let columns = store.presentation.categoryColumns(for: matches)
|
||||||
Text("已核对材料").font(.title2.bold())
|
VStack(spacing: 12) {
|
||||||
Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary)
|
HStack(spacing: 12) {
|
||||||
}
|
AppSearchField(placeholder: "金额、商户、日期或文件名", text: $query).focused($searchFocused)
|
||||||
Spacer()
|
Toggle("需关注 \(store.presentation.attentionCount)", isOn: $attentionOnly)
|
||||||
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0)
|
.toggleStyle(.checkbox).fixedSize()
|
||||||
Button { store.openExpense(selectedMatchIDs: pptSelection.ids) } label: {
|
Picker("排序", selection: $order) {
|
||||||
Label("所选报销单", systemImage: "tablecells")
|
ForEach(MatchOrder.allCases) { Text($0.rawValue).tag($0) }
|
||||||
}.disabled(selectedMatches.isEmpty)
|
}.labelsHidden().frame(width: 100).help("列内排序")
|
||||||
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
|
Button { descending.toggle() } label: {
|
||||||
Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up")
|
Image(systemName: descending ? "arrow.down" : "arrow.up")
|
||||||
}
|
}.help(descending ? "降序,点击改为升序" : "升序,点击改为降序")
|
||||||
.buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty)
|
|
||||||
}
|
}
|
||||||
if store.state.matches.isEmpty {
|
Group {
|
||||||
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
|
if store.state.matches.isEmpty {
|
||||||
} else {
|
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "",
|
||||||
Picker("分组", selection: $selectedGroup) {
|
actionTitle: "前往人工配对") { store.page = .pair }
|
||||||
ForEach(groups, id: \.self) { group in
|
} else if matches.isEmpty {
|
||||||
Text(group == "全部" ? "全部(\(store.state.matches.count))" : "\(group)(\(count(for: group)))")
|
EmptyPanel(symbol: "magnifyingglass", title: "没有符合筛选的材料", message: "",
|
||||||
.tag(group)
|
actionTitle: "清除筛选") { query = ""; attentionOnly = false }
|
||||||
}
|
} else {
|
||||||
|
MatchedCategoryBoard(columns: columns, selection: $pptSelection)
|
||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
}.frame(maxWidth: .infinity, minHeight: 220, maxHeight: .infinity)
|
||||||
.onChange(of: groups) { _, available in
|
ViewThatFits(in: .horizontal) {
|
||||||
if !available.contains(selectedGroup) { selectedGroup = "全部" }
|
HStack(spacing: 16) {
|
||||||
|
selectionSummary(matches: matches, selected: selected)
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
exportActions
|
||||||
|
}
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
selectionSummary(matches: matches, selected: selected)
|
||||||
|
HStack { Spacer(); exportActions }
|
||||||
}
|
}
|
||||||
MatchGroupPage(
|
|
||||||
title: selectedGroup,
|
|
||||||
matches: matches(for: selectedGroup),
|
|
||||||
baseIndex: baseIndex(for: selectedGroup),
|
|
||||||
classified: $store.classified,
|
|
||||||
selection: $pptSelection
|
|
||||||
)
|
|
||||||
HStack {
|
|
||||||
Text("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))")
|
|
||||||
.monospacedDigit()
|
|
||||||
Spacer()
|
|
||||||
Text("其中 \(selectedMatches.filter { selectedGroup != "全部" && $0.category != selectedGroup }.count) 组在其他分组")
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Button("清空选择") { pptSelection.ids.removeAll() }.disabled(pptSelection.ids.isEmpty)
|
|
||||||
}.font(.callout)
|
|
||||||
Text("切换分组保留勾选;PPT 和个人报销单仅导出所选材料,报销单按费用类型合并。行程 Excel 仍使用全部已核对材料。")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
Text("PPT 使用竖版 3:4:每页最多两张发票,付款续页最多六张,实物照片留空供手动粘贴。")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}.padding(28).disabled(store.busy)
|
}.padding(AppTheme.page)
|
||||||
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in
|
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in
|
||||||
pptSelection.retain(identifiers)
|
pptSelection.retain(identifiers)
|
||||||
}
|
}
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: WorkspaceCommand.find)) { _ in searchFocused = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func matches(for group: String) -> [MatchGroup] {
|
private func selectionSummary(matches: [MatchGroup], selected: [MatchGroup]) -> some View {
|
||||||
let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group }
|
HStack(spacing: 12) {
|
||||||
return matches
|
Toggle("全选当前结果", isOn: Binding(
|
||||||
}
|
get: { pptSelection.containsAll(matches) },
|
||||||
|
set: { pptSelection.setVisible(matches, selected: $0) }
|
||||||
private func count(for group: String) -> Int {
|
)).toggleStyle(.checkbox).labelsHidden().disabled(matches.isEmpty).help("全选当前结果")
|
||||||
matches(for: group).count
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
}
|
Text(selected.isEmpty ? "共 \(matches.count) 组" : "已选 \(selected.count) 组")
|
||||||
|
.font(.callout.weight(.medium))
|
||||||
private func baseIndex(for group: String) -> Int {
|
Text("\(selected.isEmpty ? "当前发票" : "所选发票") \(currency((selected.isEmpty ? matches : selected).reduce(0) { $0 + $1.invoiceTotal }))")
|
||||||
guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 }
|
|
||||||
return first + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct MatchGroupPage: View {
|
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
|
||||||
let title: String
|
|
||||||
let matches: [MatchGroup]
|
|
||||||
let baseIndex: Int
|
|
||||||
@Binding var classified: Bool
|
|
||||||
@Binding var selection: MatchedPPTSelection
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack {
|
|
||||||
Toggle("全选当前分组", isOn: Binding(
|
|
||||||
get: { selection.containsAll(matches) },
|
|
||||||
set: { selection.setVisible(matches, selected: $0) }
|
|
||||||
))
|
|
||||||
.toggleStyle(.checkbox)
|
|
||||||
Text(title).font(.headline)
|
|
||||||
Text("\(matches.count) 组 · 已选 \(matches.filter { selection.ids.contains($0.id) }.count)")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Spacer()
|
}.monospacedDigit().fixedSize()
|
||||||
Text("按发票日期排列").font(.caption).foregroundStyle(.secondary)
|
let hidden = pptSelection.ids.subtracting(Set(matches.map(\.id))).count
|
||||||
|
if hidden > 0 { Text("\(hidden) 组在筛选外").font(.caption).foregroundStyle(.secondary).fixedSize() }
|
||||||
|
if !selected.isEmpty {
|
||||||
|
Button { pptSelection.ids.removeAll() } label: { Image(systemName: "xmark.circle") }
|
||||||
|
.buttonStyle(.plain).help("清空选择").accessibilityLabel("清空选择")
|
||||||
}
|
}
|
||||||
if matches.isEmpty {
|
if !query.isEmpty || attentionOnly {
|
||||||
EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。")
|
Button("清除筛选") { query = ""; attentionOnly = false }.buttonStyle(.borderless)
|
||||||
} else {
|
|
||||||
Table(matches) {
|
|
||||||
TableColumn("选择") { match in
|
|
||||||
Toggle("选择第 \(index(of: match)) 组", isOn: Binding(
|
|
||||||
get: { selection.ids.contains(match.id) },
|
|
||||||
set: { selected in
|
|
||||||
if selected { selection.ids.insert(match.id) }
|
|
||||||
else { selection.ids.remove(match.id) }
|
|
||||||
}
|
|
||||||
))
|
|
||||||
.toggleStyle(.checkbox).labelsHidden()
|
|
||||||
}.width(48)
|
|
||||||
TableColumn("序号") { match in
|
|
||||||
Text(String(format: "%02d", index(of: match))).monospacedDigit().foregroundStyle(.secondary)
|
|
||||||
}.width(min: 48, ideal: 56, max: 64)
|
|
||||||
TableColumn("发票") { match in
|
|
||||||
MaterialCell(items: match.invoices, store: store)
|
|
||||||
}.width(min: 170, ideal: 235)
|
|
||||||
TableColumn("付款截图") { match in
|
|
||||||
MaterialCell(items: match.payments, store: store)
|
|
||||||
}.width(min: 170, ideal: 235)
|
|
||||||
TableColumn("金额") { match in
|
|
||||||
VStack(alignment: .trailing, spacing: 3) {
|
|
||||||
Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)).monospacedDigit()
|
|
||||||
Text("付 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary).monospacedDigit()
|
|
||||||
}.frame(maxWidth: .infinity, alignment: .trailing)
|
|
||||||
}.width(min: 115, ideal: 135)
|
|
||||||
TableColumn("日期") { match in
|
|
||||||
Text(match.date == "9999-12-31" ? "未识别" : match.date).monospacedDigit()
|
|
||||||
}.width(min: 92, ideal: 105)
|
|
||||||
TableColumn("匹配") { match in
|
|
||||||
MatchConfidenceCell(match: match)
|
|
||||||
}.width(min: 160, ideal: 195)
|
|
||||||
TableColumn("操作") { match in
|
|
||||||
HStack(spacing: 7) {
|
|
||||||
if classified {
|
|
||||||
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
|
|
||||||
ForEach(expenseCategories, id: \.self) { Text($0) }
|
|
||||||
}.labelsHidden().frame(width: 95)
|
|
||||||
}
|
|
||||||
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }
|
|
||||||
.buttonStyle(.borderless).help("撤销本组配对")
|
|
||||||
}
|
|
||||||
}.width(min: classified ? 135 : 48, ideal: classified ? 145 : 60)
|
|
||||||
}
|
|
||||||
.frame(minHeight: 330)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func index(of match: MatchGroup) -> Int {
|
private var exportActions: some View {
|
||||||
guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex }
|
HStack(spacing: 10) {
|
||||||
return baseIndex + index
|
Menu {
|
||||||
|
Button("导出全部行程 Excel", systemImage: "tram") { store.export(.travel) }
|
||||||
|
.disabled(store.busy || store.state.travelCount == 0)
|
||||||
|
Divider()
|
||||||
|
Button("导出说明", systemImage: "questionmark.circle") { showExportHelp = true }
|
||||||
|
} label: { Image(systemName: "ellipsis.circle") }
|
||||||
|
.menuStyle(.borderlessButton).fixedSize().help("其他导出与说明")
|
||||||
|
.popover(isPresented: $showExportHelp) {
|
||||||
|
Text("跨类型和筛选保留勾选。每列表头的全选只作用于该列当前结果。PPT 和报销单仅导出所选材料,报销单按费用类型合并;行程 Excel 使用全部已核对材料。\n\nPPT 为竖版 3:4,实物照片留空。列内排序不改变原有导出顺序。")
|
||||||
|
.font(.callout).padding(20).frame(width: 360)
|
||||||
|
}
|
||||||
|
Button { store.openExpense(selectedMatchIDs: pptSelection.ids) } label: {
|
||||||
|
Label("报销单", systemImage: "tablecells")
|
||||||
|
}.disabled(store.busy || pptSelection.ids.isEmpty)
|
||||||
|
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
|
||||||
|
Label("导出 PPT", systemImage: "square.and.arrow.up")
|
||||||
|
}.buttonStyle(.borderedProminent).disabled(store.busy || pptSelection.ids.isEmpty)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,22 +119,22 @@ struct MatchConfidenceCell: View {
|
|||||||
if match.matchType == "auto" {
|
if match.matchType == "auto" {
|
||||||
Button { showExplanation = true } label: {
|
Button { showExplanation = true } label: {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
HStack(spacing: 4) {
|
AppBadge(title: "自动 · \(match.evidenceLabel)",
|
||||||
Text("自动 · \(match.evidenceLabel)").monospacedDigit()
|
symbol: match.needsAttention ? "exclamationmark.circle" : "checkmark.seal",
|
||||||
Image(systemName: "info.circle").font(.caption)
|
tone: match.needsAttention ? .warning : .success)
|
||||||
}.foregroundStyle((match.evidenceScore ?? 0) >= 90 ? .teal : .orange)
|
|
||||||
Text(match.explanation?.summary ?? "查看匹配依据")
|
Text(match.explanation?.summary ?? "查看匹配依据")
|
||||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
}.padding(.vertical, 4)
|
}.padding(.vertical, 4)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.help((match.explanation?.summary ?? "查看匹配依据") + ";点击查看详细得分和核对建议")
|
.help((match.explanation?.summary ?? "查看匹配依据") + ";点击查看详细得分和核对建议")
|
||||||
.accessibilityLabel("自动匹配证据评分 \(match.evidenceLabel),查看原因")
|
.accessibilityLabel("自动匹配证据评分 \(match.evidenceLabel),\(match.needsAttention ? "需关注" : "评分高"),查看原因")
|
||||||
.popover(isPresented: $showExplanation) {
|
.popover(isPresented: $showExplanation) {
|
||||||
MatchExplanationPopover(match: match)
|
MatchExplanationPopover(match: match)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("人工确认").foregroundStyle(.blue).help("此组由人工确认,不使用自动评分")
|
AppBadge(title: "人工确认", symbol: "person.crop.circle.badge.checkmark", tone: .information)
|
||||||
|
.help("此组由人工确认,不使用自动评分")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,7 +177,7 @@ struct MatchExplanationPopover: View {
|
|||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: item.points == item.maximum ? "checkmark.circle.fill" : "exclamationmark.circle")
|
Image(systemName: item.points == item.maximum ? "checkmark.circle.fill" : "exclamationmark.circle")
|
||||||
.foregroundStyle(item.points == item.maximum ? .teal : .orange)
|
.foregroundStyle(item.points == item.maximum ? AppTheme.success : AppTheme.warning)
|
||||||
Text(item.title).fontWeight(.medium)
|
Text(item.title).fontWeight(.medium)
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(item.points) / \(item.maximum) 分").monospacedDigit()
|
Text("\(item.points) / \(item.maximum) 分").monospacedDigit()
|
||||||
@@ -263,18 +210,36 @@ struct MatchExplanationPopover: View {
|
|||||||
struct MaterialCell: View {
|
struct MaterialCell: View {
|
||||||
let items: [Material]
|
let items: [Material]
|
||||||
@ObservedObject var store: WorkspaceStore
|
@ObservedObject var store: WorkspaceStore
|
||||||
|
@State private var expanded = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
Text("\(items.count) 份").font(.caption.weight(.medium))
|
Button { expanded = true } label: {
|
||||||
|
Label("\(items.count) 份", systemImage: items.count > 1 ? "square.stack" : "doc")
|
||||||
|
}.buttonStyle(.borderless).font(.caption.weight(.medium)).help("查看全部材料")
|
||||||
ForEach(items.prefix(2)) { item in
|
ForEach(items.prefix(2)) { item in
|
||||||
Button(item.name) { store.preview = item }
|
Button(item.name) { store.preview = item }
|
||||||
.buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)")
|
.buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)")
|
||||||
}
|
}
|
||||||
if items.count > 2 {
|
if items.count > 2 {
|
||||||
Text("还有 \(items.count - 2) 份").font(.caption).foregroundStyle(.secondary)
|
Button("+\(items.count - 2)") { expanded = true }
|
||||||
|
.buttonStyle(.borderless).font(.caption).help("查看其余材料")
|
||||||
}
|
}
|
||||||
}.padding(.vertical, 4)
|
}.padding(.vertical, 4)
|
||||||
|
.popover(isPresented: $expanded) {
|
||||||
|
ScrollView {
|
||||||
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170))], spacing: 12) {
|
||||||
|
ForEach(items) { item in
|
||||||
|
Button { expanded = false; store.preview = item } label: {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
MaterialThumbnail(item: item, height: 150)
|
||||||
|
Text(item.name).font(.caption).lineLimit(2)
|
||||||
|
}
|
||||||
|
}.buttonStyle(.plain).help("预览 \(item.name)")
|
||||||
|
}
|
||||||
|
}.padding(16)
|
||||||
|
}.frame(width: 420, height: min(500, CGFloat((items.count + 1) / 2) * 210))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +247,9 @@ struct ExpenseSheet: View {
|
|||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showTemplate = false
|
@State private var showTemplate = false
|
||||||
|
@State private var removedSignature: (index: Int, value: String)?
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("个人报销单").font(.title2.bold())
|
Text("个人报销单").font(.title2.bold())
|
||||||
@@ -292,9 +258,10 @@ struct ExpenseSheet: View {
|
|||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
PayeeProfileSummary(profileStore: store.payeeProfile)
|
|
||||||
ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true }
|
|
||||||
ScrollView {
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
PayeeProfileSummary(profileStore: store.payeeProfile)
|
||||||
|
ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true }
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in
|
ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
@@ -304,34 +271,60 @@ struct ExpenseSheet: View {
|
|||||||
Text("\(summary.groupCount) 组 · \(summary.invoiceCount) 张单据").font(.caption).foregroundStyle(.secondary)
|
Text("\(summary.groupCount) 组 · \(summary.invoiceCount) 张单据").font(.caption).foregroundStyle(.secondary)
|
||||||
}.frame(width: 120, alignment: .leading)
|
}.frame(width: 120, alignment: .leading)
|
||||||
Text(currency(summary.amountValue)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing)
|
Text(currency(summary.amountValue)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing)
|
||||||
TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 }))
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 }))
|
||||||
|
if store.purposes[summary.category]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
|
||||||
|
Text("将使用“\(summary.category)”").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
}.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
}.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
HStack {
|
HStack {
|
||||||
Text("合计 \(currency(NSDecimalNumber(decimal: store.expenseRows.reduce(.zero) { $0 + $1.amount }).doubleValue))").fontWeight(.semibold).monospacedDigit()
|
Text("合计 \(currency(NSDecimalNumber(decimal: store.expenseRows.reduce(.zero) { $0 + $1.amount }).doubleValue))").fontWeight(.semibold).monospacedDigit()
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("金额沿用报销单的发票计算口径,不使用付款截图金额。").font(.caption).foregroundStyle(.secondary)
|
Text("金额沿用报销单的发票计算口径,不使用付款截图金额。").font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
if !store.expenseSelectionValid {
|
if !store.expenseSelectionValid {
|
||||||
Text("勾选材料已变化,请关闭弹窗后重新选择。").foregroundStyle(.red)
|
AppNotice(text: "勾选材料已变化", tone: .danger,
|
||||||
|
actionTitle: "返回重新选择", action: { dismiss(); store.page = .matched })
|
||||||
|
}
|
||||||
|
if duplicatePurposes {
|
||||||
|
AppNotice(text: "多个费用类型使用了相同用途;金额仍按费用类型分别汇总。", tone: .warning)
|
||||||
}
|
}
|
||||||
Divider()
|
Divider()
|
||||||
HStack {
|
HStack {
|
||||||
Text("签字岗位").font(.headline)
|
Text("签字岗位").font(.headline)
|
||||||
Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary)
|
Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Menu {
|
||||||
|
ForEach(defaultSignatures, id: \.self) { name in
|
||||||
|
Button(name) { store.signatures.append(name) }.disabled(store.signatures.contains(name))
|
||||||
|
}
|
||||||
|
} label: { Image(systemName: "text.badge.plus") }
|
||||||
|
.help("选择签字岗位").disabled(store.signatures.count >= 6)
|
||||||
Button { store.signatures.append("") } label: { Label("添加", systemImage: "plus") }.disabled(store.signatures.count >= 6)
|
Button { store.signatures.append("") } label: { Label("添加", systemImage: "plus") }.disabled(store.signatures.count >= 6)
|
||||||
}
|
}
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
|
||||||
ForEach(store.signatures.indices, id: \.self) { index in
|
ForEach(store.signatures.indices, id: \.self) { index in
|
||||||
HStack {
|
HStack {
|
||||||
TextField("岗位名称", text: Binding(get: { store.signatures.indices.contains(index) ? store.signatures[index] : "" }, set: { if store.signatures.indices.contains(index) { store.signatures[index] = $0 } }))
|
TextField("岗位名称", text: Binding(get: { store.signatures.indices.contains(index) ? store.signatures[index] : "" }, set: { if store.signatures.indices.contains(index) { store.signatures[index] = $0 } }))
|
||||||
Button { store.signatures.remove(at: index) } label: { Image(systemName: "minus.circle") }.buttonStyle(.borderless)
|
Button {
|
||||||
|
removedSignature = (index, store.signatures.remove(at: index))
|
||||||
|
} label: { Image(systemName: "minus.circle") }.buttonStyle(.borderless).help("移除岗位")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let removedSignature {
|
||||||
|
AppNotice(text: "已移除“\(removedSignature.value.isEmpty ? "空岗位" : removedSignature.value)”",
|
||||||
|
actionTitle: store.signatures.count < 6 ? "撤销" : nil, action: {
|
||||||
|
guard store.signatures.count < 6 else { return }
|
||||||
|
store.signatures.insert(removedSignature.value, at: min(removedSignature.index, store.signatures.count))
|
||||||
|
self.removedSignature = nil
|
||||||
|
}, dismiss: { self.removedSignature = nil })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Divider()
|
Divider()
|
||||||
HStack {
|
HStack {
|
||||||
Text("按当前模板填充;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary)
|
Text("按当前模板填充;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary)
|
||||||
@@ -342,10 +335,16 @@ struct ExpenseSheet: View {
|
|||||||
}.buttonStyle(.borderedProminent)
|
}.buttonStyle(.borderedProminent)
|
||||||
.disabled(!store.expenseSelectionValid)
|
.disabled(!store.expenseSelectionValid)
|
||||||
}
|
}
|
||||||
}.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder)
|
}.padding(AppTheme.page)
|
||||||
|
.frame(width: AppTheme.sheetSize(width: 900, height: 700).width,
|
||||||
|
height: AppTheme.sheetSize(width: 900, height: 700).height).textFieldStyle(.roundedBorder)
|
||||||
.disabled(store.busy && !store.showExpensePreview)
|
.disabled(store.busy && !store.showExpensePreview)
|
||||||
.interactiveDismissDisabled(store.busy)
|
.interactiveDismissDisabled(store.busy)
|
||||||
.sheet(isPresented: $showTemplate) { ExpenseTemplateManager() }
|
.sheet(isPresented: $showTemplate) { ExpenseTemplateManager() }
|
||||||
.sheet(isPresented: $store.showExpensePreview) { ExpensePreviewSheet() }
|
.sheet(isPresented: $store.showExpensePreview) { ExpensePreviewSheet() }
|
||||||
}
|
}
|
||||||
|
private var duplicatePurposes: Bool {
|
||||||
|
let values = store.expenseRows.map { store.purposes[$0.category]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? $0.category }.filter { !$0.isEmpty }
|
||||||
|
return Set(values).count < values.count
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,66 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
|
||||||
|
enum MaterialImageLoader {
|
||||||
|
static func load(_ url: URL, pixels: Int) async -> NSImage? {
|
||||||
|
let task = Task.detached(priority: .utility) { () -> CGImage? in
|
||||||
|
guard !Task.isCancelled else { return nil }
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
|
||||||
|
return pdfFirstPage(url, pixels: pixels)
|
||||||
|
}
|
||||||
|
let bitmap = CGImageSourceCreateThumbnailAtIndex(source, 0, [
|
||||||
|
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||||
|
kCGImageSourceThumbnailMaxPixelSize: pixels,
|
||||||
|
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||||
|
kCGImageSourceShouldCacheImmediately: true
|
||||||
|
] as CFDictionary)
|
||||||
|
return Task.isCancelled ? nil : bitmap
|
||||||
|
}
|
||||||
|
let bitmap = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() }
|
||||||
|
guard !Task.isCancelled, let bitmap else { return nil }
|
||||||
|
return NSImage(cgImage: bitmap, size: .zero)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonisolated private static func pdfFirstPage(_ url: URL, pixels: Int) -> CGImage? {
|
||||||
|
guard let document = CGPDFDocument(url as CFURL), let page = document.page(at: 1) else { return nil }
|
||||||
|
let box = page.getBoxRect(.mediaBox)
|
||||||
|
guard box.width > 0, box.height > 0 else { return nil }
|
||||||
|
let rotated = abs(page.rotationAngle) % 180 == 90
|
||||||
|
let size = rotated ? CGSize(width: box.height, height: box.width) : box.size
|
||||||
|
let scale = CGFloat(max(1, pixels)) / max(size.width, size.height)
|
||||||
|
let width = max(1, Int(ceil(size.width * scale))), height = max(1, Int(ceil(size.height * scale)))
|
||||||
|
guard !Task.isCancelled,
|
||||||
|
let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
|
||||||
|
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
|
||||||
|
let rect = CGRect(x: 0, y: 0, width: width, height: height)
|
||||||
|
context.setFillColor(CGColor(gray: 1, alpha: 1))
|
||||||
|
context.fill(rect)
|
||||||
|
context.concatenate(page.getDrawingTransform(.mediaBox, rect: rect, rotate: 0, preserveAspectRatio: true))
|
||||||
|
context.drawPDFPage(page)
|
||||||
|
return Task.isCancelled ? nil : context.makeImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct MaterialThumbnail: View {
|
struct MaterialThumbnail: View {
|
||||||
let item: Material
|
let item: Material
|
||||||
var height: CGFloat = 155
|
var height: CGFloat = 155
|
||||||
|
var pixels: Int = 700
|
||||||
@State private var image: NSImage?
|
@State private var image: NSImage?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Color.white
|
AppTheme.surface
|
||||||
if let image {
|
if let image {
|
||||||
Image(nsImage: image).resizable().scaledToFit().padding(6)
|
Image(nsImage: image).resizable().scaledToFit().padding(6)
|
||||||
} else {
|
} else {
|
||||||
Image(systemName: "doc.richtext").font(.largeTitle).foregroundStyle(.gray)
|
Image(systemName: "doc.richtext").font(.largeTitle).foregroundStyle(.gray)
|
||||||
}
|
}
|
||||||
}.frame(height: height).clipShape(RoundedRectangle(cornerRadius: 9))
|
}.frame(height: height).clipShape(RoundedRectangle(cornerRadius: AppTheme.radius))
|
||||||
.task(id: item.previewURL) {
|
.task(id: item.previewURL) {
|
||||||
let url = item.previewURL
|
image = nil
|
||||||
image = await Task.detached(priority: .utility) {
|
let loaded = await MaterialImageLoader.load(item.previewURL, pixels: pixels)
|
||||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
guard !Task.isCancelled else { return }
|
||||||
let bitmap = CGImageSourceCreateThumbnailAtIndex(source, 0, [
|
image = loaded
|
||||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
|
||||||
kCGImageSourceThumbnailMaxPixelSize: 700,
|
|
||||||
kCGImageSourceCreateThumbnailWithTransform: true
|
|
||||||
] as CFDictionary) else { return NSImage(contentsOf: url) }
|
|
||||||
return NSImage(cgImage: bitmap, size: .zero)
|
|
||||||
}.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,10 +70,12 @@ struct MaterialCard: View {
|
|||||||
let item: Material
|
let item: Material
|
||||||
var selected = false
|
var selected = false
|
||||||
var selectable = true
|
var selectable = true
|
||||||
|
var thumbnailHeight: CGFloat = 155
|
||||||
|
var counterpartCount = 0
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 9) {
|
VStack(alignment: .leading, spacing: 9) {
|
||||||
MaterialThumbnail(item: item)
|
MaterialThumbnail(item: item, height: thumbnailHeight)
|
||||||
.overlay(alignment: .topTrailing) {
|
.overlay(alignment: .topTrailing) {
|
||||||
Button { store.preview = item } label: {
|
Button { store.preview = item } label: {
|
||||||
Image(systemName: "arrow.up.left.and.arrow.down.right").padding(7)
|
Image(systemName: "arrow.up.left.and.arrow.down.right").padding(7)
|
||||||
@@ -49,34 +86,44 @@ struct MaterialCard: View {
|
|||||||
Spacer(minLength: 2)
|
Spacer(minLength: 2)
|
||||||
if selected { Image(systemName: "checkmark.circle.fill").foregroundStyle(.teal) }
|
if selected { Image(systemName: "checkmark.circle.fill").foregroundStyle(.teal) }
|
||||||
}
|
}
|
||||||
Text(item.amountLabel).font(.headline).monospacedDigit().foregroundStyle(.teal)
|
Text(item.amountLabel).font(.headline).monospacedDigit()
|
||||||
Text(item.ocr.merchants.first ?? "未识别商户").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
Text(item.ocr.merchants.first ?? "未识别商户").font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
HStack {
|
HStack {
|
||||||
Text(item.dateLabel)
|
Text(item.dateLabel)
|
||||||
Spacer()
|
Spacer()
|
||||||
if item.ocr.status == "failed" { Label("识别失败", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) }
|
if item.ocr.status == "failed" { Label("识别失败", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) }
|
||||||
}.font(.caption2).foregroundStyle(.secondary)
|
}.font(.caption2).foregroundStyle(.secondary)
|
||||||
}.padding(11)
|
}.padding(12)
|
||||||
.background(selected ? Color.teal.opacity(0.08) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 13))
|
.background(selected ? AppTheme.accent.opacity(0.08) : AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
|
||||||
.overlay(RoundedRectangle(cornerRadius: 13).stroke(selected ? .teal : .primary.opacity(0.08), lineWidth: selected ? 2 : 1))
|
.overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(selected ? AppTheme.accent : AppTheme.border, lineWidth: selected ? 2 : 1))
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
.focusable(selectable)
|
.focusable(selectable)
|
||||||
.onKeyPress(.return) {
|
.onKeyPress(.return) {
|
||||||
|
guard !store.busy else { return .ignored }
|
||||||
|
if selectable { store.toggle(item) } else { store.preview = item }
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
.onKeyPress(.space) {
|
||||||
|
guard !store.busy else { return .ignored }
|
||||||
if selectable { store.toggle(item) } else { store.preview = item }
|
if selectable { store.toggle(item) } else { store.preview = item }
|
||||||
return .handled
|
return .handled
|
||||||
}
|
}
|
||||||
.onTapGesture { if selectable { store.toggle(item) } else { store.preview = item } }
|
.onTapGesture { if selectable { store.toggle(item) } else { store.preview = item } }
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
.accessibilityLabel("\(item.name),\(item.amountLabel),\(selected ? "已选择" : "未选择")")
|
.accessibilityLabel("\(item.name),\(item.amountLabel),\(selected ? "已选择,与 \(counterpartCount) 份材料配对" : "未选择")")
|
||||||
.accessibilityAction { if selectable { store.toggle(item) } else { store.preview = item } }
|
.accessibilityAction { if selectable { store.toggle(item) } else { store.preview = item } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct MaterialPreview: View {
|
struct MaterialPreview: View {
|
||||||
let item: Material
|
let item: Material
|
||||||
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var zoom: CGFloat = 1
|
@State private var zoom: CGFloat = 1
|
||||||
@State private var showText = false
|
@State private var showText = false
|
||||||
|
@State private var image: NSImage?
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var attempt = 0
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -85,25 +132,33 @@ struct MaterialPreview: View {
|
|||||||
Text(item.amountLabel + " · " + item.dateLabel).font(.caption).foregroundStyle(.secondary)
|
Text(item.amountLabel + " · " + item.dateLabel).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if !item.matched && item.type != "photo" {
|
||||||
|
Button { store.toggle(item) } label: {
|
||||||
|
Label(isSelected ? "取消选择" : "选中此材料", systemImage: isSelected ? "checkmark.circle.fill" : "circle")
|
||||||
|
}.disabled(store.busy)
|
||||||
|
}
|
||||||
Toggle("识别文本", isOn: $showText).toggleStyle(.button)
|
Toggle("识别文本", isOn: $showText).toggleStyle(.button)
|
||||||
Button { zoom = max(0.5, zoom - 0.25) } label: { Image(systemName: "minus.magnifyingglass") }
|
Button { zoom = max(0.5, zoom - 0.25) } label: { Image(systemName: "minus.magnifyingglass") }.help("缩小")
|
||||||
Text("\(Int(zoom * 100))%").font(.caption.monospacedDigit()).frame(width: 44)
|
Text("\(Int(zoom * 100))%").font(.caption.monospacedDigit()).frame(width: 44)
|
||||||
Button { zoom = min(4, zoom + 0.25) } label: { Image(systemName: "plus.magnifyingglass") }
|
Button { zoom = min(4, zoom + 0.25) } label: { Image(systemName: "plus.magnifyingglass") }.help("放大")
|
||||||
Button("完成") { dismiss() }.keyboardShortcut(.cancelAction)
|
Button("完成") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||||
}.padding(18)
|
}.padding(18)
|
||||||
Divider()
|
Divider()
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
ScrollView([.horizontal, .vertical]) {
|
ScrollView([.horizontal, .vertical]) {
|
||||||
if let image = NSImage(contentsOf: item.previewURL) {
|
if let image {
|
||||||
Image(nsImage: image).resizable().scaledToFit()
|
Image(nsImage: image).resizable().scaledToFit()
|
||||||
.frame(width: max(1, geometry.size.width - 30) * zoom, height: max(1, geometry.size.height - 30) * zoom)
|
.frame(width: max(1, geometry.size.width - 30) * zoom, height: max(1, geometry.size.height - 30) * zoom)
|
||||||
.padding(15)
|
.padding(15)
|
||||||
|
} else if loading {
|
||||||
|
ProgressView().frame(width: geometry.size.width, height: geometry.size.height)
|
||||||
} else {
|
} else {
|
||||||
EmptyPanel(symbol: "exclamationmark.triangle", title: "无法预览此文件", message: item.ocr.message)
|
EmptyPanel(symbol: "exclamationmark.triangle", title: "无法预览此文件", message: item.ocr.message,
|
||||||
|
actionTitle: "重试") { attempt += 1 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.background(.black.opacity(0.04))
|
}.background(AppTheme.canvas)
|
||||||
if showText {
|
if showText {
|
||||||
Divider()
|
Divider()
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -115,6 +170,21 @@ struct MaterialPreview: View {
|
|||||||
}.frame(width: 280)
|
}.frame(width: 280)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.frame(width: 1000, height: 700)
|
}.frame(width: AppTheme.sheetSize(width: 1100, height: 760).width,
|
||||||
|
height: AppTheme.sheetSize(width: 1100, height: 760).height)
|
||||||
|
.task(id: "\(item.previewURL.path):\(attempt)") {
|
||||||
|
loading = true
|
||||||
|
image = nil
|
||||||
|
let thumbnail = await MaterialImageLoader.load(item.previewURL, pixels: 700)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
image = thumbnail
|
||||||
|
let full = await MaterialImageLoader.load(item.previewURL, pixels: 6000)
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
image = full ?? thumbnail
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var isSelected: Bool {
|
||||||
|
(item.type == "invoice" ? store.selectedInvoices : store.selectedPayments).contains(item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
enum PairConnectionOverflow {
|
||||||
|
case above, below
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairConnectionEndpoint: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let point: CGPoint
|
||||||
|
let count: Int
|
||||||
|
let overflow: PairConnectionOverflow?
|
||||||
|
let route: [CGPoint]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairConnectionLine: Equatable {
|
||||||
|
let start: CGPoint
|
||||||
|
let end: CGPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PairConnectionLayout {
|
||||||
|
let invoices: [PairConnectionEndpoint]
|
||||||
|
let payments: [PairConnectionEndpoint]
|
||||||
|
let clipRect: CGRect
|
||||||
|
|
||||||
|
var representedPairCount: Int {
|
||||||
|
invoices.reduce(0) { $0 + $1.count } * payments.reduce(0) { $0 + $1.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines: [PairConnectionLine] {
|
||||||
|
guard !invoices.isEmpty, !payments.isEmpty else { return [] }
|
||||||
|
let endpoints = invoices + payments
|
||||||
|
var result = endpoints.flatMap { endpoint in
|
||||||
|
zip(endpoint.route, endpoint.route.dropFirst()).map { PairConnectionLine(start: $0, end: $1) }
|
||||||
|
}
|
||||||
|
let joins = endpoints.compactMap { $0.route.last }
|
||||||
|
if let top = joins.min(by: { $0.y < $1.y }), let bottom = joins.max(by: { $0.y < $1.y }), top != bottom {
|
||||||
|
result.append(PairConnectionLine(start: top, end: bottom))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
init(invoiceIDs: [String], paymentIDs: [String], selectedInvoices: Set<String>, selectedPayments: Set<String>,
|
||||||
|
frames: [String: CGRect], invoiceViewport: CGRect, paymentViewport: CGRect) {
|
||||||
|
let top = max(invoiceViewport.minY, paymentViewport.minY)
|
||||||
|
let bottom = min(invoiceViewport.maxY, paymentViewport.maxY)
|
||||||
|
guard !invoiceViewport.isEmpty, !paymentViewport.isEmpty, bottom > top else {
|
||||||
|
invoices = []; payments = []; clipRect = .zero
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clipRect = CGRect(x: invoiceViewport.minX, y: top,
|
||||||
|
width: paymentViewport.maxX - invoiceViewport.minX, height: bottom - top)
|
||||||
|
let spineX = (invoiceViewport.maxX + paymentViewport.minX) / 2
|
||||||
|
invoices = Self.endpoints(ids: invoiceIDs, selected: selectedInvoices, frames: frames,
|
||||||
|
viewport: invoiceViewport.intersection(clipRect), invoice: true, spineX: spineX)
|
||||||
|
payments = Self.endpoints(ids: paymentIDs, selected: selectedPayments, frames: frames,
|
||||||
|
viewport: paymentViewport.intersection(clipRect), invoice: false, spineX: spineX)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func endpoints(ids: [String], selected: Set<String>, frames: [String: CGRect],
|
||||||
|
viewport: CGRect, invoice: Bool, spineX: CGFloat) -> [PairConnectionEndpoint] {
|
||||||
|
let firstVisible = ids.firstIndex { id in
|
||||||
|
guard let rect = frames[id] else { return false }
|
||||||
|
let visible = rect.intersection(viewport)
|
||||||
|
return !visible.isNull && !visible.isEmpty
|
||||||
|
}
|
||||||
|
var result: [PairConnectionEndpoint] = []
|
||||||
|
var above = 0, below = 0
|
||||||
|
for (index, id) in ids.enumerated() where selected.contains(id) {
|
||||||
|
if let rect = frames[id] {
|
||||||
|
let visible = rect.intersection(viewport)
|
||||||
|
if !visible.isNull && !visible.isEmpty {
|
||||||
|
let point = CGPoint(x: invoice ? visible.maxX : visible.minX, y: visible.midY)
|
||||||
|
let rowFrames = ids.compactMap { frames[$0] }.filter { $0.minY < point.y && $0.maxY > point.y }
|
||||||
|
let blocked = rowFrames.contains { invoice ? $0.minX >= point.x : $0.maxX <= point.x }
|
||||||
|
var route = [point]
|
||||||
|
if blocked {
|
||||||
|
// Route around neighboring cards through the grid's 12-point row/column gaps.
|
||||||
|
let bottom = (rowFrames.map(\.maxY).max() ?? rect.maxY) + 6
|
||||||
|
let top = (rowFrames.map(\.minY).min() ?? rect.minY) - 6
|
||||||
|
let laneY = bottom < viewport.maxY - 2 ? bottom : max(viewport.minY + 2, top)
|
||||||
|
let laneX = point.x + (invoice ? 6 : -6)
|
||||||
|
route += [CGPoint(x: laneX, y: point.y), CGPoint(x: laneX, y: laneY),
|
||||||
|
CGPoint(x: spineX, y: laneY)]
|
||||||
|
} else {
|
||||||
|
route.append(CGPoint(x: spineX, y: point.y))
|
||||||
|
}
|
||||||
|
result.append(PairConnectionEndpoint(
|
||||||
|
id: "material:\(id)", point: point, count: 1, overflow: nil, route: route))
|
||||||
|
} else if rect.maxY <= viewport.minY {
|
||||||
|
above += 1
|
||||||
|
} else {
|
||||||
|
below += 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Lazy grids discard offscreen cells; their sorted positions still identify the scroll direction.
|
||||||
|
if let firstVisible, index < firstVisible { above += 1 } else { below += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let inset = min(12, viewport.height / 2)
|
||||||
|
let edgeX = invoice ? viewport.maxX - 24 : viewport.minX + 24
|
||||||
|
if above > 0 {
|
||||||
|
let point = CGPoint(x: edgeX, y: viewport.minY + inset)
|
||||||
|
result.append(PairConnectionEndpoint(id: "above", point: point, count: above, overflow: .above,
|
||||||
|
route: [point, CGPoint(x: spineX, y: point.y)]))
|
||||||
|
}
|
||||||
|
if below > 0 {
|
||||||
|
let point = CGPoint(x: edgeX, y: viewport.maxY - inset)
|
||||||
|
result.append(PairConnectionEndpoint(id: "below", point: point, count: below, overflow: .below,
|
||||||
|
route: [point, CGPoint(x: spineX, y: point.y)]))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-48
@@ -1,79 +1,178 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
|
enum PairAnchor: Hashable {
|
||||||
|
case material(String), invoiceViewport, paymentViewport
|
||||||
|
}
|
||||||
|
|
||||||
struct MaterialAnchorKey: PreferenceKey {
|
struct MaterialAnchorKey: PreferenceKey {
|
||||||
static var defaultValue: [String: Anchor<CGRect>] = [:]
|
static var defaultValue: [PairAnchor: Anchor<CGRect>] = [:]
|
||||||
static func reduce(value: inout [String: Anchor<CGRect>], nextValue: () -> [String: Anchor<CGRect>]) {
|
static func reduce(value: inout [PairAnchor: Anchor<CGRect>], nextValue: () -> [PairAnchor: Anchor<CGRect>]) {
|
||||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PairPage: View {
|
struct PairPage: View {
|
||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
|
@State private var invoiceQuery = ""
|
||||||
|
@State private var paymentQuery = ""
|
||||||
|
@State private var invoiceOrder: MaterialOrder = .date
|
||||||
|
@State private var paymentOrder: MaterialOrder = .date
|
||||||
|
@State private var thumbnailSize = 1.0
|
||||||
|
@State private var scrollRevision = 0
|
||||||
|
@FocusState private var focusedMaterial: String?
|
||||||
|
@FocusState private var searchFocused: Bool
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 18) {
|
let invoices = WorkspaceFilter.materials(store.state.unmatchedInvoices, query: invoiceQuery, order: invoiceOrder)
|
||||||
|
let payments = WorkspaceFilter.materials(store.state.unmatchedPayments, query: paymentQuery, order: paymentOrder)
|
||||||
|
VStack(alignment: .leading, spacing: AppTheme.gap) {
|
||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
Text("人工配对").font(.title2.bold())
|
||||||
Text("人工连连对").font(.title3.bold())
|
|
||||||
Text("左右均可多选,确认后作为一个核对组处理。人工确认不限制金额相等。")
|
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
Image(systemName: "photo").foregroundStyle(.secondary)
|
||||||
|
Slider(value: $thumbnailSize, in: 0...2, step: 1).frame(width: 100)
|
||||||
|
.help("缩略图大小").accessibilityLabel("缩略图大小")
|
||||||
Picker("分类", selection: $store.category) {
|
Picker("分类", selection: $store.category) {
|
||||||
ForEach(expenseCategories, id: \.self) { Text($0) }
|
ForEach(expenseCategories, id: \.self) { Text($0) }
|
||||||
}.frame(width: 160)
|
}.frame(width: 160).disabled(store.busy)
|
||||||
Button("确认配对", action: store.confirmPair).buttonStyle(.borderedProminent).disabled(!store.canPair)
|
|
||||||
}
|
}
|
||||||
HStack {
|
HStack(alignment: .top, spacing: 32) {
|
||||||
Label("已选 \(store.selectedInvoices.count) 张发票", systemImage: "doc.text")
|
column(invoice: true, items: invoices)
|
||||||
Image(systemName: "link").foregroundStyle(.teal)
|
column(invoice: false, items: payments)
|
||||||
Label("已选 \(store.selectedPayments.count) 张付款", systemImage: "creditcard")
|
|
||||||
Spacer()
|
|
||||||
if !store.selectedInvoices.isEmpty || !store.selectedPayments.isEmpty {
|
|
||||||
Button("取消选择") { store.selectedInvoices = []; store.selectedPayments = [] }.buttonStyle(.borderless)
|
|
||||||
}
|
|
||||||
}.font(.callout).foregroundStyle(.secondary)
|
|
||||||
HStack(alignment: .top, spacing: 40) {
|
|
||||||
column(title: "待配对发票", items: store.state.unmatchedInvoices, selected: store.selectedInvoices)
|
|
||||||
column(title: "待配对付款", items: store.state.unmatchedPayments, selected: store.selectedPayments)
|
|
||||||
}
|
}
|
||||||
.overlayPreferenceValue(MaterialAnchorKey.self) { anchors in
|
.overlayPreferenceValue(MaterialAnchorKey.self) { anchors in
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
Canvas { context, _ in
|
// Resolve anchors in the view update, not in Canvas's retained drawing closure.
|
||||||
for invoice in store.selectedInvoices {
|
let _ = scrollRevision
|
||||||
for payment in store.selectedPayments {
|
if let left = anchors[.invoiceViewport], let right = anchors[.paymentViewport] {
|
||||||
if let left = anchors[invoice], let right = anchors[payment] {
|
let frames = Dictionary(uniqueKeysWithValues: anchors.compactMap { key, anchor -> (String, CGRect)? in
|
||||||
let start = CGPoint(x: geometry[left].maxX, y: geometry[left].midY)
|
guard case .material(let id) = key else { return nil }
|
||||||
let end = CGPoint(x: geometry[right].minX, y: geometry[right].midY)
|
return (id, geometry[anchor])
|
||||||
var line = Path()
|
})
|
||||||
line.move(to: start)
|
let layout = PairConnectionLayout(
|
||||||
let bend = max(30, (end.x - start.x) * 0.42)
|
invoiceIDs: invoices.map(\.id), paymentIDs: payments.map(\.id),
|
||||||
line.addCurve(to: end, control1: CGPoint(x: start.x + bend, y: start.y), control2: CGPoint(x: end.x - bend, y: end.y))
|
selectedInvoices: store.selectedInvoices, selectedPayments: store.selectedPayments,
|
||||||
context.stroke(line, with: .color(.teal.opacity(0.6)), style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
frames: frames, invoiceViewport: geometry[left], paymentViewport: geometry[right])
|
||||||
}
|
PairConnectionOverlay(layout: layout)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.allowsHitTesting(false).clipped()
|
}.allowsHitTesting(false).clipped().accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
}.padding(28).disabled(store.busy)
|
Divider()
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Label("\(store.selectedInvoices.count) 张发票", systemImage: "doc.text")
|
||||||
|
Image(systemName: "link").foregroundStyle(AppTheme.accent)
|
||||||
|
Label("\(store.selectedPayments.count) 张付款", systemImage: "creditcard")
|
||||||
|
Spacer()
|
||||||
|
Button("取消选择") { store.selectedInvoices = []; store.selectedPayments = [] }
|
||||||
|
.disabled(store.busy || (store.selectedInvoices.isEmpty && store.selectedPayments.isEmpty))
|
||||||
|
Button { store.confirmPair() } label: {
|
||||||
|
Label("确认配对(\(store.selectedInvoices.count) × \(store.selectedPayments.count))", systemImage: "link")
|
||||||
|
}.buttonStyle(.borderedProminent).disabled(!store.canPair)
|
||||||
|
}.font(.callout)
|
||||||
|
}.padding(AppTheme.page)
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: WorkspaceCommand.find)) { _ in searchFocused = true }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func column(title: String, items: [Material], selected: Set<String>) -> some View {
|
private func column(invoice: Bool, items: [Material]) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 14) {
|
let source = invoice ? store.state.unmatchedInvoices : store.state.unmatchedPayments
|
||||||
HStack { Text(title).font(.headline); Spacer(); Text("\(items.count)").foregroundStyle(.secondary) }
|
let query = invoice ? $invoiceQuery : $paymentQuery
|
||||||
|
let order = invoice ? $invoiceOrder : $paymentOrder
|
||||||
|
let selected = invoice ? store.selectedInvoices : store.selectedPayments
|
||||||
|
let counterpart = invoice ? store.selectedPayments.count : store.selectedInvoices.count
|
||||||
|
return VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack {
|
||||||
|
Text(invoice ? "待配对发票" : "待配对付款").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(items.count) / \(source.count)").monospacedDigit().foregroundStyle(.secondary)
|
||||||
|
Picker("排序", selection: order) {
|
||||||
|
ForEach(MaterialOrder.allCases) { Text($0.rawValue).tag($0) }
|
||||||
|
}.labelsHidden().frame(width: 90).help("排序")
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
if invoice {
|
||||||
|
AppSearchField(placeholder: "文件名、商户、金额或日期", text: query).focused($searchFocused)
|
||||||
|
} else { AppSearchField(placeholder: "文件名、商户、金额或日期", text: query) }
|
||||||
|
}
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
EmptyPanel(symbol: "checkmark.circle", title: "材料已全部处理", message: "导入材料或撤销核对组后,会显示在这里。")
|
EmptyPanel(symbol: source.isEmpty ? "checkmark.circle" : "magnifyingglass",
|
||||||
|
title: source.isEmpty ? (invoice ? "所有发票已处理或已核对" : "所有付款已处理或已核对") : "没有匹配的材料",
|
||||||
|
message: "", actionTitle: source.isEmpty ? "查看已核对" : "清空搜索") {
|
||||||
|
if source.isEmpty { store.page = .matched } else { query.wrappedValue = "" }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ScrollView {
|
ScrollViewReader { proxy in
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) {
|
ScrollView {
|
||||||
ForEach(items) { item in
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 155 + thumbnailSize * 30), spacing: 12)], spacing: 12) {
|
||||||
MaterialCard(item: item, selected: selected.contains(item.id))
|
ForEach(items) { item in
|
||||||
.anchorPreference(key: MaterialAnchorKey.self, value: .bounds) { selected.contains(item.id) ? [item.id: $0] : [:] }
|
MaterialCard(item: item, selected: selected.contains(item.id),
|
||||||
|
thumbnailHeight: 110 + thumbnailSize * 45, counterpartCount: counterpart)
|
||||||
|
.focused($focusedMaterial, equals: item.id)
|
||||||
|
.focusEffectDisabled()
|
||||||
|
.onKeyPress(.downArrow) { moveFocus(item.id, in: items, offset: 1, proxy: proxy) }
|
||||||
|
.onKeyPress(.upArrow) { moveFocus(item.id, in: items, offset: -1, proxy: proxy) }
|
||||||
|
.anchorPreference(key: MaterialAnchorKey.self, value: .bounds) {
|
||||||
|
[.material(item.id): $0]
|
||||||
|
}
|
||||||
|
.id(item.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.padding(3)
|
.padding(3)
|
||||||
|
}
|
||||||
|
.transformAnchorPreference(key: MaterialAnchorKey.self, value: .bounds) { anchors, viewport in
|
||||||
|
anchors[invoice ? .invoiceViewport : .paymentViewport] = viewport
|
||||||
|
}
|
||||||
|
.onScrollGeometryChange(for: CGPoint.self) { $0.contentOffset } action: { old, new in
|
||||||
|
if old != new { scrollRevision &+= 1 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let hidden = selected.subtracting(Set(items.map(\.id))).count
|
||||||
|
Text(hidden > 0 ? "另有 \(hidden) 份已选材料不在当前结果中" : "已选 \(selected.count) 份")
|
||||||
|
.font(.caption).foregroundStyle(.secondary).frame(height: 18)
|
||||||
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func moveFocus(_ id: String, in items: [Material], offset: Int, proxy: ScrollViewProxy) -> KeyPress.Result {
|
||||||
|
guard let index = items.firstIndex(where: { $0.id == id }) else { return .ignored }
|
||||||
|
let next = max(0, min(items.count - 1, index + offset))
|
||||||
|
focusedMaterial = items[next].id
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) { proxy.scrollTo(items[next].id) }
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PairConnectionOverlay: View {
|
||||||
|
let layout: PairConnectionLayout
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !layout.lines.isEmpty {
|
||||||
|
Canvas { context, _ in
|
||||||
|
context.clip(to: Path(layout.clipRect))
|
||||||
|
var path = Path()
|
||||||
|
for connection in layout.lines {
|
||||||
|
path.move(to: connection.start)
|
||||||
|
path.addLine(to: connection.end)
|
||||||
|
}
|
||||||
|
context.stroke(path, with: .color(AppTheme.accent.opacity(0.8)),
|
||||||
|
style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
|
||||||
|
for endpoint in layout.invoices + layout.payments where endpoint.overflow == nil {
|
||||||
|
context.fill(Path(ellipseIn: CGRect(x: endpoint.point.x - 3, y: endpoint.point.y - 3, width: 6, height: 6)),
|
||||||
|
with: .color(AppTheme.accent))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overflowBadges(layout.invoices)
|
||||||
|
overflowBadges(layout.payments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overflowBadges(_ endpoints: [PairConnectionEndpoint]) -> some View {
|
||||||
|
ForEach(endpoints.filter { $0.overflow != nil }) { endpoint in
|
||||||
|
Label("\(endpoint.count)", systemImage: endpoint.overflow == .above ? "arrow.up" : "arrow.down")
|
||||||
|
.font(.caption2.weight(.semibold)).monospacedDigit().foregroundStyle(AppTheme.accent)
|
||||||
|
.frame(width: 44, height: 22)
|
||||||
|
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 5))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 5).stroke(AppTheme.accent.opacity(0.5)))
|
||||||
|
.position(endpoint.point)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ struct PayeeProfileSheet: View {
|
|||||||
@State private var showAccount = false
|
@State private var showAccount = false
|
||||||
@State private var confirmDelete = false
|
@State private var confirmDelete = false
|
||||||
@State private var errorMessage: String?
|
@State private var errorMessage: String?
|
||||||
|
var onSave: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
@@ -54,6 +55,7 @@ struct PayeeProfileSheet: View {
|
|||||||
Button("保存") {
|
Button("保存") {
|
||||||
do {
|
do {
|
||||||
try profileStore.save(draft)
|
try profileStore.save(draft)
|
||||||
|
onSave?()
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch { errorMessage = error.localizedDescription }
|
} catch { errorMessage = error.localizedDescription }
|
||||||
}
|
}
|
||||||
@@ -61,7 +63,7 @@ struct PayeeProfileSheet: View {
|
|||||||
.disabled(profileStore.loadError != nil)
|
.disabled(profileStore.loadError != nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(26).frame(width: 580)
|
.padding(AppTheme.page).frame(width: AppTheme.sheetSize(width: 580, height: 560).width)
|
||||||
.onAppear { draft = profileStore.profile ?? PayeeProfile() }
|
.onAppear { draft = profileStore.profile ?? PayeeProfile() }
|
||||||
.confirmationDialog("删除本机保存的收款信息?", isPresented: $confirmDelete) {
|
.confirmationDialog("删除本机保存的收款信息?", isPresented: $confirmDelete) {
|
||||||
Button("删除收款信息", role: .destructive) {
|
Button("删除收款信息", role: .destructive) {
|
||||||
@@ -81,6 +83,7 @@ struct PayeeProfileSheet: View {
|
|||||||
struct PayeeProfileSummary: View {
|
struct PayeeProfileSummary: View {
|
||||||
@ObservedObject var profileStore: PayeeProfileStore
|
@ObservedObject var profileStore: PayeeProfileStore
|
||||||
@State private var editing = false
|
@State private var editing = false
|
||||||
|
@State private var saved = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
@@ -89,14 +92,24 @@ struct PayeeProfileSummary: View {
|
|||||||
if let profile = profileStore.profile {
|
if let profile = profileStore.profile {
|
||||||
Text("\(profile.recipient) · \(profile.bankName) · 尾号 \(profile.accountNumber.suffix(4))")
|
Text("\(profile.recipient) · \(profile.bankName) · 尾号 \(profile.accountNumber.suffix(4))")
|
||||||
.font(.caption).textSelection(.enabled)
|
.font(.caption).textSelection(.enabled)
|
||||||
|
Button {
|
||||||
|
NSPasteboard.general.clearContents()
|
||||||
|
NSPasteboard.general.setString(String(profile.accountNumber.suffix(4)), forType: .string)
|
||||||
|
} label: { Image(systemName: "doc.on.doc") }.buttonStyle(.borderless).help("复制账号尾号")
|
||||||
} else {
|
} else {
|
||||||
Text(profileStore.loadError ?? "尚未填写,导出的收款信息将留空,不使用模板示例账号。")
|
Text(profileStore.loadError ?? "尚未填写,导出的收款信息将留空,不使用模板示例账号。")
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if saved { AppBadge(title: "已保存", symbol: "checkmark", tone: .success) }
|
||||||
Button("设置收款信息") { editing = true }
|
Button("设置收款信息") { editing = true }
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $editing) { PayeeProfileSheet(profileStore: profileStore) }
|
.sheet(isPresented: $editing) { PayeeProfileSheet(profileStore: profileStore, onSave: { saved = true }) }
|
||||||
|
.task(id: saved) {
|
||||||
|
guard saved else { return }
|
||||||
|
do { try await Task.sleep(for: .seconds(5)) } catch { return }
|
||||||
|
saved = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-50
@@ -4,13 +4,20 @@ struct ScanPage: View {
|
|||||||
@EnvironmentObject var store: WorkspaceStore
|
@EnvironmentObject var store: WorkspaceStore
|
||||||
@Binding var confirmClear: Bool
|
@Binding var confirmClear: Bool
|
||||||
@State private var folderTargeted = false
|
@State private var folderTargeted = false
|
||||||
|
@State private var dismissedWarnings: Set<Int> = []
|
||||||
|
@State private var expandedWarnings = false
|
||||||
|
private var hasMaterials: Bool { !(store.state.invoices + store.state.payments + store.state.photos).isEmpty }
|
||||||
|
private var pendingCount: Int { store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count }
|
||||||
|
private var warningIndices: [Int] { store.state.warnings.indices.filter { !dismissedWarnings.contains($0) } }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 22) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
Panel {
|
Panel {
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: 20) {
|
||||||
Image(systemName: folderTargeted && !store.busy ? "folder.badge.plus" : "folder.fill").font(.system(size: 44)).foregroundStyle(.teal.gradient)
|
Image(systemName: folderTargeted && !store.busy ? "folder.badge.plus" : "folder.fill")
|
||||||
|
.font(.system(size: 36)).foregroundStyle(AppTheme.accent)
|
||||||
|
.scaleEffect(folderTargeted ? 1.08 : 1)
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
Text(store.state.rootPath.isEmpty ? "从一个材料文件夹开始" : store.state.rootPath).font(.title3.bold())
|
Text(store.state.rootPath.isEmpty ? "从一个材料文件夹开始" : store.state.rootPath).font(.title3.bold())
|
||||||
Text("文件夹内请分别放置:发票 / 付款截图 / 实物照片")
|
Text("文件夹内请分别放置:发票 / 付款截图 / 实物照片")
|
||||||
@@ -18,99 +25,109 @@ struct ScanPage: View {
|
|||||||
Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹")
|
Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹")
|
||||||
.font(.callout.weight(.medium)).foregroundStyle(.teal)
|
.font(.callout.weight(.medium)).foregroundStyle(.teal)
|
||||||
Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区")
|
Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区")
|
||||||
.font(.caption).foregroundStyle(.tertiary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy)
|
Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: 16)
|
RoundedRectangle(cornerRadius: AppTheme.radius)
|
||||||
.strokeBorder(.teal.opacity(folderTargeted && !store.busy ? 1 : 0), style: StrokeStyle(lineWidth: 2, dash: [8, 5]))
|
.strokeBorder(.teal.opacity(folderTargeted && !store.busy ? 1 : 0), style: StrokeStyle(lineWidth: 2, dash: [8, 5]))
|
||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
}
|
}
|
||||||
.dropDestination(for: URL.self) { sources, _ in
|
.dropDestination(for: URL.self) { sources, _ in
|
||||||
store.importFolders(sources)
|
store.importFolders(sources)
|
||||||
} isTargeted: { targeted in
|
} isTargeted: { targeted in
|
||||||
folderTargeted = targeted
|
withAnimation(.easeInOut(duration: 0.15)) { folderTargeted = targeted }
|
||||||
|
}
|
||||||
|
if hasMaterials && pendingCount > 0 {
|
||||||
|
AppNotice(text: "\(pendingCount) 份材料待配对 · 已核对 \(store.state.matches.count) 组",
|
||||||
|
actionTitle: "前往人工配对", action: { store.page = .pair })
|
||||||
}
|
}
|
||||||
ExpenseTemplateSummary {
|
ExpenseTemplateSummary {
|
||||||
store.templateMessage = nil
|
store.templateMessage = nil
|
||||||
store.showExpenseTemplate = true
|
store.showExpenseTemplate = true
|
||||||
}
|
}
|
||||||
if !store.state.warnings.isEmpty {
|
if !warningIndices.isEmpty {
|
||||||
Panel {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
HStack {
|
||||||
Label("需要留意", systemImage: "exclamationmark.triangle").foregroundStyle(.orange).font(.headline)
|
Label("\(warningIndices.count) 条提醒", systemImage: "exclamationmark.triangle")
|
||||||
ForEach(store.state.warnings, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) }
|
.foregroundStyle(AppTheme.warning).font(.headline)
|
||||||
|
Spacer()
|
||||||
|
if warningIndices.count > 3 {
|
||||||
|
Button(expandedWarnings ? "收起" : "展开全部") { expandedWarnings.toggle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(expandedWarnings ? warningIndices : Array(warningIndices.prefix(3)), id: \.self) { index in
|
||||||
|
let warning = store.state.warnings[index]
|
||||||
|
let material = (store.state.invoices + store.state.payments + store.state.photos)
|
||||||
|
.first { warning.hasPrefix($0.name + ":") }
|
||||||
|
AppNotice(text: warning, tone: .warning,
|
||||||
|
actionTitle: material == nil ? nil : "查看材料",
|
||||||
|
action: { store.preview = material },
|
||||||
|
dismiss: { dismissedWarnings.insert(index) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 16) {
|
if hasMaterials {
|
||||||
summary("发票", symbol: "doc.text", count: store.state.invoices.count, subtitle: "\(store.state.unmatchedInvoices.count) 份待配对")
|
HStack(alignment: .top, spacing: 16) {
|
||||||
summary("付款截图", symbol: "creditcard", count: store.state.payments.count, subtitle: "\(store.state.unmatchedPayments.count) 份待配对")
|
StatNumber(title: "发票", value: "\(store.state.invoices.count)", symbol: "doc.text", detail: "\(store.state.unmatchedInvoices.count) 份待配对")
|
||||||
summary("实物照片", symbol: "photo.on.rectangle", count: store.state.photos.count, subtitle: "供 PPT 手工贴入")
|
StatNumber(title: "付款截图", value: "\(store.state.payments.count)", symbol: "creditcard", detail: "\(store.state.unmatchedPayments.count) 份待配对")
|
||||||
}
|
StatNumber(title: "实物照片", value: "\(store.state.photos.count)", symbol: "photo")
|
||||||
Text("金额概览").font(.headline)
|
StatNumber(title: "已核对", value: "\(store.state.matches.count)", symbol: "checkmark.seal",
|
||||||
HStack(alignment: .top, spacing: 16) {
|
detail: "自动 \(store.state.autoCount) · 人工 \(store.state.manualCount)")
|
||||||
amountPanel("本次导入材料", subtitle: "包含已核对和待核对材料", invoice: store.state.directoryInvoiceTotal, payment: store.state.directoryPaymentTotal)
|
}
|
||||||
amountPanel("已核对材料", subtitle: "自动匹配与人工确认均计入", invoice: store.state.invoiceTotal, payment: store.state.paymentTotal)
|
Divider()
|
||||||
}
|
AppSection(title: "金额概览", symbol: "yensign.circle") {
|
||||||
if !store.state.matches.isEmpty {
|
HStack(alignment: .top, spacing: 32) {
|
||||||
Panel {
|
amountPanel("本次导入", subtitle: "全部材料", invoice: store.state.directoryInvoiceTotal, payment: store.state.directoryPaymentTotal)
|
||||||
|
Divider()
|
||||||
|
amountPanel("已核对", subtitle: "\(store.state.matches.count) 组", invoice: store.state.invoiceTotal, payment: store.state.paymentTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !store.state.matches.isEmpty {
|
||||||
|
Divider()
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text("已核对付款 · 分类汇总").font(.headline)
|
Text("已核对付款 · 分类汇总").font(.headline)
|
||||||
LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], alignment: .leading, spacing: 18) {
|
LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], alignment: .leading, spacing: 18) {
|
||||||
ForEach(expenseCategories, id: \.self) { category in
|
ForEach(expenseCategories, id: \.self) { category in
|
||||||
let total = store.state.matches.filter { $0.category == category }.reduce(0) { $0 + $1.paymentTotal }
|
let total = store.presentation.categoryPayments[category, default: 0]
|
||||||
if total > 0 {
|
if total > 0 {
|
||||||
VStack(alignment: .leading, spacing: 7) {
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
Text(category).font(.caption).foregroundStyle(.secondary)
|
Text(category).font(.caption).foregroundStyle(.secondary)
|
||||||
Text(currency(total)).font(.headline).monospacedDigit()
|
Text(currency(total)).font(.headline).monospacedDigit()
|
||||||
|
ProgressView(value: total, total: max(total, store.state.paymentTotal))
|
||||||
|
.tint(AppTheme.accent).accessibilityLabel("\(category)付款金额占比")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Divider()
|
||||||
|
HStack {
|
||||||
|
Label("识别完成 \(store.state.recognizedCount) 份", systemImage: "text.viewfinder")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
HStack(spacing: 24) {
|
}.padding(AppTheme.page)
|
||||||
Label("自动核对 \(store.state.autoCount)", systemImage: "sparkles")
|
|
||||||
Label("人工核对 \(store.state.manualCount)", systemImage: "hand.draw")
|
|
||||||
Label("识别完成 \(store.state.recognizedCount)", systemImage: "text.viewfinder")
|
|
||||||
Spacer()
|
|
||||||
Button("前往人工配对") { store.page = .pair }.disabled(store.busy)
|
|
||||||
}.font(.callout).foregroundStyle(.secondary)
|
|
||||||
Divider()
|
|
||||||
HStack {
|
|
||||||
Text("工作区自动保存在本机,不修改原始材料。").font(.caption).foregroundStyle(.secondary)
|
|
||||||
Spacer()
|
|
||||||
Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy)
|
|
||||||
}
|
|
||||||
}.padding(28)
|
|
||||||
}
|
}
|
||||||
}
|
.onChange(of: store.state.invoices.map(\.id) + store.state.payments.map(\.id) + store.state.photos.map(\.id)) { _, _ in
|
||||||
|
dismissedWarnings = []; expandedWarnings = false
|
||||||
private func summary(_ title: String, symbol: String, count: Int, subtitle: String) -> some View {
|
|
||||||
Panel {
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
|
||||||
Label(title, systemImage: symbol).font(.callout).foregroundStyle(.teal)
|
|
||||||
Text("\(count)").font(.system(size: 34, weight: .semibold, design: .rounded))
|
|
||||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func amountPanel(_ title: String, subtitle: String, invoice: Double, payment: Double) -> some View {
|
private func amountPanel(_ title: String, subtitle: String, invoice: Double, payment: Double) -> some View {
|
||||||
Panel {
|
|
||||||
VStack(alignment: .leading, spacing: 17) {
|
VStack(alignment: .leading, spacing: 17) {
|
||||||
VStack(alignment: .leading, spacing: 5) {
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
Text(title).font(.headline)
|
Text(title).font(.headline)
|
||||||
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
Text(subtitle).font(.caption).foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
HStack { Text("发票总额").foregroundStyle(.secondary); Spacer(); Text(currency(invoice)).fontWeight(.semibold) }
|
HStack { Text("发票总额").foregroundStyle(.secondary); Spacer(); Text(currency(invoice)).fontWeight(.semibold) }
|
||||||
HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold).foregroundStyle(.teal) }
|
HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold) }
|
||||||
}.monospacedDigit()
|
}.monospacedDigit().frame(maxWidth: .infinity)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ enum SpreadsheetZoom: String, CaseIterable {
|
|||||||
struct ExpenseSpreadsheetPreview: View {
|
struct ExpenseSpreadsheetPreview: View {
|
||||||
let layout: SpreadsheetLayout
|
let layout: SpreadsheetLayout
|
||||||
var preferredSheet: String?
|
var preferredSheet: String?
|
||||||
|
var selectedReference: String?
|
||||||
|
var highlightedRow: Int?
|
||||||
|
var onSelectCell: ((String) -> Void)?
|
||||||
var onExpand: (() -> Void)?
|
var onExpand: (() -> Void)?
|
||||||
@State private var sheetIndex = 0
|
@State private var sheetIndex = 0
|
||||||
@State private var zoom: SpreadsheetZoom = .width
|
@State private var zoom: SpreadsheetZoom = .width
|
||||||
@@ -115,13 +118,18 @@ struct ExpenseSpreadsheetPreview: View {
|
|||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
Picker("工作表", selection: $sheetIndex) {
|
Picker("工作表", selection: $sheetIndex) {
|
||||||
ForEach(layout.sheets.indices, id: \.self) { Text(layout.sheets[$0].name).tag($0) }
|
ForEach(layout.sheets.indices, id: \.self) { Text(layout.sheets[$0].name).tag($0) }
|
||||||
}.labelsHidden().frame(minWidth: 90, maxWidth: 200)
|
}.labelsHidden().frame(minWidth: 70, maxWidth: geometry.size.width < 540 ? 130 : 200)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: {
|
if geometry.size.width >= 540 {
|
||||||
if let mode = $0 { zoom = mode; customScale = nil }
|
zoomPicker.pickerStyle(.segmented).frame(width: 190)
|
||||||
})) {
|
} else {
|
||||||
ForEach(SpreadsheetZoom.allCases, id: \.self) { Text($0.rawValue).tag(Optional($0)) }
|
Menu {
|
||||||
}.pickerStyle(.segmented).labelsHidden().frame(width: 190)
|
ForEach(SpreadsheetZoom.allCases, id: \.self) { mode in
|
||||||
|
Button(mode.rawValue) { zoom = mode; customScale = nil }
|
||||||
|
}
|
||||||
|
} label: { Image(systemName: "arrow.up.left.and.down.right.magnifyingglass") }
|
||||||
|
.menuStyle(.borderlessButton).fixedSize().help("缩放方式")
|
||||||
|
}
|
||||||
Button { customScale = max(0.1, scale - 0.1) } label: {
|
Button { customScale = max(0.1, scale - 0.1) } label: {
|
||||||
Image(systemName: "minus.magnifyingglass")
|
Image(systemName: "minus.magnifyingglass")
|
||||||
}.help("缩小").disabled(scale <= 0.1)
|
}.help("缩小").disabled(scale <= 0.1)
|
||||||
@@ -135,14 +143,15 @@ struct ExpenseSpreadsheetPreview: View {
|
|||||||
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
Image(systemName: "arrow.up.left.and.arrow.down.right")
|
||||||
}.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口")
|
}.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口")
|
||||||
}
|
}
|
||||||
}.padding(.horizontal, 16).frame(height: 44)
|
}.padding(.horizontal, geometry.size.width < 540 ? 8 : 16).frame(height: 44)
|
||||||
.background(Color(nsColor: .windowBackgroundColor))
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
ScrollView([.horizontal, .vertical]) {
|
ScrollView([.horizontal, .vertical]) {
|
||||||
SpreadsheetCanvas(sheet: sheet, scale: scale)
|
SpreadsheetCanvas(sheet: sheet, scale: scale, selectedReference: selectedReference,
|
||||||
|
highlightedRow: highlightedRow, onSelectCell: onSelectCell)
|
||||||
.frame(width: sheet.width * scale, height: sheet.height * scale)
|
.frame(width: sheet.width * scale, height: sheet.height * scale)
|
||||||
.background(.white).padding(24)
|
.background(.white).padding(24)
|
||||||
.frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top)
|
.frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top)
|
||||||
}.background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1)))
|
}.background(Color(nsColor: .underPageBackgroundColor))
|
||||||
.id(sheetIndex)
|
.id(sheetIndex)
|
||||||
}.onChange(of: sheetIndex) { _, _ in customScale = nil }
|
}.onChange(of: sheetIndex) { _, _ in customScale = nil }
|
||||||
.onChange(of: preferredSheet, initial: true) { _, name in
|
.onChange(of: preferredSheet, initial: true) { _, name in
|
||||||
@@ -152,15 +161,29 @@ struct ExpenseSpreadsheetPreview: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var zoomPicker: some View {
|
||||||
|
Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: {
|
||||||
|
if let mode = $0 { zoom = mode; customScale = nil }
|
||||||
|
})) {
|
||||||
|
ForEach(SpreadsheetZoom.allCases, id: \.self) { Text($0.rawValue).tag(Optional($0)) }
|
||||||
|
}.labelsHidden()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SpreadsheetCanvas: NSViewRepresentable {
|
struct SpreadsheetCanvas: NSViewRepresentable {
|
||||||
let sheet: SpreadsheetSheet
|
let sheet: SpreadsheetSheet
|
||||||
let scale: CGFloat
|
let scale: CGFloat
|
||||||
|
var selectedReference: String?
|
||||||
|
var highlightedRow: Int?
|
||||||
|
var onSelectCell: ((String) -> Void)?
|
||||||
func makeNSView(context: Context) -> SpreadsheetCanvasView { SpreadsheetCanvasView(sheet: sheet, scale: scale) }
|
func makeNSView(context: Context) -> SpreadsheetCanvasView { SpreadsheetCanvasView(sheet: sheet, scale: scale) }
|
||||||
func updateNSView(_ view: SpreadsheetCanvasView, context: Context) {
|
func updateNSView(_ view: SpreadsheetCanvasView, context: Context) {
|
||||||
view.sheet = sheet
|
view.sheet = sheet
|
||||||
view.scale = scale
|
view.scale = scale
|
||||||
|
view.selectedReference = selectedReference
|
||||||
|
view.highlightedRow = highlightedRow
|
||||||
|
view.onSelectCell = onSelectCell
|
||||||
view.needsDisplay = true
|
view.needsDisplay = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,6 +198,9 @@ struct SpreadsheetCanvas: NSViewRepresentable {
|
|||||||
|
|
||||||
var sheet: SpreadsheetSheet
|
var sheet: SpreadsheetSheet
|
||||||
var scale: CGFloat
|
var scale: CGFloat
|
||||||
|
var selectedReference: String?
|
||||||
|
var highlightedRow: Int?
|
||||||
|
var onSelectCell: ((String) -> Void)?
|
||||||
override var isFlipped: Bool { true }
|
override var isFlipped: Bool { true }
|
||||||
override var isOpaque: Bool { true }
|
override var isOpaque: Bool { true }
|
||||||
init(sheet: SpreadsheetSheet, scale: CGFloat) {
|
init(sheet: SpreadsheetSheet, scale: CGFloat) {
|
||||||
@@ -186,6 +212,15 @@ struct SpreadsheetCanvas: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
required init?(coder: NSCoder) { nil }
|
required init?(coder: NSCoder) { nil }
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
guard let onSelectCell else { super.mouseUp(with: event); return }
|
||||||
|
let point = convert(event.locationInWindow, from: nil)
|
||||||
|
let unscaled = NSPoint(x: point.x / scale, y: point.y / scale)
|
||||||
|
if let cell = sheet.cells.first(where: { $0.rect.contains(unscaled) }) {
|
||||||
|
onSelectCell(cell.reference)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func color(_ hex: String) -> NSColor {
|
static func color(_ hex: String) -> NSColor {
|
||||||
let value = UInt32(hex, radix: 16) ?? 0
|
let value = UInt32(hex, radix: 16) ?? 0
|
||||||
return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255,
|
return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255,
|
||||||
@@ -305,6 +340,16 @@ struct SpreadsheetCanvas: NSViewRepresentable {
|
|||||||
}
|
}
|
||||||
context.restoreGState()
|
context.restoreGState()
|
||||||
}
|
}
|
||||||
|
if onSelectCell != nil {
|
||||||
|
for cell in cells where cell.reference == selectedReference ||
|
||||||
|
(highlightedRow != nil && Int(cell.reference.filter(\.isNumber)) == highlightedRow) {
|
||||||
|
context.setFillColor(NSColor.systemTeal.withAlphaComponent(0.14).cgColor)
|
||||||
|
context.fill(cell.rect)
|
||||||
|
context.setStrokeColor(NSColor.systemTeal.cgColor)
|
||||||
|
context.setLineWidth(2 / scale)
|
||||||
|
context.stroke(cell.rect.insetBy(dx: 1 / scale, dy: 1 / scale))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func drawBorder(_ cell: SpreadsheetCell, edge: String, border: SpreadsheetBorder,
|
private func drawBorder(_ cell: SpreadsheetCell, edge: String, border: SpreadsheetBorder,
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import SwiftUI
|
|||||||
struct SpreadsheetFilePreview: View {
|
struct SpreadsheetFilePreview: View {
|
||||||
let url: URL
|
let url: URL
|
||||||
var preferredSheet: String?
|
var preferredSheet: String?
|
||||||
|
var selectedReference: String?
|
||||||
|
var highlightedRow: Int?
|
||||||
|
var onSelectCell: ((String) -> Void)?
|
||||||
@State private var layout: SpreadsheetLayout?
|
@State private var layout: SpreadsheetLayout?
|
||||||
@State private var error: String?
|
@State private var error: String?
|
||||||
@State private var attempt = 0
|
@State private var attempt = 0
|
||||||
@@ -17,7 +20,8 @@ struct SpreadsheetFilePreview: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
if let layout {
|
if let layout {
|
||||||
ExpenseDocumentPreview(layout: layout, title: "报销单模板", preferredSheet: preferredSheet)
|
ExpenseDocumentPreview(layout: layout, title: "报销单模板", preferredSheet: preferredSheet,
|
||||||
|
selectedReference: selectedReference, highlightedRow: highlightedRow, onSelectCell: onSelectCell)
|
||||||
} else if let error {
|
} else if let error {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
ContentUnavailableView("模板预览暂不可用", systemImage: "doc.badge.exclamationmark",
|
ContentUnavailableView("模板预览暂不可用", systemImage: "doc.badge.exclamationmark",
|
||||||
@@ -59,6 +63,9 @@ struct ExpenseDocumentPreview: View {
|
|||||||
let layout: SpreadsheetLayout
|
let layout: SpreadsheetLayout
|
||||||
var title = "报销单预览"
|
var title = "报销单预览"
|
||||||
var preferredSheet: String?
|
var preferredSheet: String?
|
||||||
|
var selectedReference: String?
|
||||||
|
var highlightedRow: Int?
|
||||||
|
var onSelectCell: ((String) -> Void)?
|
||||||
@StateObject private var expanded = SpreadsheetPreviewWindow()
|
@StateObject private var expanded = SpreadsheetPreviewWindow()
|
||||||
|
|
||||||
private var warnings: [String] {
|
private var warnings: [String] {
|
||||||
@@ -73,11 +80,13 @@ struct ExpenseDocumentPreview: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet) {
|
ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet,
|
||||||
|
selectedReference: selectedReference, highlightedRow: highlightedRow,
|
||||||
|
onSelectCell: onSelectCell, onExpand: onSelectCell == nil ? {
|
||||||
expanded.show(layout: layout, title: title, preferredSheet: preferredSheet)
|
expanded.show(layout: layout, title: title, preferredSheet: preferredSheet)
|
||||||
}
|
} : nil)
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
.background(.white)
|
.background(Color(nsColor: .textBackgroundColor))
|
||||||
if !warnings.isEmpty {
|
if !warnings.isEmpty {
|
||||||
Text(warnings.joined(separator: ";"))
|
Text(warnings.joined(separator: ";"))
|
||||||
.font(.caption).foregroundStyle(.secondary)
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum TemplateMappingValidation {
|
||||||
|
static func column(_ value: String) -> Bool {
|
||||||
|
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
|
guard !value.isEmpty, value.count <= 3, value.utf8.allSatisfy({ (65...90).contains($0) }) else { return false }
|
||||||
|
return value.utf8.reduce(0) { $0 * 26 + Int($1) - 64 } <= 256
|
||||||
|
}
|
||||||
|
static func address(_ value: String) -> Bool {
|
||||||
|
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||||
|
guard let range = value.range(of: #"^[A-Z]{1,3}[1-9][0-9]{0,3}$"#, options: .regularExpression),
|
||||||
|
range == value.startIndex..<value.endIndex else { return false }
|
||||||
|
return column(String(value.prefix(while: { $0.isLetter }))) &&
|
||||||
|
(Int(value.filter(\.isNumber)).map { $0 <= 2000 } ?? false)
|
||||||
|
}
|
||||||
|
static func references(_ value: String) -> [String] {
|
||||||
|
value.components(separatedBy: CharacterSet(charactersIn: ",,;;").union(.whitespacesAndNewlines)).filter { !$0.isEmpty }
|
||||||
|
}
|
||||||
|
static func issues(_ mapping: ExpenseTemplateMapping) -> [String] {
|
||||||
|
var result: [String] = []
|
||||||
|
for (key, label) in [("purpose", "用途"), ("amount", "金额")] {
|
||||||
|
if !column(mapping.columns[key, default: ""]) { result.append("请指定有效的\(label)列") }
|
||||||
|
}
|
||||||
|
if mapping.startRow < 1 || mapping.endRow < mapping.startRow || mapping.endRow > 2000 || mapping.endRow - mapping.startRow >= 200 {
|
||||||
|
result.append("明细范围须为 1–2000 行,每页最多 200 行")
|
||||||
|
}
|
||||||
|
let columns = mapping.columns.values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() }.filter { !$0.isEmpty }
|
||||||
|
if columns.contains(where: { !column($0) }) { result.append("列地址须在 A–IV 之间") }
|
||||||
|
if Set(columns).count != columns.count { result.append("明细列不能重复") }
|
||||||
|
let cells = mapping.cells.values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
|
||||||
|
let references = cells + references(mapping.signatureCells) + references(mapping.clearCells)
|
||||||
|
if references.contains(where: { !address($0) }) { result.append("单元格地址无效,例如 C23") }
|
||||||
|
if references.contains(where: { ref in Int(ref.filter(\.isNumber)).map { (mapping.startRow...max(mapping.startRow, mapping.endRow)).contains($0) } ?? false }) {
|
||||||
|
result.append("收款、合计、签字及清空位置不能放在明细行内")
|
||||||
|
}
|
||||||
|
let normalized = references.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() }
|
||||||
|
if Set(normalized).count != normalized.count { result.append("单元格地址不能重复") }
|
||||||
|
if self.references(mapping.signatureCells).count > 6 { result.append("签字岗位最多 6 项") }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum MaterialOrder: String, CaseIterable, Identifiable {
|
||||||
|
case date = "日期", amount = "金额", name = "文件名"
|
||||||
|
var id: Self { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MatchOrder: String, CaseIterable, Identifiable {
|
||||||
|
case date = "日期", amount = "金额", confidence = "证据评分"
|
||||||
|
var id: Self { self }
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MatchGroup {
|
||||||
|
var needsAttention: Bool {
|
||||||
|
(matchType == "auto" && (evidenceScore ?? 0) < 90) ||
|
||||||
|
(invoices + payments).contains { $0.ocr.status == "failed" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MatchedBoardLayout {
|
||||||
|
static let gap: CGFloat = 8
|
||||||
|
static let headerHeight: CGFloat = 68
|
||||||
|
static let tileHeight: CGFloat = 164
|
||||||
|
static let scrollGutter: CGFloat = 16
|
||||||
|
|
||||||
|
static func columnWidth(available: CGFloat, count: Int) -> CGFloat {
|
||||||
|
max(132, (available - CGFloat(max(0, count - 1)) * gap) / CGFloat(max(1, count)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkspaceFilter {
|
||||||
|
static func contains(_ query: String, fields: [String]) -> Bool {
|
||||||
|
query.split(whereSeparator: \.isWhitespace).allSatisfy { word in
|
||||||
|
fields.contains { $0.localizedCaseInsensitiveContains(String(word)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fields(_ item: Material) -> [String] {
|
||||||
|
[item.name, item.sortDate, item.issueDate, String(format: "%.2f", item.displayAmount)] +
|
||||||
|
item.ocr.merchants + item.ocr.amounts + item.ocr.dates
|
||||||
|
}
|
||||||
|
|
||||||
|
static func materials(_ items: [Material], query: String, order: MaterialOrder, descending: Bool = false) -> [Material] {
|
||||||
|
items.filter { contains(query, fields: fields($0)) }.sorted { left, right in
|
||||||
|
let comparison: ComparisonResult
|
||||||
|
switch order {
|
||||||
|
case .date: comparison = left.sortDate.compare(right.sortDate)
|
||||||
|
case .amount:
|
||||||
|
comparison = left.displayAmount == right.displayAmount ? .orderedSame :
|
||||||
|
left.displayAmount < right.displayAmount ? .orderedAscending : .orderedDescending
|
||||||
|
case .name: comparison = left.name.localizedStandardCompare(right.name)
|
||||||
|
}
|
||||||
|
if comparison == .orderedSame { return left.id < right.id }
|
||||||
|
return descending ? comparison == .orderedDescending : comparison == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func matches(_ items: [MatchGroup], query: String, category: String, attentionOnly: Bool,
|
||||||
|
order: MatchOrder, descending: Bool = false) -> [MatchGroup] {
|
||||||
|
items.enumerated().filter { _, match in
|
||||||
|
(category == "全部" || match.category == category) && (!attentionOnly || match.needsAttention) &&
|
||||||
|
contains(query, fields: [match.category, match.date, String(format: "%.2f", match.invoiceTotal),
|
||||||
|
String(format: "%.2f", match.paymentTotal), String(format: "%.2f", match.expenseAmount)] +
|
||||||
|
(match.invoices + match.payments).flatMap(fields))
|
||||||
|
}.sorted { left, right in
|
||||||
|
let comparison: ComparisonResult
|
||||||
|
switch order {
|
||||||
|
case .date: comparison = left.element.date.compare(right.element.date)
|
||||||
|
case .amount:
|
||||||
|
let a = left.element.expenseAmount > 0 ? left.element.expenseAmount : left.element.invoiceTotal
|
||||||
|
let b = right.element.expenseAmount > 0 ? right.element.expenseAmount : right.element.invoiceTotal
|
||||||
|
comparison = a == b ? .orderedSame : a < b ? .orderedAscending : .orderedDescending
|
||||||
|
case .confidence:
|
||||||
|
let a = left.element.evidenceScore ?? -1, b = right.element.evidenceScore ?? -1
|
||||||
|
comparison = a == b ? .orderedSame : a < b ? .orderedAscending : .orderedDescending
|
||||||
|
}
|
||||||
|
if comparison == .orderedSame { return left.offset < right.offset }
|
||||||
|
return descending ? comparison == .orderedDescending : comparison == .orderedAscending
|
||||||
|
}.map(\.element)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WorkspacePresentation {
|
||||||
|
var sortedMatches: [MatchGroup] = []
|
||||||
|
var matchNumbers: [String: Int] = [:]
|
||||||
|
var categoryCounts: [String: Int] = [:]
|
||||||
|
var categoryPayments: [String: Double] = [:]
|
||||||
|
var attentionCount = 0
|
||||||
|
|
||||||
|
init(_ state: Workspace = Workspace()) {
|
||||||
|
sortedMatches = state.sortedMatches
|
||||||
|
for (index, match) in sortedMatches.enumerated() {
|
||||||
|
matchNumbers[match.id] = index + 1
|
||||||
|
categoryCounts[match.category, default: 0] += 1
|
||||||
|
categoryPayments[match.category, default: 0] += match.paymentTotal
|
||||||
|
if match.needsAttention { attentionCount += 1 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func categoryColumns(for matches: [MatchGroup]) -> [MatchCategoryGroup] {
|
||||||
|
let grouped = Dictionary(grouping: matches, by: \.category)
|
||||||
|
let extraCategories = Set(grouped.keys).subtracting(expenseCategories).sorted()
|
||||||
|
return (expenseCategories + extraCategories).compactMap { category in
|
||||||
|
guard let items = grouped[category], !items.isEmpty else { return nil }
|
||||||
|
return MatchCategoryGroup(category: category, matches: items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MatchCategoryGroup: Identifiable {
|
||||||
|
let category: String
|
||||||
|
let matches: [MatchGroup]
|
||||||
|
var id: String { category }
|
||||||
|
var title: String { category.isEmpty ? "未分类" : category }
|
||||||
|
var invoiceTotal: Double { matches.reduce(0) { $0 + $1.invoiceTotal } }
|
||||||
|
var paymentTotal: Double { matches.reduce(0) { $0 + $1.paymentTotal } }
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import UniformTypeIdentifiers
|
|||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class WorkspaceStore: ObservableObject {
|
final class WorkspaceStore: ObservableObject {
|
||||||
@Published var state = Workspace()
|
@Published var state = Workspace() {
|
||||||
|
didSet { presentation = WorkspacePresentation(state) }
|
||||||
|
}
|
||||||
|
private(set) var presentation = WorkspacePresentation()
|
||||||
@Published var page: WorkspacePage? = .scan
|
@Published var page: WorkspacePage? = .scan
|
||||||
@Published var selectedInvoices: Set<String> = []
|
@Published var selectedInvoices: Set<String> = []
|
||||||
@Published var selectedPayments: Set<String> = []
|
@Published var selectedPayments: Set<String> = []
|
||||||
@@ -21,6 +24,8 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
@Published var purposes: [String: String] = [:]
|
@Published var purposes: [String: String] = [:]
|
||||||
@Published var signatures = defaultSignatures
|
@Published var signatures = defaultSignatures
|
||||||
@Published var showExpenseTemplate = false
|
@Published var showExpenseTemplate = false
|
||||||
|
@Published var showPayeeProfile = false
|
||||||
|
@Published var lastExportURL: URL?
|
||||||
@Published private(set) var expenseTemplate: SavedExpenseTemplate?
|
@Published private(set) var expenseTemplate: SavedExpenseTemplate?
|
||||||
@Published private(set) var expenseTemplateLoadError: String?
|
@Published private(set) var expenseTemplateLoadError: String?
|
||||||
@Published var templateDraft: ExpenseTemplateDraft?
|
@Published var templateDraft: ExpenseTemplateDraft?
|
||||||
@@ -28,14 +33,15 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
@Published var showExpensePreview = false
|
@Published var showExpensePreview = false
|
||||||
@Published private(set) var expensePreview: PreparedExpensePreview?
|
@Published private(set) var expensePreview: PreparedExpensePreview?
|
||||||
@Published private(set) var expensePreviewError: String?
|
@Published private(set) var expensePreviewError: String?
|
||||||
let payeeProfile = PayeeProfileStore()
|
let payeeProfile: PayeeProfileStore
|
||||||
private let engine = EngineBridge()
|
private let engine = EngineBridge()
|
||||||
private let storage: URL
|
private let storage: URL
|
||||||
private var cancelRequested = false
|
private var cancelRequested = false
|
||||||
private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) }
|
private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) }
|
||||||
|
|
||||||
init() {
|
init(storageURL: URL? = nil, payeeStorage: any PayeeStorage = KeychainPayeeStorage()) {
|
||||||
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
|
payeeProfile = PayeeProfileStore(storage: payeeStorage)
|
||||||
|
storage = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
|
||||||
do {
|
do {
|
||||||
try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true)
|
||||||
let session = storage.appendingPathComponent("workspace.json")
|
let session = storage.appendingPathComponent("workspace.json")
|
||||||
@@ -51,6 +57,7 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
} catch {
|
} catch {
|
||||||
expenseTemplateLoadError = error.localizedDescription
|
expenseTemplateLoadError = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
presentation = WorkspacePresentation(state)
|
||||||
restoreMatchExplanations()
|
restoreMatchExplanations()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +488,7 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
let data = try prepared.verifiedData(request: expenseExportRequest().request)
|
let data = try prepared.verifiedData(request: expenseExportRequest().request)
|
||||||
try data.write(to: destination, options: .atomic)
|
try data.write(to: destination, options: .atomic)
|
||||||
notice = "个人报销单已导出到 \(destination.lastPathComponent)"
|
notice = "个人报销单已导出到 \(destination.lastPathComponent)"
|
||||||
|
lastExportURL = destination
|
||||||
showExpensePreview = false
|
showExpensePreview = false
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
||||||
} catch { expensePreviewError = error.localizedDescription }
|
} catch { expensePreviewError = error.localizedDescription }
|
||||||
@@ -523,6 +531,7 @@ final class WorkspaceStore: ObservableObject {
|
|||||||
_ = try await run(request)
|
_ = try await run(request)
|
||||||
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
try Data(contentsOf: temporary).write(to: destination, options: .atomic)
|
||||||
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
|
||||||
|
lastExportURL = destination
|
||||||
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
NSWorkspace.shared.activateFileViewerSelecting([destination])
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
notice = "已取消导出"
|
notice = "已取消导出"
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import SwiftUI
|
|||||||
@main
|
@main
|
||||||
struct reimburseApp: App {
|
struct reimburseApp: App {
|
||||||
@StateObject private var store = WorkspaceStore()
|
@StateObject private var store = WorkspaceStore()
|
||||||
|
@State private var area = "local"
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
Window("贴票台", id: "workspace") {
|
Window("贴票台", id: "workspace") {
|
||||||
TabView {
|
TabView(selection: $area) {
|
||||||
AdminView().tabItem { Label("项目管理", systemImage: "person.3") }
|
ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") }.tag("local")
|
||||||
ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") }
|
AdminView().tabItem { Label("项目管理", systemImage: "person.3") }.tag("admin")
|
||||||
}
|
}
|
||||||
|
.tint(AppTheme.accent)
|
||||||
.environmentObject(store)
|
.environmentObject(store)
|
||||||
|
.modifier(WorkspaceDockProgress(busy: store.busy, progress: store.progress))
|
||||||
.task {
|
.task {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
if ProcessInfo.processInfo.arguments.contains("--verify-local-engine") {
|
if ProcessInfo.processInfo.arguments.contains("--verify-local-engine") {
|
||||||
@@ -32,6 +35,23 @@ struct reimburseApp: App {
|
|||||||
Button("导入报账材料…", action: store.chooseFolder)
|
Button("导入报账材料…", action: store.chooseFolder)
|
||||||
.keyboardShortcut("o").disabled(store.busy)
|
.keyboardShortcut("o").disabled(store.busy)
|
||||||
}
|
}
|
||||||
|
CommandMenu("本地贴票") {
|
||||||
|
Button("材料扫描") { area = "local"; store.page = .scan }.keyboardShortcut("1")
|
||||||
|
Button("人工配对") { area = "local"; store.page = .pair }.keyboardShortcut("2")
|
||||||
|
Button("已核对") { area = "local"; store.page = .matched }.keyboardShortcut("3")
|
||||||
|
Divider()
|
||||||
|
Button("搜索材料") {
|
||||||
|
NotificationCenter.default.post(name: WorkspaceCommand.find, object: nil)
|
||||||
|
}.keyboardShortcut("f").disabled(area != "local" || store.page == .scan)
|
||||||
|
Button("撤销最近核对组") {
|
||||||
|
if let match = store.state.matches.last { store.undo(match) }
|
||||||
|
}.keyboardShortcut("z", modifiers: [.command, .option])
|
||||||
|
.disabled(area != "local" || store.busy || store.state.matches.isEmpty)
|
||||||
|
Divider()
|
||||||
|
Button("报销模板…") { area = "local"; store.showExpenseTemplate = true }
|
||||||
|
.keyboardShortcut(",").disabled(store.busy)
|
||||||
|
Button("收款信息…") { area = "local"; store.showPayeeProfile = true }.disabled(store.busy)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import AppKit
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private struct EmptyPayeeStorage: PayeeStorage {
|
||||||
|
func read() throws -> Data? { nil }
|
||||||
|
func write(_ data: Data) throws {}
|
||||||
|
func delete() throws {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TestWindow: NSWindow {
|
||||||
|
override var canBecomeKey: Bool { true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct InterfaceSmokeTests {
|
||||||
|
@MainActor static func main() throws {
|
||||||
|
_ = NSApplication.shared
|
||||||
|
NSApp.setActivationPolicy(.accessory)
|
||||||
|
let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
|
||||||
|
let store = WorkspaceStore(storageURL: output.appendingPathComponent("isolated-workspace"), payeeStorage: EmptyPayeeStorage())
|
||||||
|
let fixture = output.appendingPathComponent("receipt.png")
|
||||||
|
let receipt = NSImage(size: NSSize(width: 640, height: 420))
|
||||||
|
receipt.lockFocus()
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSRect(x: 0, y: 0, width: 640, height: 420).fill()
|
||||||
|
("电子发票\n\n测试材料 · 交通费用\n\n金额 ¥128.00\n\n2026-09-20" as NSString).draw(
|
||||||
|
in: NSRect(x: 36, y: 36, width: 560, height: 340),
|
||||||
|
withAttributes: [.font: NSFont.systemFont(ofSize: 25), .foregroundColor: NSColor.black])
|
||||||
|
receipt.unlockFocus()
|
||||||
|
let bitmap = NSBitmapImageRep(data: receipt.tiffRepresentation!)!
|
||||||
|
try bitmap.representation(using: .png, properties: [:])!.write(to: fixture)
|
||||||
|
try checkImageLoading(fixture: fixture, output: output)
|
||||||
|
let layout = try checkCellSelection()
|
||||||
|
var ocr = OCRData()
|
||||||
|
ocr.status = "success"; ocr.amounts = ["128.00"]; ocr.merchants = ["测试交通服务有限公司"]; ocr.dates = ["2026-09-20"]
|
||||||
|
func material(_ index: Int, type: String) -> Material {
|
||||||
|
Material(id: "\(type)-\(index)", name: "\(index)-测试交通费用凭证.pdf", path: fixture.path, type: type,
|
||||||
|
size: 2048, matched: false, previewPath: fixture.path, ocr: ocr,
|
||||||
|
displayAmount: 128, sortDate: "2026-09-20", issueDate: "2026-09-20")
|
||||||
|
}
|
||||||
|
var workspace = Workspace(rootPath: "九月项目报销")
|
||||||
|
workspace.invoices = (1...12).map { material($0, type: "invoice") }
|
||||||
|
workspace.payments = (1...10).map { material($0, type: "payment") }
|
||||||
|
workspace.warnings = (1...5).map { "材料 \($0) 日期待核对" }
|
||||||
|
workspace.directoryPaymentTotal = 1280
|
||||||
|
for index in 0..<7 {
|
||||||
|
var invoice = workspace.invoices[index], payment = workspace.payments[index]
|
||||||
|
invoice.matched = true; payment.matched = true
|
||||||
|
workspace.invoices[index] = invoice; workspace.payments[index] = payment
|
||||||
|
workspace.matches.append(MatchGroup(id: "match-\(index)", invoices: [invoice], payments: [payment],
|
||||||
|
category: expenseCategories[index], matchType: index == 0 ? "auto" : "manual",
|
||||||
|
score: 100, reasons: [], paymentTotal: 128, expenseAmount: 128))
|
||||||
|
}
|
||||||
|
store.state = workspace
|
||||||
|
store.updateCategory(workspace.matches[0], category: "住宿")
|
||||||
|
let moved = store.presentation.categoryColumns(for: store.state.matches)
|
||||||
|
precondition(!moved.contains(where: { $0.category == "餐饮" }))
|
||||||
|
precondition(moved.first(where: { $0.category == "住宿" })!.matches.count == 2)
|
||||||
|
precondition(store.state.matches.map(\.id) == workspace.matches.map(\.id))
|
||||||
|
store.updateCategory(store.state.matches[0], category: "餐饮")
|
||||||
|
precondition(store.presentation.categoryColumns(for: store.state.matches).map(\.id) == expenseCategories)
|
||||||
|
print("Changing category updates columns without changing match identifiers or export order")
|
||||||
|
let model = AdminStore()
|
||||||
|
model.access = AdminAccess(userId: "test", platform: true, admin: true, projects: [], groups: [])
|
||||||
|
model.expenses = (1...6).map { index in
|
||||||
|
AdminExpense(id: "\(index)", groupId: 1, projectId: 1, userId: "fixture",
|
||||||
|
name: "测试申请人\(index)", team: "制片组", projectName: "测试项目",
|
||||||
|
type: "交通差旅", amountCents: 12800, amountText: "128.00", note: "",
|
||||||
|
status: "待审核", state: "pending", date: "2026-09-20", version: 1, files: [:], events: [])
|
||||||
|
}
|
||||||
|
model.policies = [AdminExpensePolicy(type: "交通差旅", description: "项目交通费用", tips: "提供行程材料",
|
||||||
|
requiredKinds: [], materials: [], active: true)]
|
||||||
|
for dark in [false, true] {
|
||||||
|
NSApp.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
|
||||||
|
var pairWorkspace = workspace
|
||||||
|
pairWorkspace.matches = []
|
||||||
|
pairWorkspace.invoices = (1...12).map { material($0, type: "invoice") }
|
||||||
|
pairWorkspace.payments = (1...12).map { material($0, type: "payment") }
|
||||||
|
store.state = pairWorkspace
|
||||||
|
let invoiceIDs = WorkspaceFilter.materials(pairWorkspace.invoices, query: "", order: .date).map(\.id)
|
||||||
|
let paymentIDs = WorkspaceFilter.materials(pairWorkspace.payments, query: "", order: .date).map(\.id)
|
||||||
|
for (invoiceCount, paymentCount) in [(1, 1), (1, 6), (6, 1), (6, 6)] {
|
||||||
|
store.selectedInvoices = Set(invoiceIDs.prefix(invoiceCount))
|
||||||
|
store.selectedPayments = Set(paymentIDs.prefix(paymentCount))
|
||||||
|
try render(PairPage().environmentObject(store), size: CGSize(width: 1280, height: 800),
|
||||||
|
name: "pair-connections-\(invoiceCount)x\(paymentCount)-\(dark)", output: output)
|
||||||
|
}
|
||||||
|
store.selectedInvoices = []; store.selectedPayments = []
|
||||||
|
for size in [CGSize(width: 1280, height: 800), CGSize(width: 1024, height: 640), CGSize(width: 900, height: 560)] {
|
||||||
|
let suffix = "\(dark ? "dark" : "light")-\(Int(size.width))"
|
||||||
|
store.state = Workspace()
|
||||||
|
try render(ScanPage(confirmClear: .constant(false)).environmentObject(store), size: size, name: "empty-\(suffix)", output: output)
|
||||||
|
store.state = workspace
|
||||||
|
try render(ScanPage(confirmClear: .constant(false)).environmentObject(store), size: size, name: "scan-\(suffix)", output: output)
|
||||||
|
try render(PairPage().environmentObject(store), size: size, name: "pair-\(suffix)", output: output)
|
||||||
|
try render(MatchedPage().environmentObject(store), size: size, name: "matched-\(suffix)", output: output)
|
||||||
|
try render(AdminExpensesTab(model: model), size: size, name: "admin-\(suffix)", output: output)
|
||||||
|
try render(AdminPoliciesTab(model: model), size: size, name: "policies-\(suffix)", output: output)
|
||||||
|
for page in WorkspacePage.allCases {
|
||||||
|
store.page = page
|
||||||
|
try render(ContentView().environmentObject(store), size: size, name: "workspace-\(page.id)-\(suffix)", output: output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var columnWorkspace = workspace
|
||||||
|
columnWorkspace.matches = (0..<12).map { index in
|
||||||
|
var match = workspace.matches[index % 3]
|
||||||
|
match.id = "column-\(index)"
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
columnWorkspace.matches[0].payments = Array(workspace.payments.prefix(4))
|
||||||
|
let noisyCandidates = ["载次数", "名称:测试购买方有限公司", "全称", "一站式企业出行与商旅平台"]
|
||||||
|
for index in columnWorkspace.matches.indices {
|
||||||
|
columnWorkspace.matches[index].invoices[0].ocr.merchants = [noisyCandidates[index % noisyCandidates.count]]
|
||||||
|
}
|
||||||
|
store.state = columnWorkspace
|
||||||
|
precondition(store.presentation.categoryColumns(for: store.state.matches).map(\.id) == Array(expenseCategories.prefix(3)))
|
||||||
|
try render(MatchedPage().environmentObject(store), size: CGSize(width: 1024, height: 640),
|
||||||
|
name: "matched-populated-types-\(dark)", output: output)
|
||||||
|
try render(MatchedCategoryBoard(columns: store.presentation.categoryColumns(for: store.state.matches),
|
||||||
|
selection: .constant(MatchedPPTSelection(ids: ["column-0", "column-1"])))
|
||||||
|
.environmentObject(store), size: CGSize(width: 1900, height: 720), name: "category-columns-\(dark)", output: output)
|
||||||
|
try render(MatchedCategoryBoard(columns: store.presentation.categoryColumns(for: store.state.matches),
|
||||||
|
selection: .constant(MatchedPPTSelection(ids: ["column-0", "column-1"])))
|
||||||
|
.environmentObject(store), size: CGSize(width: 1000, height: 560), name: "category-compact-\(dark)", output: output)
|
||||||
|
try render(MatchedGroupInspector(matchID: "column-0", orderedIDs: columnWorkspace.matches.map(\.id),
|
||||||
|
selection: .constant(MatchedPPTSelection(ids: ["column-0"])),
|
||||||
|
navigate: { _ in }).environmentObject(store),
|
||||||
|
size: AppTheme.sheetSize(width: 1040, height: 740), name: "matched-inspector-\(dark)", output: output)
|
||||||
|
store.state = Workspace()
|
||||||
|
try render(MatchedPage().environmentObject(store), size: CGSize(width: 900, height: 560),
|
||||||
|
name: "matched-empty-\(dark)", output: output)
|
||||||
|
store.state = workspace
|
||||||
|
store.openExpense(selectedMatchIDs: Set(workspace.matches.map(\.id)))
|
||||||
|
let sheetSize = AppTheme.sheetSize(width: 900, height: 700)
|
||||||
|
try render(ExpenseSheet().environmentObject(store), size: sheetSize, name: "expense-\(dark)", output: output)
|
||||||
|
try render(AdminLoginView(model: AdminStore()), size: CGSize(width: 1024, height: 640), name: "login-\(dark)", output: output)
|
||||||
|
try render(ExpenseTemplateManager().environmentObject(store),
|
||||||
|
size: AppTheme.sheetSize(width: 1100, height: 760), name: "templates-\(dark)", output: output)
|
||||||
|
try render(ExpenseSpreadsheetPreview(layout: layout, selectedReference: "A1", highlightedRow: 2,
|
||||||
|
onSelectCell: { _ in }),
|
||||||
|
size: CGSize(width: 320, height: 500), name: "interactive-sheet-\(dark)", output: output)
|
||||||
|
}
|
||||||
|
print("Native light/dark compact/desktop screenshots passed: \(output.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor static func checkCellSelection() throws -> SpreadsheetLayout {
|
||||||
|
let style = SpreadsheetStyle(font: "Arial", fontSize: 14, bold: false, italic: false,
|
||||||
|
underline: false, strike: false, color: "000000", fill: "FFFFFF",
|
||||||
|
horizontal: "left", vertical: "center", wrap: false, shrink: false,
|
||||||
|
rotation: 0, indent: 0, borders: [:], accounting: false)
|
||||||
|
let cells = (0..<8).map { row in
|
||||||
|
SpreadsheetCell(reference: "A\(row + 1)", x: 0, y: Double(row * 50), width: 240, height: 50,
|
||||||
|
text: "A\(row + 1) 测试单元格", style: style, overflowLeft: 0, overflowRight: 240)
|
||||||
|
}
|
||||||
|
let sheet = SpreadsheetSheet(name: "测试报销模板", width: 240, height: 400, cells: cells)
|
||||||
|
for scale in [0.5, 1, 1.5] {
|
||||||
|
let canvas = SpreadsheetCanvasView(sheet: sheet, scale: scale)
|
||||||
|
let window = NSWindow(contentRect: canvas.frame, styleMask: [.borderless], backing: .buffered, defer: false)
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.contentView = canvas
|
||||||
|
var selected: String?
|
||||||
|
canvas.onSelectCell = { selected = $0 }
|
||||||
|
let location = canvas.convert(NSPoint(x: 100 * scale, y: 75 * scale), to: nil)
|
||||||
|
let event = NSEvent.mouseEvent(with: .leftMouseUp, location: location, modifierFlags: [],
|
||||||
|
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
||||||
|
eventNumber: 1, clickCount: 1, pressure: 1)!
|
||||||
|
canvas.mouseUp(with: event)
|
||||||
|
precondition(selected == "A2", "Scaled cell hit testing failed")
|
||||||
|
window.contentView = nil
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
print("Spreadsheet cell selection passed at 50%, 100% and 150% zoom")
|
||||||
|
return try SpreadsheetLayout(version: 4, sheets: [sheet], warnings: []).validated()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@MainActor static func checkImageLoading(fixture: URL, output: URL) throws {
|
||||||
|
let pdf = output.appendingPathComponent("two-pages.pdf")
|
||||||
|
let data = NSMutableData()
|
||||||
|
var page = CGRect(x: 0, y: 0, width: 400, height: 600)
|
||||||
|
let context = CGContext(consumer: CGDataConsumer(data: data)!, mediaBox: &page, nil)!
|
||||||
|
for color in [CGColor(red: 1, green: 0, blue: 0, alpha: 1), CGColor(red: 0, green: 0, blue: 1, alpha: 1)] {
|
||||||
|
context.beginPDFPage(nil)
|
||||||
|
context.setFillColor(color)
|
||||||
|
context.fill(page)
|
||||||
|
context.endPDFPage()
|
||||||
|
}
|
||||||
|
context.closePDF()
|
||||||
|
try (data as Data).write(to: pdf)
|
||||||
|
var completed = false
|
||||||
|
Task { @MainActor in
|
||||||
|
let thumbnail = await MaterialImageLoader.load(fixture, pixels: 160)
|
||||||
|
precondition(thumbnail != nil && max(thumbnail!.size.width, thumbnail!.size.height) <= 160)
|
||||||
|
let image = await MaterialImageLoader.load(pdf, pixels: 300)
|
||||||
|
precondition(image != nil)
|
||||||
|
let bitmap = NSBitmapImageRep(data: image!.tiffRepresentation!)!
|
||||||
|
let center = bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)!.usingColorSpace(.deviceRGB)!
|
||||||
|
precondition(center.redComponent > 0.9 && center.blueComponent < 0.1, "Preview must use the first PDF page")
|
||||||
|
let missing = await MaterialImageLoader.load(output.appendingPathComponent("missing.png"), pixels: 160)
|
||||||
|
precondition(missing == nil)
|
||||||
|
let cancelled = Task { await MaterialImageLoader.load(fixture, pixels: 160) }
|
||||||
|
cancelled.cancel()
|
||||||
|
let result = await cancelled.value
|
||||||
|
precondition(result == nil)
|
||||||
|
completed = true
|
||||||
|
}
|
||||||
|
let deadline = Date().addingTimeInterval(15)
|
||||||
|
while !completed && Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.02)) }
|
||||||
|
precondition(completed, "Image loading timed out")
|
||||||
|
print("Async image downsampling, PDF first page, missing files and cancellation passed")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor static func render<V: View>(_ view: V, size: CGSize, name: String, output: URL) throws {
|
||||||
|
let dark = NSApp.appearance?.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
|
||||||
|
let host = NSHostingView(rootView: view.tint(AppTheme.accent)
|
||||||
|
.environment(\.colorScheme, dark ? .dark : .light)
|
||||||
|
.background(Color(nsColor: .windowBackgroundColor)))
|
||||||
|
let window = TestWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless],
|
||||||
|
backing: .buffered, defer: false)
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.contentView = host
|
||||||
|
window.appearance = NSApp.appearance
|
||||||
|
window.backgroundColor = .windowBackgroundColor
|
||||||
|
window.orderFront(nil)
|
||||||
|
host.layoutSubtreeIfNeeded()
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.35))
|
||||||
|
host.layoutSubtreeIfNeeded()
|
||||||
|
if name == "pair-light-900" || name == "matched-light-900" {
|
||||||
|
window.makeKey()
|
||||||
|
NotificationCenter.default.post(name: WorkspaceCommand.find, object: nil)
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
|
||||||
|
precondition((window.firstResponder as? NSTextView)?.isFieldEditor == true,
|
||||||
|
"Find command did not focus the search field: \(name)")
|
||||||
|
print("Search keyboard focus passed: \(name)")
|
||||||
|
}
|
||||||
|
precondition(host.bounds.width <= size.width + 1 && host.bounds.height <= size.height + 1,
|
||||||
|
"View exceeds viewport: \(name), \(host.bounds.size)")
|
||||||
|
let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds)!
|
||||||
|
host.cacheDisplay(in: host.bounds, to: bitmap)
|
||||||
|
if name.hasPrefix("pair-connections-") {
|
||||||
|
try checkPairScrolling(host: host, baseline: bitmap, name: name, output: output)
|
||||||
|
}
|
||||||
|
var shades = Set<Int>()
|
||||||
|
for x in stride(from: 0, to: bitmap.pixelsWide, by: 11) {
|
||||||
|
for y in stride(from: 0, to: bitmap.pixelsHigh, by: 11) {
|
||||||
|
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) {
|
||||||
|
shades.insert(Int((color.redComponent + color.greenComponent + color.blueComponent) * 85))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
precondition(shades.count > 8, "Blank or incomplete rendering: \(name)")
|
||||||
|
try bitmap.representation(using: .png, properties: [:])!.write(to: output.appendingPathComponent(name + ".png"))
|
||||||
|
window.orderOut(nil)
|
||||||
|
window.contentView = nil
|
||||||
|
window.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor static func checkPairScrolling(host: NSView, baseline: NSBitmapImageRep, name: String, output: URL) throws {
|
||||||
|
func descendants(_ view: NSView) -> [NSView] {
|
||||||
|
view.subviews.flatMap { [$0] + descendants($0) }
|
||||||
|
}
|
||||||
|
let scrolls = descendants(host).compactMap { $0 as? NSScrollView }
|
||||||
|
.filter { $0.documentView != nil && $0.bounds.width > 100 }
|
||||||
|
.sorted { host.convert($0.bounds, from: $0).minX < host.convert($1.bounds, from: $1).minX }
|
||||||
|
precondition(scrolls.count == 2, "Expected two native material scroll views")
|
||||||
|
func connectorPixels(_ bitmap: NSBitmapImageRep) -> Set<Int> {
|
||||||
|
let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width
|
||||||
|
let center = Int(host.bounds.midX * scale)
|
||||||
|
let top = Int(150 * scale), bottom = Int((host.bounds.height - 90) * scale)
|
||||||
|
var pixels = Set<Int>()
|
||||||
|
for x in (center - 8)...(center + 8) {
|
||||||
|
for y in top..<bottom {
|
||||||
|
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
|
||||||
|
color.greenComponent > color.redComponent + 0.12,
|
||||||
|
color.blueComponent > color.redComponent + 0.12 {
|
||||||
|
pixels.insert(y * bitmap.pixelsWide + x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pixels
|
||||||
|
}
|
||||||
|
var previous = connectorPixels(baseline)
|
||||||
|
precondition(previous.count > 10, "Selected pair lines missing without hover: \(name)")
|
||||||
|
let firstRowLimit = Int(host.bounds.height * 0.7 * CGFloat(baseline.pixelsWide) / host.bounds.width)
|
||||||
|
precondition(previous.contains { $0 / baseline.pixelsWide < firstRowLimit },
|
||||||
|
"Visible first-row cards are not connected: \(name)")
|
||||||
|
for (step, scroll) in [scrolls[1], scrolls[0], scrolls[1]].enumerated() {
|
||||||
|
let target = step == 2 ? CGFloat(0) : CGFloat(330)
|
||||||
|
scroll.contentView.scroll(to: NSPoint(x: 0, y: target))
|
||||||
|
scroll.reflectScrolledClipView(scroll.contentView)
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
|
||||||
|
host.layoutSubtreeIfNeeded()
|
||||||
|
precondition(abs(scroll.contentView.bounds.minY - target) < 2, "Native scroll did not move")
|
||||||
|
let snapshot = host.bitmapImageRepForCachingDisplay(in: host.bounds)!
|
||||||
|
host.cacheDisplay(in: host.bounds, to: snapshot)
|
||||||
|
let current = connectorPixels(snapshot)
|
||||||
|
precondition(current.count > 10, "Pair lines vanished on scroll: \(name), step \(step)")
|
||||||
|
precondition(current != previous, "Pair line positions did not update on scroll: \(name), step \(step)")
|
||||||
|
previous = current
|
||||||
|
try snapshot.representation(using: .png, properties: [:])!
|
||||||
|
.write(to: output.appendingPathComponent("\(name)-scroll-\(step).png"))
|
||||||
|
}
|
||||||
|
print("Persistent unhovered pair lines and independent native scrolling passed: \(name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct PairConnectionLayoutTests {
|
||||||
|
static func main() {
|
||||||
|
let left = CGRect(x: 0, y: 80, width: 300, height: 500)
|
||||||
|
let right = CGRect(x: 340, y: 80, width: 300, height: 500)
|
||||||
|
let invoices = (0..<4).map { "i\($0)" }, payments = (0..<4).map { "p\($0)" }
|
||||||
|
var frames: [String: CGRect] = [:]
|
||||||
|
for index in 0..<4 {
|
||||||
|
frames[invoices[index]] = CGRect(x: 3, y: 90 + index * 110, width: 290, height: 100)
|
||||||
|
frames[payments[index]] = CGRect(x: 343, y: 90 + index * 110, width: 290, height: 100)
|
||||||
|
}
|
||||||
|
func layout(_ selectedInvoices: Set<String>, _ selectedPayments: Set<String>,
|
||||||
|
_ currentFrames: [String: CGRect]) -> PairConnectionLayout {
|
||||||
|
PairConnectionLayout(invoiceIDs: invoices, paymentIDs: payments,
|
||||||
|
selectedInvoices: selectedInvoices, selectedPayments: selectedPayments,
|
||||||
|
frames: currentFrames, invoiceViewport: left, paymentViewport: right)
|
||||||
|
}
|
||||||
|
for (invoiceCount, paymentCount) in [(1, 1), (1, 4), (4, 1), (4, 4)] {
|
||||||
|
let result = layout(Set(invoices.prefix(invoiceCount)), Set(payments.prefix(paymentCount)), frames)
|
||||||
|
precondition(result.representedPairCount == invoiceCount * paymentCount)
|
||||||
|
precondition(result.invoices.count == invoiceCount && result.payments.count == paymentCount)
|
||||||
|
precondition(result.invoices.allSatisfy { $0.overflow == nil })
|
||||||
|
precondition(result.payments.allSatisfy { $0.overflow == nil })
|
||||||
|
precondition(result.lines.allSatisfy { result.clipRect.contains($0.start) && result.clipRect.contains($0.end) })
|
||||||
|
}
|
||||||
|
precondition(layout([], Set(payments), frames).lines.isEmpty)
|
||||||
|
precondition(layout(Set(invoices), [], frames).lines.isEmpty)
|
||||||
|
let initial = layout(["i0"], Set(payments), frames)
|
||||||
|
var scrolled = frames
|
||||||
|
for id in payments { scrolled[id] = frames[id]!.offsetBy(dx: 0, dy: -160) }
|
||||||
|
let afterScroll = layout(["i0"], Set(payments), scrolled)
|
||||||
|
precondition(afterScroll.invoices.map(\.point) == initial.invoices.map(\.point))
|
||||||
|
precondition(afterScroll.payments.map(\.point) != initial.payments.map(\.point))
|
||||||
|
precondition(afterScroll.payments.reduce(0) { $0 + $1.count } == 4)
|
||||||
|
precondition(afterScroll.payments.first(where: { $0.overflow == .above })?.count == 1)
|
||||||
|
precondition(afterScroll.representedPairCount == 4)
|
||||||
|
|
||||||
|
// Offscreen anchors can disappear from LazyVGrid without removing their selected relationships.
|
||||||
|
scrolled.removeValue(forKey: "p0")
|
||||||
|
let recycled = layout(["i0"], Set(payments), scrolled)
|
||||||
|
precondition(recycled.lines == afterScroll.lines)
|
||||||
|
scrolled.removeValue(forKey: "p3")
|
||||||
|
let bothEdges = layout(Set(invoices), Set(payments), scrolled)
|
||||||
|
precondition(bothEdges.payments.first(where: { $0.overflow == .above })?.count == 1)
|
||||||
|
precondition(bothEdges.payments.first(where: { $0.overflow == .below })?.count == 1)
|
||||||
|
precondition(bothEdges.invoices.reduce(0) { $0 + $1.count } * bothEdges.payments.reduce(0) { $0 + $1.count } == 16)
|
||||||
|
|
||||||
|
var partial = frames
|
||||||
|
partial["p0"] = CGRect(x: 343, y: 20, width: 290, height: 100)
|
||||||
|
let clipped = layout(["i0"], ["p0"], partial)
|
||||||
|
precondition(clipped.payments[0].point.y == 100)
|
||||||
|
precondition(clipped.payments[0].overflow == nil)
|
||||||
|
let allRecycled = layout(Set(invoices), Set(payments), [:])
|
||||||
|
precondition(allRecycled.invoices[0].count == 4 && allRecycled.payments[0].count == 4)
|
||||||
|
precondition(allRecycled.representedPairCount == 16 && allRecycled.lines.count <= 3)
|
||||||
|
|
||||||
|
let filtered = PairConnectionLayout(invoiceIDs: ["i0"], paymentIDs: ["p1"], selectedInvoices: Set(invoices),
|
||||||
|
selectedPayments: Set(payments), frames: frames, invoiceViewport: left, paymentViewport: right)
|
||||||
|
precondition(filtered.representedPairCount == 1)
|
||||||
|
let empty = PairConnectionLayout(invoiceIDs: invoices, paymentIDs: payments, selectedInvoices: Set(invoices),
|
||||||
|
selectedPayments: Set(payments), frames: frames, invoiceViewport: .zero, paymentViewport: right)
|
||||||
|
precondition(empty.lines.isEmpty)
|
||||||
|
let grid: [String: CGRect] = [
|
||||||
|
"i0": CGRect(x: 3, y: 90, width: 140, height: 150),
|
||||||
|
"i1": CGRect(x: 155, y: 90, width: 140, height: 150),
|
||||||
|
"p0": CGRect(x: 343, y: 90, width: 140, height: 150),
|
||||||
|
"p1": CGRect(x: 495, y: 90, width: 140, height: 150)
|
||||||
|
]
|
||||||
|
let routed = layout(["i0", "i1"], ["p0", "p1"], grid)
|
||||||
|
precondition(routed.representedPairCount == 4)
|
||||||
|
for line in routed.lines {
|
||||||
|
let stroke = CGRect(x: min(line.start.x, line.end.x) - 0.5, y: min(line.start.y, line.end.y) - 0.5,
|
||||||
|
width: abs(line.end.x - line.start.x) + 1, height: abs(line.end.y - line.start.y) + 1)
|
||||||
|
precondition(grid.values.allSatisfy { !stroke.intersects($0.insetBy(dx: 1, dy: 1)) },
|
||||||
|
"Connection crosses a card's contents")
|
||||||
|
}
|
||||||
|
print("Pair connections: 1:1, 1:N, N:1, N:M, independent scrolling, clipping, recycled cells and filtering passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TemplateMappingValidationTests {
|
||||||
|
static func main() {
|
||||||
|
for value in ["A", "c", "IV"] { precondition(TemplateMappingValidation.column(value)) }
|
||||||
|
for value in ["", "A1", "IW", "1", "=A"] { precondition(!TemplateMappingValidation.column(value)) }
|
||||||
|
for value in ["C23", "iv2000", "A1"] { precondition(TemplateMappingValidation.address(value)) }
|
||||||
|
for value in ["A0", "A2001", "A01", "IW1", "C", "C23:D24"] { precondition(!TemplateMappingValidation.address(value)) }
|
||||||
|
var mapping = ExpenseTemplateMapping(sheetName: "Sheet", columns: ["purpose": "C", "amount": "H"],
|
||||||
|
cells: ["recipient": "C23"], signatureCells: "A30,D30")
|
||||||
|
precondition(TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
|
mapping.cells["bankName"] = " "
|
||||||
|
precondition(TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
|
mapping.columns["amount"] = "C"
|
||||||
|
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
|
mapping.columns["amount"] = "H"
|
||||||
|
mapping.cells["recipient"] = "C10"
|
||||||
|
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
|
mapping.cells["recipient"] = "C23"
|
||||||
|
mapping.clearCells = "C23"
|
||||||
|
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
|
mapping.clearCells = ""
|
||||||
|
mapping.endRow = 8
|
||||||
|
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
|
||||||
|
precondition(TemplateMappingValidation.references("A1,B2; C3\nD4") == ["A1", "B2", "C3", "D4"])
|
||||||
|
print("Template column/address bounds, duplicate targets and row validation passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct WorkspacePresentationTests {
|
||||||
|
static func material(_ id: String, amount: Double, date: String, merchant: String = "测试商户") -> Material {
|
||||||
|
var ocr = OCRData()
|
||||||
|
ocr.status = "success"
|
||||||
|
ocr.amounts = [String(format: "%.2f", amount)]
|
||||||
|
ocr.merchants = [merchant]
|
||||||
|
return Material(id: id, name: id + ".pdf", path: "", type: "invoice", size: 0, matched: false,
|
||||||
|
previewPath: "", ocr: ocr, displayAmount: amount, sortDate: date, issueDate: date)
|
||||||
|
}
|
||||||
|
static func main() throws {
|
||||||
|
let a = material("a", amount: 10, date: "2026-09-02", merchant: "铁路")
|
||||||
|
let b = material("b", amount: 100, date: "2026-09-01")
|
||||||
|
let c = material("c", amount: 20, date: "2026-09-03")
|
||||||
|
let items = [a, b, c]
|
||||||
|
precondition(WorkspaceFilter.materials(items, query: "", order: .amount).map(\.id) == ["a", "c", "b"])
|
||||||
|
precondition(WorkspaceFilter.materials(items, query: "", order: .date).map(\.id) == ["b", "a", "c"])
|
||||||
|
precondition(WorkspaceFilter.materials(items, query: "铁路 10.00", order: .name).map(\.id) == ["a"])
|
||||||
|
precondition(WorkspaceFilter.materials(items, query: "B.PDF", order: .name).map(\.id) == ["b"])
|
||||||
|
precondition(WorkspaceFilter.materials(items, query: "2026-09-03", order: .name).map(\.id) == ["c"])
|
||||||
|
precondition(WorkspaceFilter.materials(items, query: "", order: .amount, descending: true).map(\.id) == ["b", "c", "a"])
|
||||||
|
var automatic = MatchGroup(id: "auto", invoices: [a], payments: [], category: "交通", matchType: "auto", score: 100, reasons: [])
|
||||||
|
let manual = MatchGroup(id: "manual", invoices: [b], payments: [], category: "其他", matchType: "manual", score: 100, reasons: [])
|
||||||
|
precondition(automatic.needsAttention && !manual.needsAttention)
|
||||||
|
automatic.explanation = try JSONDecoder().decode(MatchExplanation.self, from: Data("""
|
||||||
|
{"version":2,"title":"test","rawScore":55,"checks":[{"title":"amount","points":55,"maximum":100,"detail":"","issue":""}],"note":""}
|
||||||
|
""".utf8))
|
||||||
|
precondition(automatic.needsAttention)
|
||||||
|
var failed = manual
|
||||||
|
failed.id = "failed"
|
||||||
|
failed.invoices[0].ocr.status = "failed"
|
||||||
|
precondition(failed.needsAttention)
|
||||||
|
let matches = [automatic, manual, failed]
|
||||||
|
precondition(WorkspaceFilter.matches(matches, query: "", category: "全部", attentionOnly: true, order: .date).count == 2)
|
||||||
|
let visible = WorkspaceFilter.matches(matches, query: "铁路", category: "交通", attentionOnly: false, order: .date)
|
||||||
|
precondition(visible.map(\.id) == ["auto"])
|
||||||
|
var selection = MatchedPPTSelection(ids: ["manual"])
|
||||||
|
selection.setVisible(visible, selected: true)
|
||||||
|
precondition(selection.ids == ["manual", "auto"])
|
||||||
|
selection.setVisible(visible, selected: false)
|
||||||
|
precondition(selection.ids == ["manual"])
|
||||||
|
var workspace = Workspace()
|
||||||
|
workspace.matches = matches
|
||||||
|
let presentation = WorkspacePresentation(workspace)
|
||||||
|
precondition(presentation.matchNumbers.count == 3)
|
||||||
|
precondition(presentation.categoryCounts["其他"] == 2 && presentation.attentionCount == 2)
|
||||||
|
precondition(workspace.matches.map(\.id) == ["auto", "manual", "failed"])
|
||||||
|
let columns = presentation.categoryColumns(for: matches)
|
||||||
|
precondition(columns.map(\.category) == ["交通", "其他"])
|
||||||
|
precondition(columns.allSatisfy { !$0.matches.isEmpty })
|
||||||
|
let traffic = columns.first(where: { $0.category == "交通" })!
|
||||||
|
let other = columns.first(where: { $0.category == "其他" })!
|
||||||
|
precondition(traffic.matches.map(\.id) == ["auto"] && traffic.invoiceTotal == 10)
|
||||||
|
precondition(other.matches.map(\.id) == ["manual", "failed"] && other.invoiceTotal == 200)
|
||||||
|
precondition(columns.flatMap(\.matches).count == matches.count)
|
||||||
|
selection.setVisible(traffic.matches, selected: true)
|
||||||
|
selection.setVisible(other.matches, selected: true)
|
||||||
|
selection.setVisible(traffic.matches, selected: false)
|
||||||
|
precondition(selection.ids == ["manual", "failed"])
|
||||||
|
let filteredColumns = presentation.categoryColumns(for: visible)
|
||||||
|
precondition(filteredColumns.map(\.id) == ["交通"])
|
||||||
|
precondition(selection.ids == ["manual", "failed"])
|
||||||
|
precondition(presentation.categoryColumns(for: matches).map(\.id) == ["交通", "其他"])
|
||||||
|
let sorted = WorkspaceFilter.matches(matches, query: "", category: "全部", attentionOnly: false, order: .date)
|
||||||
|
precondition(presentation.categoryColumns(for: sorted).first(where: { $0.category == "其他" })!.matches.map(\.id) == ["manual", "failed"])
|
||||||
|
workspace.matches[1].category = "住宿"
|
||||||
|
var updated = WorkspacePresentation(workspace)
|
||||||
|
precondition(updated.categoryColumns(for: workspace.matches).first(where: { $0.category == "住宿" })!.matches.map(\.id) == ["manual"])
|
||||||
|
precondition(selection.ids == ["manual", "failed"])
|
||||||
|
let exported = try selection.exportWorkspace(from: workspace)
|
||||||
|
precondition(exported.matches.map(\.id) == ["manual", "failed"])
|
||||||
|
workspace.matches[2].category = "住宿"
|
||||||
|
updated = WorkspacePresentation(workspace)
|
||||||
|
precondition(updated.categoryColumns(for: workspace.matches).map(\.id) == ["住宿", "交通"])
|
||||||
|
workspace.matches.removeAll { $0.category == "住宿" }
|
||||||
|
precondition(WorkspacePresentation(workspace).categoryColumns(for: workspace.matches).map(\.id) == ["交通"])
|
||||||
|
workspace.matches = matches
|
||||||
|
workspace.matches[1].category = "住宿"
|
||||||
|
workspace.matches[0].category = "历史类型"
|
||||||
|
workspace.matches[2].category = ""
|
||||||
|
updated = WorkspacePresentation(workspace)
|
||||||
|
let extra = updated.categoryColumns(for: workspace.matches)
|
||||||
|
precondition(extra.map(\.id) == ["住宿", "", "历史类型"])
|
||||||
|
precondition(extra.first(where: { $0.category == "" })!.title == "未分类")
|
||||||
|
precondition(extra.first(where: { $0.category == "历史类型" })!.matches.map(\.id) == ["auto"])
|
||||||
|
precondition(updated.categoryColumns(for: []).isEmpty)
|
||||||
|
precondition(WorkspacePresentation().categoryColumns(for: []).isEmpty)
|
||||||
|
var noisy = manual
|
||||||
|
noisy.invoices[0].ocr.merchants = ["载次数", "名称:测试购买方有限公司", "全称", "一站式企业出行与商旅平台"]
|
||||||
|
let originalCandidates = noisy.invoices[0].ocr.merchants
|
||||||
|
precondition(WorkspaceFilter.matches([noisy], query: "测试购买方", category: "全部",
|
||||||
|
attentionOnly: false, order: .date).map(\.id) == [manual.id])
|
||||||
|
precondition(noisy.invoices[0].ocr.merchants == originalCandidates)
|
||||||
|
precondition(MatchedBoardLayout.columnWidth(available: 900, count: 7) == 132)
|
||||||
|
let desktopWidth = MatchedBoardLayout.columnWidth(available: 1100, count: 7)
|
||||||
|
precondition(abs(desktopWidth * 7 + MatchedBoardLayout.gap * 6 - 1100) < 0.001)
|
||||||
|
precondition(MatchedBoardLayout.columnWidth(available: 700, count: 1) == 700)
|
||||||
|
precondition(MatchedBoardLayout.columnWidth(available: 0, count: 0).isFinite)
|
||||||
|
print("Original OCR search preservation and compact category layout sizing passed")
|
||||||
|
print("Search, numeric sorting, attention filtering, cached summaries and hidden selection retention passed")
|
||||||
|
print("Automatic empty-column removal, category reappearance, filtering, selection and unchanged export order passed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { mkdtempSync, readdirSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join, dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const output = mkdtempSync(join(tmpdir(), 'receipt-ui-checks-'));
|
||||||
|
console.log(`Validation output: ${output}`);
|
||||||
|
const domain = ['Models', 'MatchExplanation'];
|
||||||
|
const cases = {
|
||||||
|
PairConnectionLayout: ['PairConnectionLayout'],
|
||||||
|
WorkspacePresentation: [...domain, 'WorkspacePresentation', 'MatchedPPTSelection'],
|
||||||
|
TemplateMappingValidation: ['ExpenseTemplate', 'TemplateMappingValidation'],
|
||||||
|
MatchedPPTSelection: [...domain, 'MatchedPPTSelection'],
|
||||||
|
ExpenseSummary: [...domain, 'MatchedPPTSelection', 'ExpenseSummary'],
|
||||||
|
MatchExplanation: domain,
|
||||||
|
ExpenseTemplate: ['ExpenseTemplate'],
|
||||||
|
ExpensePreview: ['ExpenseTemplate', 'ExpensePreview', 'SpreadsheetLayout'],
|
||||||
|
FolderImportSource: ['FolderImportSource'],
|
||||||
|
PayeeProfile: ['PayeeProfile'],
|
||||||
|
AdminAttachments: ['AdminModels'],
|
||||||
|
AdminPPTSelection: ['AdminModels'],
|
||||||
|
AdminPolicyEditor: ['AdminModels', 'AdminStore'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function run(command, args) {
|
||||||
|
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit' });
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.argv.includes('--render-only')) {
|
||||||
|
for (const [name, sources] of Object.entries(cases)) {
|
||||||
|
const binary = join(output, name);
|
||||||
|
run('swiftc', [...sources.map(source => `reimburse/${source}.swift`), `tests/${name}Tests.swift`, '-o', binary]);
|
||||||
|
run(binary, []);
|
||||||
|
}
|
||||||
|
console.log(`${Object.keys(cases).length} Swift test suites passed.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv.includes('--render') || process.argv.includes('--render-only')) {
|
||||||
|
const sources = readdirSync(join(root, 'reimburse'))
|
||||||
|
.filter(name => name.endsWith('.swift') && name !== 'reimburseApp.swift')
|
||||||
|
.map(name => `reimburse/${name}`);
|
||||||
|
const binary = join(output, 'InterfaceSmokeTests');
|
||||||
|
run('swiftc', [...sources, 'tests/InterfaceSmokeTests.swift', '-o', binary]);
|
||||||
|
run(binary, [join(output, 'screenshots')]);
|
||||||
|
}
|
||||||
|
console.log(`Validation output: ${output}`);
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# 贴票台 · 界面优化方案
|
||||||
|
|
||||||
|
> 依据当前代码(`reimburse/` 下 26 个 Swift 视图文件)整理,只提优化建议,不改动代码。
|
||||||
|
> 应用结构:顶层 TabView(`项目管理 AdminView` + `本地贴票 ContentView`),本地贴票内含 `材料扫描 ScanPage`、`人工配对 PairPage`、`已核对 MatchedPage` 三个页面,以及报销单模板、收款信息、报销单预览等弹窗。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现状盘点
|
||||||
|
|
||||||
|
### 信息架构
|
||||||
|
| 层级 | 内容 | 现状评价 |
|
||||||
|
|---|---|---|
|
||||||
|
| 顶层 Tab | 项目管理 / 本地贴票 | 应用名为"贴票台"且主功能是本地贴票,但项目管理排在第一个 Tab,与产品定位略有错位 |
|
||||||
|
| 本地贴票 | 侧栏 3 页 + 顶栏统计 + 工具栏 3 按钮 | 结构清晰,导航合理 |
|
||||||
|
| 弹窗 | 报销模板(1000×700)、收款信息(宽 580)、个人报销单(870×620)、预览(动态)、材料预览(1000×700) | 尺寸规格不统一,部分固定尺寸在小屏上有溢出风险 |
|
||||||
|
|
||||||
|
### 已做好的地方(应保留)
|
||||||
|
- `MaterialCard` 卡片 + Canvas 贝塞尔连线的配对交互,直观且具辨识度;
|
||||||
|
- `MatchExplanationPopover` 的 100 分制证据评分明细,透明度高;
|
||||||
|
- 拖拽导入的 dropDestination 高亮反馈、`.help()` 悬停提示、`accessibilityLabel` 覆盖较全;
|
||||||
|
- Table + 分组 Picker + 跨分组保留勾选的选择模型,业务逻辑扎实。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、设计系统层面(全局问题)
|
||||||
|
|
||||||
|
### 2.1 颜色语义不统一 ⭐ 高优先级
|
||||||
|
- **现状**:teal 同时承担「品牌色、强调色、成功色、进行中状态」四种语义(侧栏图标、付款金额、匹配满分、通知横幅全是 teal);警告用 orange、错误用 red、AdminView 里成功又用 green。同一语义在两个 Tab 中颜色不同。
|
||||||
|
- **建议**:
|
||||||
|
1. 建立语义色枚举(如 `AppTheme.success / warning / danger / accent`),收敛到一处定义;
|
||||||
|
2. teal 保留为品牌/强调色;成功态用系统 green、警告 orange、危险 red,与项目管理端对齐;
|
||||||
|
3. 「自动匹配 ≥90 分」与「人工确认」目前用 teal / blue 区分,建议改为统一的徽章组件(形状+图标+色),而非仅靠文字前缀。
|
||||||
|
- 金额显示可遵循国内财务习惯:发票与付款两列总额(topbar)目前一灰一 teal,对比弱,建议付款列加图标或用同色不同字重,避免用户误读为涨跌。
|
||||||
|
|
||||||
|
### 2.2 深色模式适配缺失 ⭐ 高优先级
|
||||||
|
- **现状**:`MaterialThumbnail` 用 `Color.white` 背景、`SpreadsheetPreview / ExpenseTemplateManager` 大量 `.background(.white)`、预览区 `.black.opacity(0.04)`。深色模式下会出现大面积刺眼的白色块与文字对比不足。
|
||||||
|
- **建议**:表格/缩略图区保留白底是合理的(还原纸张观感),但外层容器、边框、占位图标应改用 `Color(nsColor: .textBackgroundColor)` 或材质色;至少验证 `Window` 在深色外观下所有页面无纯白大色块、无低对比 caption。
|
||||||
|
|
||||||
|
### 2.3 排版与间距 token 化
|
||||||
|
- **现状**:`padding(28) / (26) / (22) / (20)`、`cornerRadius 16 / 13 / 12 / 10 / 9 / 8`、字号 `.title2 / .title3 / .system(size: 34 / 44 / 42)` 混用,页面间无对应关系。
|
||||||
|
- **建议**:定义间距(4 的倍数:8/12/16/24/28)与圆角(8 卡片内、12 卡片、16 面板)两档规格;页面主标题统一为 `.title2.bold()`(当前 PairPage 是 title3、MatchedPage 是 title2)。
|
||||||
|
|
||||||
|
### 2.4 组件重复实现
|
||||||
|
- `AdminView` 内自建了 `statusBadge / sectionCard / searchField / emptyState`,`ContentView` 一侧有 `Panel / EmptyPanel`,两套体系风格接近但参数不同。
|
||||||
|
- **建议**:抽出共享组件库(Badge、Card、SearchField、EmptyState、StatNumber),两个 Tab 复用,减少后续维护分叉。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、页面级优化
|
||||||
|
|
||||||
|
### 3.1 材料扫描 ScanPage
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 空工作区时仍显示全部统计面板(全是 0),首屏信息密度高但价值低 | 无材料时展示「导入引导」大空状态:仅保留拖拽卡片 + 模板卡片,统计面板在导入后再出现 | P1 |
|
||||||
|
| 单列滚动太长:导入卡、模板卡、警告、3 个统计卡、2 个金额面板、分类汇总、底部计数,关键动作「前往人工配对」沉底 | ① 统计卡片改为一行 4 列紧凑指标条;②「前往人工配对」在有待配对材料时上浮为醒目入口(如导入完成后的成功横幅内嵌跳转按钮);③ 分类汇总与金额概览合并为一个「金额」面板 | P1 |
|
||||||
|
| 拖拽高亮 `opacity 0→1` 突变 | 加 `withAnimation(.easeInOut(duration: 0.15))`,并让图标/文字随 targeted 状态做位移或缩放微动效 | P2 |
|
||||||
|
| warnings 面板无法关闭、数量多时占据大量空间 | 逐条可关闭 + 超过 3 条折叠为「N 条提醒」;每条加对应的跳转动作(如跳转到识别失败的材料) | P2 |
|
||||||
|
| 三个统计卡数字 `size: 34` 硬编码,视觉与「金额概览」面板重复 | 数字统一走 `StatNumber` 组件(`.rounded` 数字字体 + tabular),金额面板可考虑用小图表(分类占比条)替代纯文本 | P3 |
|
||||||
|
|
||||||
|
### 3.2 人工配对 PairPage
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 材料多时无法定位:无搜索、无排序切换、无金额/日期过滤 | 每栏顶部加搜索框(文件名/商户/金额)+ 排序菜单(日期/金额/文件名),复用 AdminView 的 `searchField` 样式 | P0 |
|
||||||
|
| 已选 N 张发票 × N 张付款为全连接,选 5×5 时 25 条虚线交叉难以阅读 | 连线改为:hover/聚焦某卡片时只高亮与它相关的连线;或选中的卡片显示序号,连线带序号标签 | P1 |
|
||||||
|
| 只有 Return 键可操作选中,无方向键导航 | 利用现有 `.focusable()` 补充 ↑↓ 移动焦点、Space 选中,形成完整键盘流;说明文字中标注快捷键 | P1 |
|
||||||
|
| 缩略图固定 180pt 网格,看不清细节需逐个开预览 | 增加缩放滑杆(紧凑/标准/大图三档),或双击卡片直接放大预览后可从预览中「选中此材料」 | P2 |
|
||||||
|
| 确认配对按钮位于右上角,与底部的选中信息分离 | 选中数 >0 时在两栏之间显示居中的「确认配对(N×M)」悬浮按钮,动线更短 | P2 |
|
||||||
|
| 空状态文案两栏雷同 | 左栏空时提示「所有发票已处理或已核对」,右栏提示对应付款文案,并可放「撤销上一组」入口 | P3 |
|
||||||
|
|
||||||
|
### 3.3 已核对 MatchedPage
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 无搜索/过滤:几十组材料时找特定金额/商户困难 | 表格上方加搜索框(金额、商户、日期),支持过滤后再勾选 | P0 |
|
||||||
|
| `MaterialCell` 只列前 2 个文件名链接,「还有 N 份」不可展开 | 点击单元格弹出 popover 缩略图网格,与 PairPage 的卡片视觉统一;>2 份时显示「+N」叠图效果 | P1 |
|
||||||
|
| 底部三行 caption 说明文字(勾选规则、PPT 版式)常驻 | 收敛为一个「导出说明」帮助按钮(`questionmark.circle`)弹出;常用信息已在按钮禁用态与选中计数中体现 | P2 |
|
||||||
|
| 分组多时 segmented Picker 挤压变形 | 分类 ≥5 个时改用 Menu + 下拉,或横向滚动 chips | P2 |
|
||||||
|
| 「操作」列撤销仅图标按钮,与分类 Picker 挤在一列 | 撤销改为 hover 行时出现的 trailing 按钮或右键菜单;分类修改也可考虑双击单元格弹出,减少列宽 | P2 |
|
||||||
|
| 表格无小计:分组切换后看不到当前分组金额合计 | 表格底部(或分组标题旁)显示「本分组合计:发票 X · 付款 Y」 | P2 |
|
||||||
|
| 评分 <90 分为橙色但没有聚合入口(「哪些组需要人工复核」) | 增加「需关注」过滤(评分 <90 或识别失败),配合 P0 搜索框;侧栏「已核对」徽标可显示低分组数量 | P1 |
|
||||||
|
|
||||||
|
### 3.4 个人报销单 ExpenseSheet 与预览
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 固定 `frame(width: 870, height: 620)`,小屏(MacBook Air 13" 缩放)可能顶出屏幕 | 改为 `min` 上限策略(参照 `ExpensePreviewSheet` 的 `min(1200, visibleFrame - 80)` 写法) | P1 |
|
||||||
|
| 费用行用途 TextField 无校验反馈(空值、重复用途不易发现) | 空用途时行内灰字提示;重复用途合并提示 | P3 |
|
||||||
|
| 签字岗位 minus.circle 无确认,误触即删 | 删除加轻量确认或支持撤销 toast;输入框加默认岗位快选 Menu | P3 |
|
||||||
|
| `expenseSelectionValid` 失效只显示一行红字,动作指引弱 | 失效时禁用预览按钮并把红字放进醒目横幅,附「返回重新选择」按钮 | P2 |
|
||||||
|
| 预览弹窗已较好;「返回修改」与「确认并保存」并排但视觉主次不明 | 确认按钮保持 prominent 即可;建议保存成功后用横幅告知保存路径并提供「在访达中显示」 | P2 |
|
||||||
|
|
||||||
|
### 3.5 报销模板管理 ExpenseTemplateManager / ExpenseTemplateFields
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 模板字段调整(列字母/单元格地址)要求用户手工填 "C" "C23",认知门槛高 | ① 输入框支持点选:在左侧表格预览上点击单元格即回填地址(SwiftUI 可用 onTapGesture + 布局信息);② 输入时实时校验字母/地址格式并给出示例反馈 | P1 |
|
||||||
|
| 「查看单元格地址与原文」DisclosureGroup 是纯文本行,对照困难 | 与左侧预览联动:hover 某行时高亮预览中对应行;保留行号列 | P3 |
|
||||||
|
| 未识别完整时只有一行橙色警告,确认按钮置灰原因不直观 | 每个缺失必填项(用途/金额列)在对应输入框旁标橙点,按钮旁注明「还差 N 项」 | P2 |
|
||||||
|
| 模板卡片(ExpenseTemplateSummary)与导入卡片视觉相似,都是 teal 虚线框 | 模板卡片改为普通卡片 + 右上「模板」徽标,虚线拖拽样式只留给导入场景,减少误拖 | P3 |
|
||||||
|
|
||||||
|
### 3.6 收款信息 PayeeProfileSheet
|
||||||
|
|
||||||
|
- 现状已经较好(SecureField + 显隐切换 + 删除确认)。可补充:保存成功后 toast 确认;`PayeeProfileSummary` 中「尾号 XXXX」处加复制按钮。
|
||||||
|
- 优先级 P3。
|
||||||
|
|
||||||
|
### 3.7 项目管理 AdminView
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| 单文件 1364 行、50+ 个 `@State`,六个页签全部内联 | 拆分为独立 View 文件(`AdminExpensesTab` 等);这不是纯界面问题,但直接影响后续所有 UI 迭代效率 | P1(技术债) |
|
||||||
|
| 自绘 tab 条(navigationItem)与 macOS 惯例不符,窗口宽度不足时六个标签挤压 | 采用 macOS 15 可用的 `TabView` 新样式或保留但允许横向滚动 + 更多收纳进 Menu | P2 |
|
||||||
|
| 登录页固定 400 宽输入区 + ScrollView,视觉重心偏上 | 居中卡片式布局;服务地址输入记忆历史地址下拉;微信扫码过期自动刷新提示 | P2 |
|
||||||
|
| 报销审批表无分页指示、无列排序 | Table 补充排序(金额/日期);行数多时显示计数 | P2 |
|
||||||
|
| 错误横幅红色整条较突兀 | 收敛为顶部统一通知组件(同本地贴票端的 notice 样式),成功/警告/错误三态 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、全局交互与反馈
|
||||||
|
|
||||||
|
1. **统一通知体系**(P1):目前成功提示是自定义 teal 横幅、错误是 `alert`、Admin 是红横幅。建议统一为三态通知条(成功/警告/错误),支持多条堆叠与自动消失(错误不自动消失),并复用 macOS 15 的 `SymbolEffect`。
|
||||||
|
2. **菜单栏与快捷键**(P2):已有 ⌘O 导入。建议补充:⌘Z 撤销最近核对组(复用 store.undo)、⌘F 聚焦搜索(配合 P0 搜索项)、⌘1/2/3 切换三个页面、⌘, 打开设置类弹窗(模板/收款信息)。
|
||||||
|
3. **空状态体系**(P1):EmptyPanel 目前只有图标+标题+说明,可按页面补一个行动按钮(如「选择文件夹」「前往人工配对」),形成闭环。
|
||||||
|
4. **进度反馈**(P2):busy 时是整页 `disabled` + 顶部进度条;扫描大目录时建议在 Dock 图标显示进度(NSApp.dockTile.progress),并保留页面滚动可读。
|
||||||
|
5. **危险操作确认**(P2):「清空重置」的 confirmationDialog 文案已很详细,但「恢复内置模板」「删除收款信息」等破坏性操作可统一加「需要输入确认/二次点击」模式中的轻量档:按钮文字改为具体后果(如「移除本机模板副本」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、性能相关(影响体感的界面项)
|
||||||
|
|
||||||
|
| 问题 | 建议 | 优先级 |
|
||||||
|
|---|---|---|
|
||||||
|
| `MaterialPreview` 用 `NSImage(contentsOf:)` 在主线程同步解码整图,大图/PDF 首页可能卡顿 | 改为 `task` 异步加载 + 占位 ProgressView;预览窗口打开先显示缩略图再换全图 | P1 |
|
||||||
|
| `Workspace.sortedMatches`、`matches(for:)`、`groups`、表格行的 `index(of:)` 都是每次重算的 computed 属性,匹配组多时 Table 滚动会重复计算 | 在 Store 层缓存排序结果(matches 变更时失效);`index(of:)` 用 `Dictionary(id→index)` 替代 | P2 |
|
||||||
|
| ScanPage 分类汇总对每个 category 全量 filter(7 × N) | 一次遍历 reduce 成字典再渲染 | P3 |
|
||||||
|
| `MaterialThumbnail` 700px 缩略图生成已异步(好);但 `task(id: previewURL)` 取消旧任务的行为依赖 detached task,建议显式检查 `Task.isCancelled` | 补充取消检查,避免快速滚动时旧图覆盖新图 | P2 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、无障碍与本地化细节
|
||||||
|
|
||||||
|
- caption / caption2 + `.tertiary` 的组合在浅色模式下对比度偏低(如「支持图片和 PDF · PDF 处理首页」),建议 tertiary 只用于辅助信息,主说明用 secondary。
|
||||||
|
- `MatchConfidenceCell` 的颜色(teal/orange)承载了语义,建议在 accessibilityLabel 中补充「评分高/需关注」文字(目前已有百分比,可再补状态词)。
|
||||||
|
- PairPage 连线是 Canvas 绘制,VoiceOver 完全不可见;建议在读出的标签中描述配对关系(已有 accessibilityLabel 基础,补充「已与 N 张付款连线」)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、实施路线图
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 预期收益 |
|
||||||
|
|---|---|---|
|
||||||
|
| **P0(先做)** | PairPage / MatchedPage 搜索与排序;低分组「需关注」过滤 | 解决材料量大时的核心查找痛点 |
|
||||||
|
| **P1** | 颜色语义统一 + 深色模式适配;语义色/间距 token 化;空状态行动按钮;ScanPage 信息层级重组;MaterialPreview 异步加载;AdminView 拆分 | 视觉一致性与小屏/深色体验 |
|
||||||
|
| **P2** | 统一通知组件;键盘快捷键;表格 MaterialCell 缩略图 popover;分组小计;模板字段可视化点选;拖拽动效;进度 Dock 反馈 | 效率与细节打磨 |
|
||||||
|
| **P3** | 统计卡组件化、金额小图表、模板卡片徽标、签字岗位快选 | 锦上添花 |
|
||||||
|
|
||||||
|
> 约束提醒:以上均为界面层建议,实施时须保持 README 中列明的业务口径不变(PPT 版式、金额计算口径、勾选保留规则等),尤其 P1 中 ScanPage 重组不要改变「再次导入替换工作区」的确认流程。
|
||||||
Reference in New Issue
Block a user