This commit is contained in:
csj
2026-09-16 13:07:16 +08:00
parent 4b49b09230
commit 1ed83d62b3
5 changed files with 115 additions and 4 deletions
+2 -2
View File
@@ -32,8 +32,8 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
## 使用流程 ## 使用流程
1. 选择包含 `发票``付款截图``实物照片` 子目录的材料根目录。 1. 选择包含 `发票``付款截图``实物照片` 子目录的材料根目录,或从访达将一个材料根文件夹拖入“材料扫描”顶部文件夹卡片。拖入时卡片高亮;不接受单个文件、应用程序或多个文件夹,处理中不接受重复导入
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会替换当前工作区。 2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。 5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。
+30
View File
@@ -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 }
}
}
+14 -1
View File
@@ -3,17 +3,20 @@ import SwiftUI
struct ScanPage: View { struct ScanPage: View {
@EnvironmentObject var store: WorkspaceStore @EnvironmentObject var store: WorkspaceStore
@Binding var confirmClear: Bool @Binding var confirmClear: Bool
@State private var folderTargeted = false
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 22) {
Panel { Panel {
HStack(spacing: 20) { 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) { 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("文件夹内请分别放置:发票 / 付款截图 / 实物照片")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹")
.font(.callout.weight(.medium)).foregroundStyle(.teal)
Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区") Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区")
.font(.caption).foregroundStyle(.tertiary) .font(.caption).foregroundStyle(.tertiary)
} }
@@ -21,6 +24,16 @@ struct ScanPage: View {
Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy) 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 { if !store.state.warnings.isEmpty {
Panel { Panel {
VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: 7) {
+34 -1
View File
@@ -47,8 +47,41 @@ final class WorkspaceStore: ObservableObject {
panel.message = "选择包含“发票”“付款截图”“实物照片”的报账材料根目录" panel.message = "选择包含“发票”“付款截图”“实物照片”的报账材料根目录"
panel.prompt = "导入材料" panel.prompt = "导入材料"
guard panel.runModal() == .OK, let source = panel.url else { return } 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 busy = true
errorMessage = nil
cancelRequested = false cancelRequested = false
progress = 0 progress = 0
status = "正在复制材料到本地工作区" status = "正在复制材料到本地工作区"
+35
View File
@@ -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")
}
}