From 1ed83d62b3403c5c9c87442fefc5637eb85bd041 Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Wed, 16 Sep 2026 13:07:16 +0800 Subject: [PATCH] 1 --- README.md | 4 ++-- reimburse/FolderImportSource.swift | 30 +++++++++++++++++++++++++ reimburse/ScanPage.swift | 15 ++++++++++++- reimburse/WorkspaceStore.swift | 35 ++++++++++++++++++++++++++++- tests/FolderImportSourceTests.swift | 35 +++++++++++++++++++++++++++++ 5 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 reimburse/FolderImportSource.swift create mode 100644 tests/FolderImportSourceTests.swift diff --git a/README.md b/README.md index 635653e..851bd7a 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,8 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ ## 使用流程 -1. 选择包含 `发票`、`付款截图`、`实物照片` 子目录的材料根目录。 -2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会替换当前工作区。 +1. 选择包含 `发票`、`付款截图`、`实物照片` 子目录的材料根目录,或从访达将一个材料根文件夹拖入“材料扫描”顶部文件夹卡片。拖入时卡片高亮;不接受单个文件、应用程序或多个文件夹,处理中不接受重复导入。 +2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。 diff --git a/reimburse/FolderImportSource.swift b/reimburse/FolderImportSource.swift new file mode 100644 index 0000000..9e31a81 --- /dev/null +++ b/reimburse/FolderImportSource.swift @@ -0,0 +1,30 @@ +import Foundation + +enum FolderImportSource { + static func validate(_ sources: [URL]) throws -> URL { + guard sources.count == 1, let source = sources.first else { + throw ValidationFailure(message: "请一次拖入一个材料根文件夹,不要同时拖入多个文件夹或文件。") + } + guard source.isFileURL else { + throw ValidationFailure(message: "请从访达拖入本机材料文件夹,不支持网页链接。") + } + let values: URLResourceValues + do { + values = try source.resourceValues(forKeys: [.isDirectoryKey, .isPackageKey, .isReadableKey]) + } catch { + throw ValidationFailure(message: "无法读取这个文件夹,请确认它仍然存在,或使用“选择文件夹”重新授权。") + } + guard values.isDirectory == true, values.isPackage != true else { + throw ValidationFailure(message: "请拖入包含“发票”“付款截图”“实物照片”子目录的材料根文件夹,而不是单个文件或应用程序。") + } + guard values.isReadable == true else { + throw ValidationFailure(message: "没有读取这个文件夹的权限,请使用“选择文件夹”重新授权。") + } + return source + } + + private struct ValidationFailure: LocalizedError { + let message: String + var errorDescription: String? { message } + } +} diff --git a/reimburse/ScanPage.swift b/reimburse/ScanPage.swift index 8b34ba8..8f746ad 100644 --- a/reimburse/ScanPage.swift +++ b/reimburse/ScanPage.swift @@ -3,17 +3,20 @@ import SwiftUI struct ScanPage: View { @EnvironmentObject var store: WorkspaceStore @Binding var confirmClear: Bool + @State private var folderTargeted = false var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { Panel { HStack(spacing: 20) { - Image(systemName: "folder.fill").font(.system(size: 44)).foregroundStyle(.teal.gradient) + Image(systemName: folderTargeted && !store.busy ? "folder.badge.plus" : "folder.fill").font(.system(size: 44)).foregroundStyle(.teal.gradient) VStack(alignment: .leading, spacing: 7) { Text(store.state.rootPath.isEmpty ? "从一个材料文件夹开始" : store.state.rootPath).font(.title3.bold()) Text("文件夹内请分别放置:发票 / 付款截图 / 实物照片") .font(.callout).foregroundStyle(.secondary) + Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹") + .font(.callout.weight(.medium)).foregroundStyle(.teal) Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区") .font(.caption).foregroundStyle(.tertiary) } @@ -21,6 +24,16 @@ struct ScanPage: View { Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy) } } + .overlay { + RoundedRectangle(cornerRadius: 16) + .strokeBorder(.teal.opacity(folderTargeted && !store.busy ? 1 : 0), style: StrokeStyle(lineWidth: 2, dash: [8, 5])) + .allowsHitTesting(false) + } + .dropDestination(for: URL.self) { sources, _ in + store.importFolders(sources) + } isTargeted: { targeted in + folderTargeted = targeted + } if !store.state.warnings.isEmpty { Panel { VStack(alignment: .leading, spacing: 7) { diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index 509b5d9..bb5131e 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -47,8 +47,41 @@ final class WorkspaceStore: ObservableObject { panel.message = "选择包含“发票”“付款截图”“实物照片”的报账材料根目录" panel.prompt = "导入材料" guard panel.runModal() == .OK, let source = panel.url else { return } - let scoped = source.startAccessingSecurityScopedResource() + importFolders([source]) + } + + @discardableResult + func importFolders(_ sources: [URL]) -> Bool { + guard !busy else { return false } + let candidate = sources.count == 1 ? sources.first : nil + let scoped = candidate?.startAccessingSecurityScopedResource() ?? false + let source: URL + do { + source = try FolderImportSource.validate(sources) + } catch { + if scoped { candidate?.stopAccessingSecurityScopedResource() } + errorMessage = error.localizedDescription + return false + } + if !state.rootPath.isEmpty { + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "替换当前工作区?" + alert.informativeText = "将导入“\(source.lastPathComponent)”,替换当前材料和核对结果。原始文件不会修改;如需保留当前结果,请先取消并导出。" + alert.addButton(withTitle: "替换并导入") + alert.addButton(withTitle: "取消") + guard alert.runModal() == .alertFirstButtonReturn else { + if scoped { source.stopAccessingSecurityScopedResource() } + return false + } + } + scanFolder(source, scoped: scoped) + return true + } + + private func scanFolder(_ source: URL, scoped: Bool) { busy = true + errorMessage = nil cancelRequested = false progress = 0 status = "正在复制材料到本地工作区" diff --git a/tests/FolderImportSourceTests.swift b/tests/FolderImportSourceTests.swift new file mode 100644 index 0000000..ec7cefb --- /dev/null +++ b/tests/FolderImportSourceTests.swift @@ -0,0 +1,35 @@ +import Foundation + +@main +struct FolderImportSourceTests { + static func rejects(_ sources: [URL]) { + do { + _ = try FolderImportSource.validate(sources) + preconditionFailure("Unexpectedly accepted \(sources)") + } catch { + precondition(!error.localizedDescription.isEmpty) + } + } + + static func main() throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let folder = root.appendingPathComponent("报销 材料", isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let validated = try FolderImportSource.validate([folder]) + precondition(validated == folder) + let file = root.appendingPathComponent("发票.pdf") + try Data().write(to: file) + let application = root.appendingPathComponent("测试.app", isDirectory: true) + try FileManager.default.createDirectory(at: application, withIntermediateDirectories: true) + rejects([]) + rejects([folder, folder]) + rejects([folder, file]) + rejects([file]) + rejects([application]) + rejects([URL(string: "https://example.invalid/materials")!]) + rejects([root.appendingPathComponent("不存在")]) + print("Folder import validation tests passed") + } +}