From b2c7b28e22b5351273766beed7f3347c40b68314 Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Wed, 16 Sep 2026 15:04:47 +0800 Subject: [PATCH] 1 --- README.md | 2 +- reimburse/MatchedPPTSelection.swift | 40 ++++++ reimburse/MatchedPage.swift | 203 +++++++++++++++++++-------- reimburse/WorkspaceStore.swift | 15 +- tests/MatchedPPTSelectionTests.swift | 62 ++++++++ 5 files changed, 264 insertions(+), 58 deletions(-) create mode 100644 reimburse/MatchedPPTSelection.swift create mode 100644 tests/MatchedPPTSelectionTests.swift diff --git a/README.md b/README.md index 851bd7a..18ad6f7 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ 2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 -5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。 +5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出 PPT。“全部”页可全选所有已核对组。行程 Excel 和个人报销单仍使用全部已核对材料。自动核对结果不代表已人工复核。 6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。 ## 功能对应 diff --git a/reimburse/MatchedPPTSelection.swift b/reimburse/MatchedPPTSelection.swift new file mode 100644 index 0000000..0262981 --- /dev/null +++ b/reimburse/MatchedPPTSelection.swift @@ -0,0 +1,40 @@ +import Foundation + +struct MatchedPPTSelection { + var ids: Set = [] + + func containsAll(_ matches: [MatchGroup]) -> Bool { + !matches.isEmpty && matches.allSatisfy { ids.contains($0.id) } + } + + mutating func setVisible(_ matches: [MatchGroup], selected: Bool) { + let visible = Set(matches.map(\.id)) + if selected { ids.formUnion(visible) } + else { ids.subtract(visible) } + } + + mutating func retain(_ identifiers: [String]) { + ids.formIntersection(identifiers) + } + + func exportWorkspace(from workspace: Workspace) throws -> Workspace { + guard !ids.isEmpty else { + throw SelectionFailure(message: "请先勾选要导出的已核对材料。") + } + guard ids.isSubset(of: Set(workspace.matches.map(\.id))) else { + throw SelectionFailure(message: "部分勾选材料已撤销或被替换,请重新选择后导出。") + } + var result = workspace + result.matches = workspace.matches.filter { ids.contains($0.id) } + result.invoices = result.matches.flatMap(\.invoices) + result.payments = result.matches.flatMap(\.payments) + result.photos = [] + result.directoryPaymentTotal = result.paymentTotal + return result + } + + private struct SelectionFailure: LocalizedError { + let message: String + var errorDescription: String? { message } + } +} diff --git a/reimburse/MatchedPage.swift b/reimburse/MatchedPage.swift index 8656951..8c2fc82 100644 --- a/reimburse/MatchedPage.swift +++ b/reimburse/MatchedPage.swift @@ -2,87 +2,180 @@ import SwiftUI struct MatchedPage: View { @EnvironmentObject var store: WorkspaceStore + @State private var selectedGroup = "全部" + @State private var pptSelection = MatchedPPTSelection() + + private var selectedMatches: [MatchGroup] { + store.state.matches.filter { pptSelection.ids.contains($0.id) } + } + + private var groups: [String] { + ["全部"] + Array(Set(store.state.matches.map(\.category))).sorted() + } + var body: some View { VStack(spacing: 18) { HStack { - Picker("排版", selection: $store.classified) { - Text("按类型分类").tag(true) - Text("不分类").tag(false) - }.pickerStyle(.segmented).frame(width: 200) + VStack(alignment: .leading, spacing: 4) { + Text("已核对材料").font(.title2.bold()) + Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary) + } Spacer() Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0) Button(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty) - Button { store.export(.ppt) } label: { Label("导出 PPT", systemImage: "square.and.arrow.up") } - .buttonStyle(.borderedProminent).disabled(store.state.matches.isEmpty) + Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: { + Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up") + } + .buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty) } if store.state.matches.isEmpty { EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。") } else { + Picker("分组", selection: $selectedGroup) { + ForEach(groups, id: \.self) { group in + Text(group == "全部" ? "全部(\(store.state.matches.count))" : "\(group)(\(count(for: group)))") + .tag(group) + } + } + .pickerStyle(.segmented) + .onChange(of: groups) { _, available in + if !available.contains(selectedGroup) { selectedGroup = "全部" } + } + MatchGroupPage( + title: selectedGroup, + matches: matches(for: selectedGroup), + baseIndex: baseIndex(for: selectedGroup), + classified: $store.classified, + selection: $pptSelection + ) HStack { - Text("按发票开票日期排列 · 自动匹配结果也计入已核对").font(.caption).foregroundStyle(.secondary) + Text("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))") + .monospacedDigit() Spacer() - Text("\(store.state.matches.count) 组").font(.caption.monospacedDigit()) - } - ScrollView { - LazyVStack(spacing: 18) { - ForEach(Array(store.state.sortedMatches.enumerated()), id: \.element.id) { index, match in - MatchRow(match: match, index: index + 1) - } - }.padding(2) - } + 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) } }.padding(28).disabled(store.busy) + .onChange(of: store.state.matches.map(\.id)) { _, identifiers in + pptSelection.retain(identifiers) + } + } + + private func matches(for group: String) -> [MatchGroup] { + let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group } + return matches + } + + private func count(for group: String) -> Int { + matches(for: group).count + } + + private func baseIndex(for group: String) -> Int { + guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 } + return first + 1 } } -struct MatchRow: View { +struct MatchGroupPage: View { @EnvironmentObject var store: WorkspaceStore - let match: MatchGroup - let index: Int + let title: String + let matches: [MatchGroup] + let baseIndex: Int + @Binding var classified: Bool + @Binding var selection: MatchedPPTSelection var body: some View { - Panel { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text(String(format: "%02d", index)).font(.title2.monospacedDigit().bold()).foregroundStyle(.teal) - VStack(alignment: .leading, spacing: 4) { - Text("\(match.invoices.count) 张发票 · \(match.payments.count) 张付款").font(.headline) - Text("发票 \(currency(match.invoiceTotal)) / 付款 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary) - } - Spacer() - Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认") - .font(.caption.weight(.medium)).padding(.horizontal, 10).padding(.vertical, 5) - .background((match.matchType == "auto" ? Color.teal : Color.blue).opacity(0.1), in: Capsule()) - if store.classified { - Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) { - ForEach(expenseCategories, id: \.self) { Text($0) } - }.labelsHidden().frame(width: 110) - } - Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }.help("撤销本组配对") + 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) + Spacer() + Text("按发票日期排列").font(.caption).foregroundStyle(.secondary) + } + if matches.isEmpty { + EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。") + } 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 + Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认") + .foregroundStyle(match.matchType == "auto" ? .teal : .blue) + }.width(min: 90, ideal: 105) + 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) } - HStack(alignment: .top, spacing: 18) { - thumbnails(match.invoices.sorted { $0.issueDate < $1.issueDate }) - Image(systemName: "link").foregroundStyle(.teal).padding(.top, 50) - thumbnails(match.payments.sorted { $0.sortDate < $1.sortDate }) - } - Text(match.reasons.joined(separator: " · ")).font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + .frame(minHeight: 330) } } } - private func thumbnails(_ items: [Material]) -> some View { - ScrollView(.horizontal) { - HStack(alignment: .top, spacing: 12) { - ForEach(items) { item in - Button { store.preview = item } label: { - VStack(alignment: .leading, spacing: 6) { - MaterialThumbnail(item: item, height: 115) - Text(item.name).font(.caption).lineLimit(2).frame(height: 32, alignment: .topLeading) - }.frame(width: 155) - }.buttonStyle(.plain).help("点击放大 \(item.name)") - } + private func index(of match: MatchGroup) -> Int { + guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex } + return baseIndex + index + } +} + +struct MaterialCell: View { + let items: [Material] + @ObservedObject var store: WorkspaceStore + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text("\(items.count) 份").font(.caption.weight(.medium)) + ForEach(items.prefix(2)) { item in + Button(item.name) { store.preview = item } + .buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)") } - }.frame(maxWidth: .infinity, alignment: .leading) + if items.count > 2 { + Text("还有 \(items.count - 2) 份").font(.caption).foregroundStyle(.secondary) + } + }.padding(.vertical, 4) } } diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index bb5131e..181dc2b 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -215,8 +215,19 @@ final class WorkspaceStore: ObservableObject { showExpense = true } - func export(_ kind: ExportKind) { + func export(_ kind: ExportKind, selectedMatchIDs: Set? = nil) { guard !busy, !state.matches.isEmpty else { return } + let exportState: Workspace + do { + if kind == .ppt, let selectedMatchIDs { + exportState = try MatchedPPTSelection(ids: selectedMatchIDs).exportWorkspace(from: state) + } else { + exportState = state + } + } catch { + errorMessage = error.localizedDescription + return + } let panel = NSSavePanel() panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data] panel.canCreateDirectories = true @@ -236,7 +247,7 @@ final class WorkspaceStore: ObservableObject { try? FileManager.default.removeItem(at: temporary) } do { - _ = try await run(["operation": kind.rawValue, "state": try jsonObject(state), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]) + _ = try await run(["operation": kind.rawValue, "state": try jsonObject(exportState), "destination": temporary.path, "classified": classified, "purposes": purposes, "signatures": signatures]) try Data(contentsOf: temporary).write(to: destination, options: .atomic) if kind == .expense { showExpense = false } notice = "\(kind.title)已导出到 \(destination.lastPathComponent)" diff --git a/tests/MatchedPPTSelectionTests.swift b/tests/MatchedPPTSelectionTests.swift new file mode 100644 index 0000000..399cb53 --- /dev/null +++ b/tests/MatchedPPTSelectionTests.swift @@ -0,0 +1,62 @@ +import Foundation + +@main +struct MatchedPPTSelectionTests { + static func match(_ id: String, category: String) -> MatchGroup { + MatchGroup(id: id, invoices: [material(id + "-invoice", type: "invoice")], + payments: [material(id + "-payment", type: "payment"), material(id + "-payment-2", type: "payment")], + category: category, matchType: "manual", score: 100, reasons: []) + } + + static func material(_ id: String, type: String) -> Material { + Material(id: id, name: id + ".png", path: "/tmp/" + id + ".png", type: type, size: 1, + matched: true, previewPath: "", ocr: OCRData(), displayAmount: 10, + sortDate: "2026-09-16", issueDate: "2026-09-16") + } + + static func main() throws { + let meal = match("meal", category: "餐饮") + let travel = match("travel", category: "交通") + let hotel = match("hotel", category: "住宿") + var workspace = Workspace() + workspace.matches = [meal, travel, hotel] + workspace.invoices = workspace.matches.flatMap(\.invoices) + workspace.payments = workspace.matches.flatMap(\.payments) + var selection = MatchedPPTSelection() + precondition(!selection.containsAll([])) + precondition(!selection.containsAll(workspace.matches)) + precondition((try? selection.exportWorkspace(from: workspace)) == nil) + selection.setVisible([meal], selected: true) + selection.setVisible([travel], selected: true) + precondition(selection.ids == ["meal", "travel"]) + precondition(selection.containsAll([meal])) + precondition(!selection.containsAll(workspace.matches)) + let exported = try selection.exportWorkspace(from: workspace) + precondition(exported.matches.map(\.id) == ["meal", "travel"]) + precondition(exported.invoices.map(\.id) == ["meal-invoice", "travel-invoice"]) + precondition(exported.payments.count == 4) + precondition(exported.matches[0].payments.count == 2) + let payload = try JSONDecoder().decode(Workspace.self, from: JSONEncoder().encode(exported)) + precondition(payload.matches.map(\.id) == ["meal", "travel"]) + precondition(!payload.invoices.contains { $0.id.hasPrefix("hotel") }) + precondition(workspace.matches.count == 3) + precondition(workspace.invoices.count == 3) + selection.setVisible([meal], selected: false) + precondition(selection.ids == ["travel"]) + let single = try selection.exportWorkspace(from: workspace) + precondition(single.matches.map(\.id) == ["travel"]) + selection.setVisible(workspace.matches, selected: true) + precondition(selection.containsAll(workspace.matches)) + let all = try selection.exportWorkspace(from: workspace) + precondition(all.matches.map(\.id) == workspace.matches.map(\.id)) + selection.setVisible(workspace.matches, selected: false) + precondition(selection.ids.isEmpty) + selection.ids = ["removed", "meal"] + precondition((try? selection.exportWorkspace(from: workspace)) == nil) + selection.retain(workspace.matches.map(\.id)) + precondition(selection.ids == ["meal"]) + selection.retain([]) + precondition(selection.ids.isEmpty) + print("Matched PPT selection tests passed") + } +}