This commit is contained in:
csj
2026-09-16 15:04:47 +08:00
parent 1ed83d62b3
commit b2c7b28e22
5 changed files with 264 additions and 58 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。 2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。 5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出 PPT。“全部”页可全选所有已核对组。行程 Excel 和个人报销单仍使用全部已核对材料。自动核对结果不代表已人工复核。
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。 6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
## 功能对应 ## 功能对应
+40
View File
@@ -0,0 +1,40 @@
import Foundation
struct MatchedPPTSelection {
var ids: Set<String> = []
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 }
}
}
+140 -47
View File
@@ -2,87 +2,180 @@ 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()
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 { var body: some View {
VStack(spacing: 18) { VStack(spacing: 18) {
HStack { HStack {
Picker("排版", selection: $store.classified) { VStack(alignment: .leading, spacing: 4) {
Text("按类型分类").tag(true) Text("已核对材料").font(.title2.bold())
Text("不分类").tag(false) Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary)
}.pickerStyle(.segmented).frame(width: 200) }
Spacer() Spacer()
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0) 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(action: store.openExpense) { Label("个人报销单", systemImage: "tablecells") }.disabled(store.state.matches.isEmpty)
Button { store.export(.ppt) } label: { Label("导出 PPT", systemImage: "square.and.arrow.up") } Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
.buttonStyle(.borderedProminent).disabled(store.state.matches.isEmpty) Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up")
}
.buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty)
} }
if store.state.matches.isEmpty { if store.state.matches.isEmpty {
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。") EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。")
} else { } 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 { HStack {
Text("按发票开票日期排列 · 自动匹配结果也计入已核对").font(.caption).foregroundStyle(.secondary) Text("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))")
.monospacedDigit()
Spacer() Spacer()
Text("\(store.state.matches.count)").font(.caption.monospacedDigit()) Text("其中 \(selectedMatches.filter { selectedGroup != "全部" && $0.category != selectedGroup }.count) 组在其他分组")
} .foregroundStyle(.secondary)
ScrollView { Button("清空选择") { pptSelection.ids.removeAll() }.disabled(pptSelection.ids.isEmpty)
LazyVStack(spacing: 18) { }.font(.callout)
ForEach(Array(store.state.sortedMatches.enumerated()), id: \.element.id) { index, match in Text("切换分组保留勾选;PPT 仅导出所选组的完整发票与付款材料。行程 Excel 和个人报销单仍使用全部已核对材料。")
MatchRow(match: match, index: index + 1) .font(.caption).foregroundStyle(.secondary)
}
}.padding(2)
}
} }
}.padding(28).disabled(store.busy) }.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 @EnvironmentObject var store: WorkspaceStore
let match: MatchGroup let title: String
let index: Int let matches: [MatchGroup]
let baseIndex: Int
@Binding var classified: Bool
@Binding var selection: MatchedPPTSelection
var body: some View { var body: some View {
Panel { VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text(String(format: "%02d", index)).font(.title2.monospacedDigit().bold()).foregroundStyle(.teal) Toggle("全选当前分组", isOn: Binding(
VStack(alignment: .leading, spacing: 4) { get: { selection.containsAll(matches) },
Text("\(match.invoices.count) 张发票 · \(match.payments.count) 张付款").font(.headline) set: { selection.setVisible(matches, selected: $0) }
Text("发票 \(currency(match.invoiceTotal)) / 付款 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary) ))
} .toggleStyle(.checkbox)
Text(title).font(.headline)
Text("\(matches.count) 组 · 已选 \(matches.filter { selection.ids.contains($0.id) }.count)")
.font(.caption).foregroundStyle(.secondary)
Spacer() 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)%" : "人工确认") Text(match.matchType == "auto" ? "自动 \(match.score)%" : "人工确认")
.font(.caption.weight(.medium)).padding(.horizontal, 10).padding(.vertical, 5) .foregroundStyle(match.matchType == "auto" ? .teal : .blue)
.background((match.matchType == "auto" ? Color.teal : Color.blue).opacity(0.1), in: Capsule()) }.width(min: 90, ideal: 105)
if store.classified { TableColumn("操作") { match in
HStack(spacing: 7) {
if classified {
Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) { Picker("分类", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
ForEach(expenseCategories, id: \.self) { Text($0) } ForEach(expenseCategories, id: \.self) { Text($0) }
}.labelsHidden().frame(width: 110) }.labelsHidden().frame(width: 95)
} }
Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }.help("撤销本组配对") Button { store.undo(match) } label: { Image(systemName: "arrow.uturn.backward") }
.buttonStyle(.borderless).help("撤销本组配对")
} }
HStack(alignment: .top, spacing: 18) { }.width(min: classified ? 135 : 48, ideal: classified ? 145 : 60)
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 { private func index(of match: MatchGroup) -> Int {
ScrollView(.horizontal) { guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex }
HStack(alignment: .top, spacing: 12) { return baseIndex + index
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)")
} }
}
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)
} }
} }
+13 -2
View File
@@ -215,8 +215,19 @@ final class WorkspaceStore: ObservableObject {
showExpense = true showExpense = true
} }
func export(_ kind: ExportKind) { func export(_ kind: ExportKind, selectedMatchIDs: Set<String>? = nil) {
guard !busy, !state.matches.isEmpty else { return } 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() let panel = NSSavePanel()
panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data] panel.allowedContentTypes = [UTType(filenameExtension: kind.fileExtension) ?? .data]
panel.canCreateDirectories = true panel.canCreateDirectories = true
@@ -236,7 +247,7 @@ final class WorkspaceStore: ObservableObject {
try? FileManager.default.removeItem(at: temporary) try? FileManager.default.removeItem(at: temporary)
} }
do { 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) try Data(contentsOf: temporary).write(to: destination, options: .atomic)
if kind == .expense { showExpense = false } if kind == .expense { showExpense = false }
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)" notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
+62
View File
@@ -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")
}
}