1
This commit is contained in:
@@ -32,8 +32,8 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
|
|||||||
|
|
||||||
## 使用流程
|
## 使用流程
|
||||||
|
|
||||||
1. 选择包含 `发票`、`付款截图`、`实物照片` 子目录的材料根目录。
|
1. 选择包含 `发票`、`付款截图`、`实物照片` 子目录的材料根目录,或从访达将一个材料根文件夹拖入“材料扫描”顶部文件夹卡片。拖入时卡片高亮;不接受单个文件、应用程序或多个文件夹,处理中不接受重复导入。
|
||||||
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会替换当前工作区。
|
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
|
||||||
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
|
||||||
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
|
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。
|
||||||
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。
|
5. “已核对”中可查看依据、改分类、撤销及导出。自动核对结果不代表已人工复核。
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = "正在复制材料到本地工作区"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user