9月20日

This commit is contained in:
csj
2026-09-20 16:31:24 +08:00
parent 967eaaec8a
commit 68bf79ec3f
37 changed files with 3581 additions and 1627 deletions
+5
View File
@@ -0,0 +1,5 @@
# 2026-09-20
- 通读 reimburse 应用全部 SwiftUI 视图代码(ContentView/ScanPage/PairPage/MatchedPage/ExpenseSheet/ExpenseTemplateSheet/PayeeProfileSheet/AdminView 等 26 个文件),产出《界面优化方案.md》于项目根目录。
- 方案要点:P0 为配对页与已核对页的搜索/排序/低分组过滤;P1 为颜色语义统一、深色模式适配、ScanPage 信息层级重组、MaterialPreview 异步加载、AdminView1364 行)拆分;P2/P3 为通知组件、快捷键、模板字段可视化点选等。
- 未改任何代码。
+4 -4
View File
@@ -36,8 +36,8 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。 2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。
3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。
4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。一对一、一对多、多对一及多对多的已选连线均持续显示,不依赖鼠标悬停。两侧独立滚动时连线跟随材料,屏幕外的已选材料以顶部或底部箭头和数量标记表示;搜索隐藏的选择仍保留并在列底部提示。
5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。 5. “已核对”以费用类型为固定表头并排分列,以紧凑缩略图、金额、日期和凭证份数展示核对组,各列共用纵向滚动区。不将未经确认的 OCR 商户候选或文件名作为卡片标题;详情按组号标识,商户识别内容仍可搜索。仅显示当前结果中有材料的类型,空列自动隐藏,有材料后自动出现,其余列自动分配可用宽度;搜索和筛选也遵循此规则,无结果时只显示空状态,窄窗口支持横向滚动。点击凭证进入详情,可放大原件、切换前后组、修改类型或撤销配对。可逐组勾选、通过类型表头全选本列当前结果,或使用底部“全选当前结果”,再点击“导出 PPT”或“报销单”。选择数量和发票金额在底部汇总。跨类型及搜索筛选保留勾选,取消一列的全选不影响其他列或筛选外的勾选。修改类型后材料移动至对应列。未勾选时不能导出这两类文件。其他导出菜单中的行程 Excel 仍使用全部已核对材料;列内排序不改变导出顺序。自动核对结果不代表已人工复核。
6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。 6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。
## 自定义个人报销单模板 ## 自定义个人报销单模板
@@ -56,7 +56,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
### 导出前预览 ### 导出前预览
在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 在“已核对”勾选材料 → “报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。
跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 WPS 逐像素相同。预览限制不改变导出的模板样式。 跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 WPS 逐像素相同。预览限制不改变导出的模板样式。
@@ -64,7 +64,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \
修改所选数据、用途、收款资料或模板后需要重新预览。保存前核对输入及文件指纹,防止保存与预览不一致的内容;返回或关闭预览时清理临时文件。临时报销单仅存于应用本地沙盒,包含完整收款信息,不上传。 修改所选数据、用途、收款资料或模板后需要重新预览。保存前核对输入及文件指纹,防止保存与预览不一致的内容;返回或关闭预览时清理临时文件。临时报销单仅存于应用本地沙盒,包含完整收款信息,不上传。
已核对列表的“匹配”列会显示简要核对项,点击百分比查看每项实际得分、未得分原因及核对建议。证据评分采用真正的 **100 分制**:单张发票与付款截图的权重为金额 40 分、商户 20 分、日期 10 分、单号 15 分、批次金额唯一性 15 分;满分之和为 100,实际得分之和就是百分比,各项未得分之和就是差额。例如仅金额一致且批次唯一时为 55/100 分,即 55%。日期相差不超过 3 天得 10 分,4–7 天得 5 分;商户达到 35% 相似度后按相似度 × 20 分四舍五入。其他组合规则的满分原本就是 100 分,仍保留;多笔付款固定 95 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。 已核对页面的凭证详情中显示简要核对项,点击自动核对评分查看每项实际得分、未得分原因及核对建议。证据评分采用真正的 **100 分制**:单张发票与付款截图的权重为金额 40 分、商户 20 分、日期 10 分、单号 15 分、批次金额唯一性 15 分;满分之和为 100,实际得分之和就是百分比,各项未得分之和就是差额。例如仅金额一致且批次唯一时为 55/100 分,即 55%。日期相差不超过 3 天得 10 分,4–7 天得 5 分;商户达到 35% 相似度后按相似度 × 20 分四舍五入。其他组合规则的满分原本就是 100 分,仍保留;多笔付款固定 95 分会明确说明保留分。百分比不是金额匹配比例,也不是成功概率。人工配对不显示自动评分。
旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore``version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。 旧工作区启动时自动升级为新版评分,无需重扫,不改变配对、分类及导出内容。内部候选筛选、歧义排除沿用原规则及原 `score`,新版百分制证据评分单独存于 `explanation.rawScore``version: 2`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。
+55
View File
@@ -0,0 +1,55 @@
# 界面优化实施记录
依据 `界面优化方案.md` 实施,仅调整 macOS 客户端。未修改后端、小程序、Python 引擎及导出模板。
## 已实施
- 默认进入本地贴票;统一语义色、通知、搜索框、徽章和统计组件。
- 人工配对支持两栏独立搜索、日期/金额/文件名排序、三档缩略图、方向键导航、空格勾选及预览内勾选。单击卡片立即勾选或取消,不等待双击判定;放大预览使用卡片右上角按钮。已选连线持续显示,兼容一对一、一对多、多对一及多对多,不依赖悬停或焦点。同组材料通过中间主线和独立分支相连,分支沿网格间隙绕开相邻卡片,避免多对多交叉线遮挡票据。左右滚动实时更新连线位置,滚出视野的已选材料归并到上下边缘数量标记;连线限制在材料区内,不覆盖搜索栏和操作栏。
- 已核对按费用类型并排分列:固定类型表头,共用纵向滚动区,以紧凑凭证缩略图、金额、日期及份数展示每组材料。不再将未经确认的 OCR 商户候选或文件名作为卡片标题,详情标题使用组号,原始 OCR 数据保留用于搜索和核对。仅显示当前结果中有材料的类型,空列自动隐藏,有材料后自动出现,其余列自动分配可用宽度;窄窗口横向滚动。点击凭证打开详情,支持大图预览、前后组切换、修改类型及撤销配对。保留搜索、列内排序、需关注过滤、整列勾选、类型金额小计及筛选外勾选计数,选择汇总和导出集中到底部。
- 扫描页空工作区隐藏统计;待配对入口前移,提醒可关闭/折叠,统计与金额汇总紧凑化,分类金额增加占比条。
- 材料预览异步解码、先显示缩略图,支持取消和重试;没有缓存预览的 PDF 仍只显示首页。预览外围适配深色模式,纸张本身保留白底。
- 报销单、材料和模板窗口按屏幕可用范围限制尺寸。报销单正文可滚动,签字岗位支持快选与移除后撤销,空用途与重复用途提供反馈。
- 模板填写位置支持预览单元格点选、原文行悬停高亮、格式/范围/重复位置实时校验;窄预览使用紧凑缩放菜单。最终模板合法性仍交由原引擎验证。
- 收款资料保存反馈及尾号复制;成功与错误通知可分别显示、关闭,成功提示自动消失,导出成功提供访达入口。
- 项目管理拆为审批、成员、组别、权限、类型规则及操作记录六个页面,共享基础组件;增加审批列排序、已加载数量、横向导航、成功服务地址历史与扫码重试按钮。
- Store 缓存已核对排序、全局序号、分类汇总及需关注数量;扫描处理在 Dock 图标显示进度。
## 交互约定
- `Command-1/2/3` 切换本地页面,`Command-F` 聚焦搜索,`Command-,` 打开模板,原有 `Command-O` 导入保留。
- 撤销最近核对组采用 `Command-Option-Z`,避免覆盖文本编辑的标准 `Command-Z`
- 搜索支持空格分隔多个条件。筛选不清空勾选,全选/取消全选只作用于当前结果。
- “需关注”包括自动证据分低于 90、缺少可验证评分,或包含识别失败材料的组;不修改配对结论。
- 表格排序不改变导出顺序。PPT 与报销单仍只导出所选组,行程表仍使用全部已核对材料。
- 用途重复仅提示,不跨费用类型重新合并;签字全部删除时仍沿用原默认岗位。金额口径、PPT 版式、模板指纹及预览/保存一致性校验不变。
- 再次导入替换工作区与危险操作的原确认流程保留。
## 验证
运行 Swift 回归测试:
```sh
node tests/run-ui-checks.mjs
```
运行上述测试以及隔离的原生界面检查:
```sh
node tests/run-ui-checks.mjs --render
```
仅重跑原生界面检查:
```sh
node tests/run-ui-checks.mjs --render-only
```
测试使用临时工作区、生成的样例材料与内存收款存储,不读取真实工作区或收款钥匙串。截图目录由脚本输出。
- 12 组 Swift 回归:搜索排序、需关注、隐藏勾选保留、金额汇总、证据评分、模板持久化与指纹、预览一致性、导入校验、收款存储、附件兼容、审批导出范围及规则编辑状态。
- 原生检查:1280×800、1024×640、900×560 的浅色/深色渲染、弹窗、窄表格预览;搜索焦点、不同缩放的单元格点击、异步图像缩放、PDF 首页及加载取消。
- 原有 `SpreadsheetLayoutTests.swift` 的排版与像素回归另行通过。
- 完整 Xcode Debug 构建及本地引擎指纹检查通过。
本轮未使用真实服务执行微信登录、在线审批或真实材料 OCR 验收。Dock 进度、VoiceOver 朗读及不同 macOS 版本的系统材质效果仍需交互验收。未引入无限历史通知队列,也未改变扫码轮询为自动重复生成二维码。
+103
View File
@@ -0,0 +1,103 @@
import SwiftUI
struct AdminAttachmentRow: View {
@ObservedObject var model: AdminStore
let file: AdminFile
@State private var image: NSImage?
@State private var loading = false
@State private var showPreview = false
private var isImage: Bool { file.mime.hasPrefix("image/") }
var body: some View {
HStack(spacing: 12) {
if isImage {
Button {
if image != nil { showPreview = true }
else { Task { await loadImage(openWhenReady: true) } }
} label: {
ZStack {
RoundedRectangle(cornerRadius: 6).fill(.quaternary.opacity(0.35))
if let image {
Image(nsImage: image)
.resizable()
.scaledToFill()
} else if loading {
ProgressView().controlSize(.small)
} else {
Image(systemName: "photo").foregroundStyle(.secondary)
}
}
.frame(width: 112, height: 76)
.clipShape(RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
.help("点击预览大图")
} else {
Image(systemName: file.mime == "application/pdf" ? "doc.richtext" : "doc")
.font(.title2)
.foregroundStyle(.teal)
.frame(width: 44)
}
VStack(alignment: .leading, spacing: 4) {
Text(file.name).lineLimit(2)
Text(isImage ? "点击图片直接预览" : "文件附件")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if isImage {
Button {
if image != nil { showPreview = true }
else { Task { await loadImage(openWhenReady: true) } }
} label: { Image(systemName: "eye") }
.help("预览图片")
}
Button { Task { await model.perform { try await model.download(file) } } } label: {
Image(systemName: "arrow.down.to.line")
}.help("下载凭证")
}
.padding(.vertical, 4)
.task(id: file.id) { await loadImage(openWhenReady: false) }
.sheet(isPresented: $showPreview) {
VStack(spacing: 0) {
HStack {
Text(file.name).font(.headline).lineLimit(1)
Spacer()
Button { showPreview = false } label: { Image(systemName: "xmark") }.help("关闭预览")
}
.padding(16)
Divider()
if let image {
Image(nsImage: image)
.resizable()
.scaledToFit()
.padding(20)
.frame(minWidth: 720, minHeight: 520)
}
}
}
}
@MainActor
private func loadImage(openWhenReady: Bool) async {
guard isImage, image == nil, !loading else {
if openWhenReady && image != nil { showPreview = true }
return
}
loading = true
defer { loading = false }
do {
let data = try await model.fileData(file)
guard let loaded = NSImage(data: data) else {
throw AdminFailure(message: "图片格式无法预览,请下载后查看")
}
image = loaded
if openWhenReady { showPreview = true }
} catch is CancellationError {
} catch {
model.error = error.localizedDescription
}
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
struct AdminAuditTab: AdminPage {
@ObservedObject var model: AdminStore
@State private var auditSearch = ""
var body: some View {
audit
}
private var filteredAudit: [AdminAudit] {
model.audit.filter { AdminDisplayFilter.matches(auditSearch, fields: [$0.actorId, $0.action, $0.resourceId, $0.detail, $0.date]) }
}
private var audit: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
searchField("搜索账户、操作、资源或详情", text: $auditSearch).frame(maxWidth: 400)
Spacer()
Text("已加载 \(model.audit.count) 条 · 匹配 \(filteredAudit.count)").font(.callout).foregroundStyle(.secondary)
Button { run { try await model.loadAudit() } } label: { Label("刷新记录", systemImage: "arrow.clockwise") }
}
ZStack {
Table(filteredAudit) {
TableColumn("时间", value: \.date)
TableColumn("账户", value: \.actorId)
TableColumn("操作", value: \.action)
TableColumn("资源", value: \.resourceId)
TableColumn("详情") { Text($0.detail).lineLimit(2).help($0.detail).textSelection(.enabled) }
}
if filteredAudit.isEmpty {
emptyState("暂无匹配的操作记录", icon: "clock.arrow.circlepath", message: "可清空搜索或刷新记录;搜索仅针对已加载的数据。")
.allowsHitTesting(false)
}
}.clipShape(RoundedRectangle(cornerRadius: 10))
Text("操作记录用于追溯管理行为;此页面不改变任何权限或业务数据。")
.font(.caption).foregroundStyle(.secondary)
}.padding(20)
}
}
+57
View File
@@ -0,0 +1,57 @@
import SwiftUI
@MainActor protocol AdminPage: View {
var model: AdminStore { get }
}
extension AdminPage {
func run(_ work: @escaping () async throws -> Void) { Task { await model.perform(work) } }
func searchField(_ placeholder: String, text: Binding<String>) -> some View {
AppSearchField(placeholder: placeholder, text: text)
}
func emptyState(_ title: String, icon: String, message: String) -> some View {
EmptyPanel(symbol: icon, title: title, message: message)
}
func statusBadge(_ text: String, positive: Bool = false, pending: Bool = false) -> some View {
AppBadge(title: text, symbol: positive ? "checkmark.circle" : pending ? "clock" : "minus.circle",
tone: positive ? .success : pending ? .warning : .information)
}
func sectionCard<Content: View>(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View {
AppSection(title: title, symbol: icon, content: content)
}
func memberState(_ state: String) -> String {
["pending": "待审核", "active": "已加入", "rejected": "已拒绝", "disabled": "已停用"][state] ?? state
}
func groupOption(_ group: AdminGroup) -> String {
group.canManage ? group.label : "\(group.label)(未授权)"
}
func saveQR(_ image: NSImage, filename: String = "入组二维码.png") {
let panel = NSSavePanel()
panel.nameFieldStringValue = filename
if panel.runModal() == .OK, let url = panel.url, let data = image.tiffRepresentation,
let bitmap = NSBitmapImageRep(data: data), let png = bitmap.representation(using: .png, properties: [:]) {
do { try png.write(to: url, options: .atomic) } catch { model.error = error.localizedDescription }
}
}
}
struct AdminAction: Identifiable {
let id = UUID()
let title: String
let perform: () -> Void
}
extension View {
func adminConfirmation(_ action: Binding<AdminAction?>) -> some View {
confirmationDialog(action.wrappedValue?.title ?? "", isPresented: Binding(
get: { action.wrappedValue != nil }, set: { if !$0 { action.wrappedValue = nil } }
), titleVisibility: .visible) {
Button("确认") {
let pending = action.wrappedValue
action.wrappedValue = nil
pending?.perform()
}
Button("取消", role: .cancel) { action.wrappedValue = nil }
}
}
}
+239
View File
@@ -0,0 +1,239 @@
import SwiftUI
struct AdminExpensesTab: AdminPage {
@ObservedObject var model: AdminStore
@State private var expenseSearch = ""
@State private var expenseState = "all"
@State private var selectedExpenses: Set<String> = []
@State private var showDateExport = false
@State private var showPPTExport = false
@State private var exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var exportEndDate = Date()
@State private var reason = ""
@State private var approvalConfirmation: AdminAction?
@State private var sortOrder = [KeyPathComparator(\AdminExpense.date, order: .reverse)]
var body: some View {
expenses
.onChange(of: filteredExpenses.map(\.id)) { _, ids in selectedExpenses.formIntersection(Set(ids)) }
.sheet(item: $model.selected) { detail($0) }
.sheet(isPresented: $showDateExport) { dateExport }
.sheet(isPresented: $showPPTExport) { AdminPPTExportSheet(model: model, initialSelection: selectedExpenses) }
}
private var filteredExpenses: [AdminExpense] {
AdminDisplayFilter.expenses(model.displayedExpenses, query: expenseSearch, state: expenseState).sorted(using: sortOrder)
}
private func metric(_ title: String, value: String, icon: String) -> some View {
StatNumber(title: title, value: value, symbol: icon).padding(.vertical, 8)
}
private var expenses: some View {
VStack(alignment: .leading, spacing: 14) {
HStack(spacing: 12) {
metric("已加载申请", value: "\(model.displayedExpenses.count)", icon: "doc.on.doc")
metric("已加载待审核", value: "\(model.displayedExpenses.filter { $0.state == "pending" }.count)", icon: "clock")
metric("已加载申请金额", value: "¥" + String(format: "%.2f", Double(model.displayedExpenses.reduce(Int64(0)) { $0 + $1.amountCents }) / 100), icon: "yensign.circle")
}
HStack(spacing: 12) {
searchField("搜索姓名、项目、类型或申请编号", text: $expenseSearch).frame(maxWidth: 340)
Picker("状态", selection: $expenseState) {
Text("全部状态").tag("all")
Text("待审核").tag("pending")
Text("已批准").tag("approved")
Text("已退回").tag("returned")
}.frame(width: 180)
Spacer()
Button { showPPTExport = true } label: { Label("导出 PPT", systemImage: "doc.richtext") }
.help("筛选已批准的申请,按组别、人员和提交日期批量导出贴票 PPT")
Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") }
}
ZStack {
Table(filteredExpenses, selection: $selectedExpenses, sortOrder: $sortOrder) {
TableColumn("申请人", value: \.name)
TableColumn("项目", value: \.projectName)
TableColumn("组别", value: \.team)
TableColumn("类型", value: \.type)
TableColumn("金额", value: \.amountCents) { Text("¥\($0.amountText)").monospacedDigit() }
TableColumn("状态") { statusBadge($0.status, positive: $0.state == "approved", pending: $0.state == "pending") }
TableColumn("提交日期", value: \.date)
TableColumn("操作") { record in
Button { reason = ""; run { try await model.detail(record) } } label: {
Label("查看详情", systemImage: "doc.text.magnifyingglass")
}
.buttonStyle(.bordered)
.help("查看申请、备注和凭证")
}.width(min: 120, ideal: 130)
}
if filteredExpenses.isEmpty {
emptyState(model.displayedExpenses.isEmpty ? "暂无报销申请" : "没有匹配的申请", icon: "tray", message: "可调整范围、搜索或状态;搜索仅针对已加载记录。")
.allowsHitTesting(false)
}
}.clipShape(RoundedRectangle(cornerRadius: 10))
HStack {
Text("已加载 \(model.expenses.count)\(model.more ? "(还有更多)" : "(已到底)") · 显示 \(filteredExpenses.count) 笔 · 已选 \(selectedExpenses.count)").foregroundStyle(.secondary)
if !selectedExpenses.isEmpty { Button("清空选择") { selectedExpenses = [] }.buttonStyle(.borderless) }
Spacer()
if model.more { Button("加载更多申请") { run { try await model.refreshExpenses(loadMore: true) } } }
Button { run { try await model.export(selectedExpenses) } } label: { Label("下载所选凭证", systemImage: "arrow.down.to.line") }
.buttonStyle(.borderedProminent).disabled(selectedExpenses.isEmpty || selectedExpenses.count > 20)
.help("每次最多选择 20 笔申请")
}.font(.callout)
Text("统计与搜索仅包含已加载数据,并非项目全量总账;凭证下载每次最多 20 笔。")
.font(.caption).foregroundStyle(.secondary)
}.padding(20)
}
private var dateExport: some View {
VStack(alignment: .leading, spacing: 18) {
HStack {
Image(systemName: "calendar.badge.arrow.down")
.font(.title2)
.foregroundStyle(.teal)
Text("按时间下载报销数据").font(.title3.bold())
}
Text("导出范围:\(exportScopeText)")
.foregroundStyle(.secondary)
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 14) {
GridRow {
Text("开始日期")
DatePicker("开始日期", selection: $exportStartDate, displayedComponents: .date)
.labelsHidden()
}
GridRow {
Text("结束日期")
DatePicker("结束日期", selection: $exportEndDate, displayedComponents: .date)
.labelsHidden()
}
}
Label("压缩包目录:日期-xxx组 / 姓名-报销类型", systemImage: "folder")
.font(.callout)
.foregroundStyle(.secondary)
HStack {
Spacer()
Button("取消") { showDateExport = false }
Button {
let start = exportStartDate
let end = exportEndDate
let project = model.projectID
let group = model.groupID
showDateExport = false
run { try await model.exportRange(startDate: start, endDate: end, projectID: project, groupID: group) }
} label: {
Label("选择位置并下载", systemImage: "arrow.down.to.line")
}
.buttonStyle(.borderedProminent)
.disabled(Calendar.current.startOfDay(for: exportStartDate) > Calendar.current.startOfDay(for: exportEndDate))
}
}
.padding(24)
.frame(width: 470)
}
private var exportScopeText: String {
if let group = model.selectedGroup { return group.label }
if let project = model.access?.projects.first(where: { $0.id == model.projectID }) { return "\(project.name) / 全部可见组" }
return "全部授权项目 / 全部可见组"
}
private func detail(_ record: AdminExpense) -> some View {
VStack(spacing: 0) {
HStack(alignment: .top) {
VStack(alignment: .leading, spacing: 6) {
Text("\(record.name) · \(record.type)").font(.title3.weight(.semibold))
Text("申请 #\(record.id) · \(record.date)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
statusBadge(record.status, positive: record.state == "approved", pending: record.state == "pending")
Button { model.selected = nil } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("关闭详情")
}.padding(20)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 20) {
HStack {
VStack(alignment: .leading, spacing: 6) {
Text("所属项目 / 组别").font(.caption).foregroundStyle(.secondary)
Text("\(record.projectName) / \(record.team)").textSelection(.enabled)
}
Spacer()
Text("¥\(record.amountText)").font(.title.weight(.semibold)).monospacedDigit()
}
if !record.note.isEmpty {
sectionCard("申请备注", icon: "text.alignleft") {
Text(record.note).textSelection(.enabled).fixedSize(horizontal: false, vertical: true)
}
}
VStack(alignment: .leading, spacing: 12) {
Label("凭证附件(\(record.files.values.flatMap { $0.files }.count)", systemImage: "paperclip").font(.headline)
Text("图片可预览;PDF 等文件可下载后查看。").font(.caption).foregroundStyle(.secondary)
LazyVStack(spacing: 8) {
ForEach(record.files.values.flatMap { $0.files }.sorted(by: { ($0.kind, $0.id) < ($1.kind, $1.id) })) { file in
AdminAttachmentRow(model: model, file: file)
Divider()
}
}
if record.files.isEmpty { Text("此申请没有附件").foregroundStyle(.secondary) }
}
DisclosureGroup("处理记录(\(record.events.count)") {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(record.events.enumerated()), id: \.offset) { _, event in
VStack(alignment: .leading, spacing: 4) {
Text(event.detail.isEmpty ? event.action : event.detail).textSelection(.enabled)
Text("\(event.date) · 账户 \(event.actorId)").font(.caption).foregroundStyle(.secondary)
}
}
}.padding(.top, 12).frame(maxWidth: .infinity, alignment: .leading)
}
}.padding(20)
}
Divider()
VStack(alignment: .leading, spacing: 10) {
if record.state == "pending", record.userId != model.access?.userId || model.access?.platform == true {
VStack(alignment: .leading, spacing: 10) {
Text("审批操作").font(.headline)
TextField("审批原因(退回 / 拒绝时必填)", text: $reason, axis: .vertical)
.textFieldStyle(.roundedBorder)
.lineLimit(2...4)
HStack {
Button {
approvalConfirmation = AdminAction(title: "退回 / 拒绝申请 #\(record.id)") {
run { try await model.approve("returned", reason: reason) }
}
} label: {
Label("退回 / 拒绝", systemImage: "arrow.uturn.backward.circle")
}
.buttonStyle(.bordered)
.tint(.red)
.disabled(reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Spacer()
Button {
approvalConfirmation = AdminAction(title: "批准申请 #\(record.id),金额 ¥\(record.amountText)") {
run { try await model.approve("approved", reason: reason) }
}
} label: {
Label("批准报销", systemImage: "checkmark.circle")
}
.buttonStyle(.borderedProminent)
}
}
.padding(14)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
} else if record.state == "pending" {
Label("不能审批本人提交的报销申请", systemImage: "person.crop.circle.badge.exclamationmark")
.foregroundStyle(.secondary)
}
if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) }
}.padding(16).frame(maxWidth: .infinity, alignment: .leading)
.background(Color(nsColor: .windowBackgroundColor))
}.frame(width: AppTheme.sheetSize(width: 740, height: 700).width,
height: AppTheme.sheetSize(width: 740, height: 700).height).disabled(model.busy)
.confirmationDialog(approvalConfirmation?.title ?? "", isPresented: Binding(
get: { approvalConfirmation != nil },
set: { if !$0 { approvalConfirmation = nil } }
), titleVisibility: .visible) {
Button("确认") {
let action = approvalConfirmation
approvalConfirmation = nil
action?.perform()
}
Button("取消", role: .cancel) { approvalConfirmation = nil }
}
}
}
+107
View File
@@ -0,0 +1,107 @@
import SwiftUI
struct AdminGroupsTab: AdminPage {
@ObservedObject var model: AdminStore
@Binding var tab: String
@Binding var memberSection: String
@Binding var permissionSection: String
@State private var groupSearch = ""
@State private var groupName = ""
@State private var renameTarget: AdminManagedGroup?
@State private var renameValue = ""
@State private var confirmation: AdminAction?
var body: some View {
groupManagement.adminConfirmation($confirmation)
.sheet(item: $renameTarget) { group in
VStack(alignment: .leading, spacing: 16) {
Text("重命名组别").font(.title3.bold())
TextField("组别名称", text: $renameValue).textFieldStyle(.roundedBorder)
HStack {
Spacer()
Button("取消") { renameTarget = nil }
Button("保存") {
let value = renameValue
renameTarget = nil
run { try await model.renameGroup(group, name: value) }
}.buttonStyle(.borderedProminent).disabled(renameValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}.padding(24).frame(width: 360)
}
}
private var filteredGroups: [AdminManagedGroup] {
model.visibleManagedGroups.filter { AdminDisplayFilter.matches(groupSearch, fields: [$0.name, $0.projectName, String($0.id)]) }
}
private var groupManagement: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
searchField("搜索组别、项目或组 ID", text: $groupSearch).frame(maxWidth: 340)
Spacer()
Picker("所属项目", selection: $model.projectID) {
Text("全部项目").tag(Int64(0))
ForEach((model.access?.projects ?? []).filter(\.active)) { project in
Text(project.name).tag(project.id)
}
}.frame(maxWidth: 340)
}
if model.editableProjects.contains(where: { $0.id == model.projectID }) {
HStack {
TextField("新组别名称", text: $groupName).textFieldStyle(.roundedBorder).frame(maxWidth: 360)
Button {
let value = groupName
run { try await model.createGroup(value); groupName = "" }
} label: { Label("创建组别", systemImage: "plus") }
.buttonStyle(.borderedProminent)
.disabled(groupName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || groupName.count > 80)
Spacer()
Text("在当前项目下新增组别").font(.caption).foregroundStyle(.secondary)
}
} else if model.projectID == 0, !model.editableProjects.isEmpty {
Menu {
ForEach(model.editableProjects) { project in
Button(project.name) { model.projectID = project.id }
}
} label: { Label("选择项目并新建组", systemImage: "plus") }
}
if filteredGroups.isEmpty {
emptyState("暂无匹配组别", icon: "rectangle.3.group", message: "选择项目后可创建组别,或清空搜索查看已有组别。")
if model.access?.projects.isEmpty == true, model.access?.platform == true {
Button("创建项目") { tab = "项目与授权"; permissionSection = "项目管理" }
}
} else {
Table(filteredGroups) {
TableColumn("组别", value: \.name)
TableColumn("所属项目", value: \.projectName)
TableColumn("组 ID") { Text("#\($0.id)").monospacedDigit() }.width(80)
TableColumn("状态") { statusBadge($0.active ? "已启用" : "已停用", positive: $0.active) }.width(80)
TableColumn("操作") { group in
HStack {
if group.canEdit {
Button {
renameValue = group.name
renameTarget = group
} label: { Label("重命名", systemImage: "pencil") }.help("重命名组别")
Button {
confirm("\(group.active ? "停用" : "恢复") \(group.name)") { try await model.toggleGroup(group) }
} label: { Text(group.active ? "停用" : "恢复") }
.help(group.active ? "停用组别" : "恢复组别")
}
if model.availableGroups.contains(where: { $0.id == group.id }) {
Button {
model.groupID = group.id
memberSection = "成员列表"
tab = "成员与邀请"
} label: { Label("成员", systemImage: "person.3") }.help("管理本组成员")
}
}
}.width(240)
}
}
Text("显示 \(filteredGroups.count) 个组别 · 修改和成员管理操作仅在已有权限范围内提供。")
.font(.caption).foregroundStyle(.secondary)
}.padding(20)
}
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
confirmation = AdminAction(title: title) { run(work) }
}
}
+79
View File
@@ -0,0 +1,79 @@
import SwiftUI
struct AdminLoginView: AdminPage {
@ObservedObject var model: AdminStore
@State private var loginMode = "password"
@State private var loginUsername = ""
@State private var loginPassword = ""
var body: some View {
login.onChange(of: loginMode) { _, _ in model.cancelLogin(); loginPassword = "" }
}
private var login: some View {
GeometryReader { geometry in
ScrollView {
VStack(spacing: 20) {
Image(systemName: "lock.shield").font(.system(size: 40)).foregroundStyle(.teal)
Text("登录工作台").font(.title2.bold())
Text("使用管理员账号或微信扫码,继续处理项目报销。")
.font(.callout).foregroundStyle(.secondary)
Text("服务地址").font(.caption).foregroundStyle(.secondary).frame(width: 400, alignment: .leading)
HStack {
TextField("HTTPS 服务地址", text: $model.server).textFieldStyle(.roundedBorder)
if !model.serverHistory.isEmpty {
Menu {
ForEach(model.serverHistory, id: \.self) { address in
Button(address) { model.server = address }
}
} label: { Image(systemName: "clock.arrow.circlepath") }
.menuStyle(.borderlessButton).frame(width: 24).help("最近使用的服务地址")
}
}.frame(width: 400).disabled(model.busy)
Picker("登录方式", selection: $loginMode) {
Text("账号密码").tag("password")
Text("微信扫码").tag("wechat")
}.pickerStyle(.segmented).frame(width: 400)
Group {
if loginMode == "password" {
VStack(spacing: 16) {
Text("用户名").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
TextField("用户名", text: $loginUsername)
.textContentType(.username).textFieldStyle(.roundedBorder)
.accessibilityLabel("用户名")
Text("密码").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
SecureField("密码", text: $loginPassword)
.textContentType(.password).textFieldStyle(.roundedBorder)
.accessibilityLabel("密码").onSubmit { submitPassword() }
Button(action: submitPassword) { Text("登录管理端").frame(maxWidth: .infinity).padding(.vertical, 5) }
.buttonStyle(.borderedProminent)
.disabled(loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || loginPassword.isEmpty)
}.frame(width: 400).disabled(model.busy)
} else {
VStack(spacing: 16) {
if let image = model.qr {
Image(nsImage: image).resizable().interpolation(.none).scaledToFit()
.frame(width: 240, height: 240).background(.white)
} else {
Image(systemName: "qrcode").font(.system(size: 64)).foregroundStyle(.secondary)
.frame(width: 240, height: 240)
}
Button(model.busy ? "等待微信确认" : model.qr == nil ? "生成微信登录码" : "重新生成登录码") { model.startLogin() }
.buttonStyle(.borderedProminent).disabled(model.busy)
if model.busy { Button("取消扫码", action: model.cancelLogin) }
}
}
}.frame(height: 320)
}.padding(24).background(AppTheme.surface).clipShape(RoundedRectangle(cornerRadius: AppTheme.radius))
.padding(.vertical, 24).frame(maxWidth: .infinity)
.frame(minHeight: geometry.size.height)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private func submitPassword() {
guard !model.busy, !loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !loginPassword.isEmpty else { return }
let password = loginPassword
let username = loginUsername
loginPassword = ""
Task { await model.passwordLogin(username: username, password: password) }
}
}
+142
View File
@@ -0,0 +1,142 @@
import SwiftUI
struct AdminMembersTab: AdminPage {
@ObservedObject var model: AdminStore
@Binding var tab: String
@Binding var memberSection: String
@Binding var role: String
@State private var memberSearch = ""
@State private var hours = 168
@State private var uses = 50
@State private var confirmation: AdminAction?
var body: some View {
members.adminConfirmation($confirmation)
}
private var filteredMembers: [AdminMember] {
model.members.filter { AdminDisplayFilter.matches(memberSearch, fields: [$0.name, $0.userId, memberState($0.status)]) }
}
private var members: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Picker("成员与邀请", selection: $memberSection) {
Text("成员列表").tag("成员列表")
Text("入组邀请").tag("入组邀请")
}.pickerStyle(.segmented).frame(width: 260)
Spacer()
if let group = model.selectedGroup { Text(group.label).font(.callout).foregroundStyle(.secondary) }
}
if model.groupID == 0 {
VStack(spacing: 12) {
emptyState(model.availableGroups.isEmpty ? "当前项目暂无可见组" : "请选择一个管理组", icon: "person.3", message: "在上方选择组别后,即可审核成员或发送入组邀请。")
if model.projectID != 0 {
Button("查看全部项目的组") { model.projectID = 0 }
}
if !model.editableProjects.isEmpty {
Button("组别管理") { tab = "组别管理" }
}
if model.access?.platform == true {
Button("项目与授权") { tab = "项目与授权" }
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let group = model.selectedGroup, !group.canManage {
VStack(spacing: 12) {
emptyState("\(group.label) · 未授权", icon: "lock", message: "当前账户没有本组成员管理权限")
if model.access?.platform == true {
Button("项目负责人授权") {
model.projectID = group.projectId
role = "leader"
tab = "项目与授权"
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
if memberSection == "成员列表" { memberList } else { invitationPanel }
}
}.padding(20)
}
private var memberList: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
searchField("搜索姓名、账户或状态", text: $memberSearch).frame(maxWidth: 340)
Spacer()
Text("已加载 \(model.members.count) 人 · 待审核 \(model.members.filter { $0.status == "pending" }.count)")
.font(.callout).foregroundStyle(.secondary)
Button { memberSection = "入组邀请" } label: { Label("邀请成员", systemImage: "person.badge.plus") }.buttonStyle(.borderedProminent)
}
ZStack {
Table(filteredMembers) {
TableColumn("姓名", value: \.name)
TableColumn("账户 ID", value: \.userId)
TableColumn("状态") { statusBadge(memberState($0.status), positive: $0.status == "active", pending: $0.status == "pending") }
TableColumn("操作") { m in
HStack {
if m.status == "pending" {
Button("批准入组") { run { try await model.reviewMember(m, status: "active") } }
Button("拒绝") { confirm("拒绝 \(m.name) 的入组申请?") { try await model.reviewMember(m, status: "rejected") } }
} else if m.status == "active" {
Button("停用") { confirm("停用 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "disabled") } }
} else {
Button("恢复") { confirm("恢复 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "active") } }
}
}
}.width(min: 200)
}.frame(minHeight: 180).clipShape(RoundedRectangle(cornerRadius: 10))
if filteredMembers.isEmpty {
emptyState("暂无匹配成员", icon: "person.2", message: "可以清空搜索,或通过邀请二维码让成员申请入组。")
.allowsHitTesting(false)
}
}
Text("成员资格与管理员权限独立;停用、恢复等操作仅作用于当前组。")
.font(.caption).foregroundStyle(.secondary)
}
}
private var invitationPanel: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
sectionCard("创建入组邀请", icon: "qrcode") {
Text("设置有效期和人数上限,生成海报后发给成员。扫码申请仍需管理员审核。")
.font(.callout).foregroundStyle(.secondary)
HStack(spacing: 24) {
Stepper("有效期 \(hours) 小时", value: $hours, in: 1...720).frame(width: 240)
Stepper("最多 \(uses)", value: $uses, in: 1...1000).frame(width: 200)
Spacer()
}
HStack {
Button { run { try await model.createInvite(hours: hours, uses: uses) } } label: { Label("生成邀请海报", systemImage: "qrcode") }.buttonStyle(.borderedProminent)
Button { run { try await model.retryInviteQR() } } label: { Label("重试加载二维码", systemImage: "arrow.clockwise") }
}
if let poster = model.invitationPoster {
HStack {
Image(nsImage: poster).resizable().interpolation(.high).scaledToFit().frame(width: 210, height: 280)
VStack(alignment: .leading, spacing: 10) {
Text("邀请海报已生成").font(.headline)
Text("二维码和有效期已经排版到海报中,可直接保存后发送给成员。").foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
Button { saveQR(poster, filename: "报销组入组邀请海报.png") } label: { Label("保存邀请海报", systemImage: "square.and.arrow.down") }.buttonStyle(.borderedProminent)
Button { run { try await model.retryInviteQR() } } label: { Label("重新生成海报", systemImage: "arrow.clockwise") }
}.frame(maxWidth: 380, alignment: .leading)
}
}
}
sectionCard("邀请记录", icon: "clock") {
if model.invites.isEmpty { Text("还没有邀请记录").foregroundStyle(.secondary) }
ForEach(model.invites) { invite in
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 5) {
Text("邀请 #\(invite.id) · 已使用 \(invite.uses) / \(invite.maxUses)").font(.callout.weight(.medium))
Text("有效期至 \(invite.expiresAt)").font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
}
Spacer()
if invite.active {
Button("撤销邀请", role: .destructive) { confirm("撤销邀请 #\(invite.id)") { try await model.revoke(invite) } }
} else { statusBadge("已撤销") }
}.padding(.vertical, 6)
Divider()
}
}
}.frame(maxWidth: 1060).frame(maxWidth: .infinity)
}
}
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
confirmation = AdminAction(title: title) { run(work) }
}
}
+182
View File
@@ -0,0 +1,182 @@
import SwiftUI
struct AdminPermissionsTab: AdminPage {
@ObservedObject var model: AdminStore
@Binding var role: String
@Binding var permissionSection: String
@State private var name = ""
@State private var selectedCandidate = ""
@State private var selectedGroups: Set<Int64> = []
@State private var confirmation: AdminAction?
var body: some View {
permissions.adminConfirmation($confirmation)
.onChange(of: model.projectID) { _, _ in selectedGroups = [] }
}
private var permissions: some View {
VStack(alignment: .leading, spacing: 16) {
Picker("项目与授权", selection: $permissionSection) {
Text("授权管理").tag("授权管理")
Text("现有授权").tag("现有授权")
if model.access?.platform == true { Text("项目管理").tag("项目管理") }
}.pickerStyle(.segmented).frame(maxWidth: 420)
ScrollView {
VStack(alignment: .leading, spacing: 20) {
if permissionSection == "项目管理", model.access?.platform == true {
sectionCard("项目管理", icon: "building.2") {
Text("创建项目,或调整项目启用状态。停用前请确认对成员业务的影响。")
.font(.callout).foregroundStyle(.secondary)
HStack {
TextField("新项目名称", text: $name).textFieldStyle(.roundedBorder)
Button { run { try await model.createProject(name); name = "" } } label: { Label("创建项目", systemImage: "plus") }.disabled(name.isEmpty)
}
ForEach(model.access?.projects ?? []) { p in
HStack { Text(p.name); Text("#\(p.id)").foregroundStyle(.secondary); Spacer(); Button(p.active ? "停用项目" : "恢复项目") { confirm("确认\(p.active ? "停用" : "恢复")项目 \(p.name)") { try await model.toggleProject(p) } } }
}
}
} else if permissionSection == "现有授权" {
sectionCard("现有授权", icon: "person.crop.rectangle.badge.checkmark") {
Text("显示当前账号可查看的授权;撤销仅影响所选角色及范围。列表会同时显示姓名和账户 ID,便于准确识别成员。")
.font(.callout).foregroundStyle(.secondary)
if model.grants.isEmpty { Text("暂无可显示的授权记录").foregroundStyle(.secondary) }
ForEach(model.grants) { g in
let roleName = g.role == "leader" ? "项目负责人" : g.role == "group" ? "组管理员" : "平台管理员"
let scopeName = grantScopeName(g)
HStack(spacing: 14) {
Image(systemName: g.role == "platform" ? "crown" : "person.crop.circle")
.foregroundStyle(g.role == "platform" ? .orange : .accentColor)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Text(g.name.isEmpty ? "未设置姓名" : g.name).font(.headline)
Text(roleName).font(.caption.weight(.medium)).padding(.horizontal, 8).padding(.vertical, 4)
.background(Color.accentColor.opacity(0.12)).clipShape(Capsule())
}
Text("账户 ID\(g.userId) · \(scopeName)").font(.caption).foregroundStyle(.secondary)
}
Spacer()
if g.role != "platform" {
Button("撤销授权") { confirm("撤销“\(g.name.isEmpty ? "账户 \(g.userId)" : g.name)”的\(roleName)授权?") { try await model.grant(userID: g.userId, role: g.role, scope: g.scopeId, revoke: true) } }
}
}
.padding(.vertical, 6)
Divider()
}
}
} else {
sectionCard("1 · 选择角色和范围", icon: "person.badge.key") {
Text(model.projectID == 0 ? "请先在上方选择项目;组管理员可勾选多个授权组。" : "当前项目:" + (model.access?.projects.first { $0.id == model.projectID }?.name ?? ""))
.font(.callout).foregroundStyle(.secondary)
Picker("角色", selection: $role) {
if model.access?.platform == true { Text("项目负责人").tag("leader") }
Text("组管理员").tag("group")
}.pickerStyle(.segmented).frame(maxWidth: 400)
if role == "leader" {
Button {
let projectID = model.projectID
confirm("生成当前项目的负责人授权码?首位扫码并确认的账户将获得权限,有效期 5 分钟。") {
try await model.createGrantCode(role: "leader", scopes: [projectID])
}
} label: { Label("生成负责人授权码", systemImage: "qrcode") }
.disabled(model.access?.platform != true || !(model.access?.projects.contains(where: { $0.id == model.projectID && $0.active }) ?? false))
} else {
ForEach(model.availableGroups.filter { group in
group.memberStatus != "disabled" &&
(model.access?.platform == true || model.access?.projects.contains(where: { $0.id == group.projectId && $0.canLead }) == true)
}) { g in
Toggle(g.label, isOn: Binding(get: { selectedGroups.contains(g.id) }, set: { if $0 { selectedGroups.insert(g.id) } else { selectedGroups.remove(g.id) } }))
}
Button {
let scopes = selectedGroups.sorted()
confirm("生成 \(scopes.count) 个组的管理员授权码?首位扫码并确认的账户将获得权限,有效期 5 分钟。") {
try await model.createGrantCode(role: "group", scopes: scopes)
selectedGroups = []
}
} label: { Label("生成组管理员授权码", systemImage: "qrcode") }
.disabled(selectedGroups.isEmpty || selectedGroups.count > 50)
}
}
sectionCard("2 · 或直接授权给已有账户", icon: "person.crop.circle.badge.checkmark") {
Text("使用上面选择的角色和范围;也可使用上面的二维码,由接收者扫码确认。")
.font(.callout).foregroundStyle(.secondary)
HStack {
Picker("授权用户", selection: $selectedCandidate) {
Text("请选择已登录微信用户").tag("")
ForEach(model.candidates) { user in
Text("\(user.name) · 账户 \(user.id)").tag(user.id)
}
}
.frame(maxWidth: 360)
Button {
let userID = selectedCandidate
let selectedRole = role
let scope = selectedRole == "leader" ? model.projectID : selectedGroups.sorted().first ?? 0
confirm("直接授权给账户 \(userID)") {
if selectedRole == "group" {
for groupID in selectedGroups.sorted() {
try await model.grant(userID: userID, role: selectedRole, scope: groupID)
}
selectedGroups = []
} else {
try await model.grant(userID: userID, role: selectedRole, scope: scope)
}
selectedCandidate = ""
}
} label: { Label("直接授权", systemImage: "person.badge.key") }
.disabled(selectedCandidate.isEmpty ||
(role == "leader" ? model.projectID == 0 : selectedGroups.isEmpty))
}
}
if let ticket = model.grantTicket {
sectionCard("二维码授权进度", icon: "qrcode") { grantCode(ticket) }
}
}
}.frame(maxWidth: 1060).frame(maxWidth: .infinity)
}
}.padding(20)
}
private func grantScopeName(_ grant: AdminGrant) -> String {
if grant.role == "platform" { return "全部项目与范围" }
if grant.role == "leader" {
return "项目:" + (model.access?.projects.first { $0.id == grant.scopeId }?.name ?? "范围 #\(grant.scopeId)")
}
return "组:" + (model.access?.groups.first { $0.id == grant.scopeId }?.label ?? "范围 #\(grant.scopeId)")
}
private func grantCode(_ ticket: AdminGrantTicket) -> some View {
HStack(alignment: .top, spacing: 24) {
Group {
if let image = model.grantImage, model.grantState == "pending" {
Image(nsImage: image).resizable().interpolation(.none).scaledToFit().background(.white)
} else {
Image(systemName: model.grantState == "confirmed" ? "checkmark.shield" : "qrcode")
.font(.system(size: 48)).foregroundStyle(.secondary)
}
}.frame(width: 210, height: 210)
VStack(alignment: .leading, spacing: 12) {
Text(ticket.role == "leader" ? "项目负责人授权" : "组管理员授权").font(.headline)
ForEach(ticket.scopes) { scope in
Text(ticket.role == "leader" ? scope.projectName : "\(scope.projectName) / \(scope.name)")
.fixedSize(horizontal: false, vertical: true)
}
Text(["pending": "等待微信扫码确认", "confirmed": "授权成功 · 账户 \(model.grantRecipient)", "expired": "授权码已过期", "revoked": "授权码已撤销"][model.grantState] ?? "")
if let expiry = ticket.expiryDate {
Text("到期:\(expiry.formatted(date: .omitted, time: .standard))").font(.caption).foregroundStyle(.secondary)
}
HStack {
if model.grantState == "pending" {
Button { run { try await model.retryGrantImage() } } label: { Image(systemName: "qrcode") }.help("重新加载授权码")
if let image = model.grantImage {
Button { saveQR(image, filename: "管理员授权码.png") } label: { Image(systemName: "square.and.arrow.down") }.help("保存授权二维码")
}
Button {
confirm("撤销此授权码?已保存的二维码也将失效。") { try await model.revokeGrantCode() }
} label: { Image(systemName: "xmark.circle") }.help("撤销授权码")
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
}.padding(.vertical, 12)
}
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
confirmation = AdminAction(title: title) { run(work) }
}
}
+386
View File
@@ -0,0 +1,386 @@
import SwiftUI
struct AdminPoliciesTab: AdminPage {
@ObservedObject var model: AdminStore
@State private var selectedPolicyType = ""
@State private var policySearch = ""
@State private var showMaterialEditor = false
@State private var lastSavedPolicy: AdminExpensePolicy?
@State private var showTypeEditor = false
@State private var previousTypeName: String?
@State private var editedTypeName = ""
@State private var newPolicyMaterialName = ""
@State private var newPolicyMaterialHint = ""
@State private var newPolicyMaterialAllowsImage = true
@State private var newPolicyMaterialAllowsPDF = true
@State private var editingPolicyMaterialKind = ""
@State private var confirmation: AdminAction?
var body: some View {
policies.adminConfirmation($confirmation)
.onChange(of: model.policies.map(\.type), initial: true) { _, types in
if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" }
}
.onChange(of: selectedPolicyType) { _, _ in resetPolicyMaterialEditor() }
.sheet(isPresented: $showTypeEditor) { expenseTypeEditor }
.sheet(isPresented: $showMaterialEditor) {
if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) {
policyMaterialEditor(policy: $model.policies[index])
}
}
}
private var expenseTypeEditor: some View {
VStack(alignment: .leading, spacing: 16) {
Text(previousTypeName == nil ? "新增报销类型" : "重命名报销类型").font(.title3.bold())
TextField("类型名称,最多 40 字", text: $editedTypeName).textFieldStyle(.roundedBorder)
Text(previousTypeName == nil ? "新类型默认停用。创建后配置说明、提示和材料要求,再勾选启用并保存规则。" : "材料规则保持不变,历史报销记录保留提交时的类型名称。未提交的草稿需重新选择新名称。")
.font(.caption).foregroundStyle(.secondary)
if let error = model.error { Text(error).foregroundStyle(.red) }
HStack {
Spacer()
Button("取消") { showTypeEditor = false }
Button("保存") {
let name = editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines)
let previous = previousTypeName
run {
try await model.saveExpenseType(name: name, previous: previous)
policySearch = ""
selectedPolicyType = name
resetPolicyMaterialEditor()
showTypeEditor = false
}
}.buttonStyle(.borderedProminent)
.disabled(editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars.count > 40)
}
}.padding(24).frame(width: 440).disabled(model.busy).interactiveDismissDisabled(model.busy)
}
private var filteredPolicies: [AdminExpensePolicy] {
let query = policySearch.trimmingCharacters(in: .whitespacesAndNewlines)
return model.policies.filter { query.isEmpty || $0.type.localizedCaseInsensitiveContains(query) }
}
private func beginTypeEditor(_ policy: AdminExpensePolicy? = nil) {
model.error = nil
previousTypeName = policy?.type
editedTypeName = policy?.type ?? ""
showTypeEditor = true
}
private var expenseTypeManagement: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("报销类型").font(.headline)
Text("\(model.policies.count)").font(.caption).foregroundStyle(.secondary)
Spacer()
}
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("搜索类型", text: $policySearch).textFieldStyle(.plain)
if !policySearch.isEmpty {
Button { policySearch = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain).foregroundStyle(.secondary).help("清空搜索")
}
}.padding(10).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 8))
ScrollView {
LazyVStack(spacing: 6) {
ForEach(filteredPolicies) { policy in
policySidebarRow(policy)
}
if filteredPolicies.isEmpty {
Text(model.policies.isEmpty ? "还没有报销类型" : "没有匹配的类型")
.font(.callout).foregroundStyle(.secondary).padding(.vertical, 24)
}
}
}
Button { beginTypeEditor() } label: {
Label("新增报销类型", systemImage: "plus").frame(maxWidth: .infinity).padding(.vertical, 5)
}.buttonStyle(.borderedProminent).disabled(model.access?.platform != true)
Text("类型配置对所有项目生效")
.font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity)
}.padding(16).frame(width: 244).frame(maxHeight: .infinity)
.background(Color(nsColor: .windowBackgroundColor))
}
private func policySidebarRow(_ policy: AdminExpensePolicy) -> some View {
let selected = policy.type == selectedPolicyType
return Button {
selectedPolicyType = policy.type
} label: {
HStack(spacing: 10) {
Image(systemName: "doc.text").foregroundStyle(selected ? Color.accentColor : Color.secondary)
VStack(alignment: .leading, spacing: 5) {
Text(policy.type).font(.system(size: 13, weight: selected ? .semibold : .regular))
.lineLimit(2).multilineTextAlignment(.leading)
HStack(spacing: 5) {
Circle().fill(policy.active ? Color.green : Color.secondary.opacity(0.45)).frame(width: 5, height: 5)
Text(policy.active ? "启用" : "停用").font(.caption).foregroundStyle(.secondary)
}
}
Spacer(minLength: 0)
if selected { Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(Color.accentColor) }
}.padding(12).frame(maxWidth: .infinity, alignment: .leading)
.background(selected ? Color.accentColor.opacity(0.1) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 9))
.contentShape(Rectangle())
}.buttonStyle(.plain).accessibilityLabel("\(policy.type)\(policy.active ? "启用" : "停用")")
.accessibilityAddTraits(selected ? [.isSelected] : [])
}
private var policies: some View {
HStack(spacing: 0) {
expenseTypeManagement
Divider()
if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) {
policyDetail(policy: $model.policies[index])
} else {
VStack(spacing: 12) {
Image(systemName: "doc.badge.plus").font(.system(size: 34)).foregroundStyle(Color.accentColor)
Text("从一个报销类型开始").font(.title3.weight(.semibold))
Text("新增类型后,设置说明和上传材料要求。")
.foregroundStyle(.secondary)
Button("新增报销类型") { beginTypeEditor() }
.buttonStyle(.borderedProminent).disabled(model.access?.platform != true)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.12)))
.padding(16)
}
private func policyDetail(policy: Binding<AdminExpensePolicy>) -> some View {
VStack(spacing: 0) {
HStack(alignment: .center, spacing: 16) {
VStack(alignment: .leading, spacing: 5) {
Text(policy.wrappedValue.type).font(.title2.weight(.semibold))
Text("设置申请人看到的说明与材料要求").font(.callout).foregroundStyle(.secondary)
}
Spacer()
Toggle("启用此类型", isOn: policy.active).toggleStyle(.switch)
.disabled(model.access?.platform != true)
Menu {
Button("重命名类型") { beginTypeEditor(policy.wrappedValue) }
Divider()
Button("删除类型…", role: .destructive) {
let type = policy.wrappedValue.type
confirm("删除报销类型“\(type)”?将删除该类型及材料规则。历史报销记录和附件保留。") {
try await model.deleteExpenseType(type)
if selectedPolicyType == type { selectedPolicyType = model.policies.first?.type ?? "" }
}
}
} label: { Image(systemName: "ellipsis.circle").font(.title3) }
.menuStyle(.borderlessButton).frame(width: 28)
.help("类型操作:重命名、删除").disabled(model.access?.platform != true)
}.padding(24)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
if model.access?.platform != true {
Label("只读 · 仅平台管理员可修改", systemImage: "lock").font(.callout).foregroundStyle(.secondary)
}
policyBasicInformation(policy: policy)
policyMaterialsSection(policy: policy)
}.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity)
}
Divider()
HStack(spacing: 12) {
if lastSavedPolicy == policy.wrappedValue {
Label("规则已保存", systemImage: "checkmark.circle.fill").foregroundStyle(.green)
} else {
Text(model.access?.platform == true ? "修改后请保存 · 切换类型不会自动保存" : "当前为只读模式")
.foregroundStyle(.secondary)
}
Spacer()
if model.access?.platform == true {
Button {
let value = policy.wrappedValue
run {
try await model.updatePolicy(value)
lastSavedPolicy = model.policies.first { $0.type == value.type }
}
} label: { Label("保存规则", systemImage: "checkmark").padding(.horizontal, 10) }
.buttonStyle(.borderedProminent).controlSize(.large)
}
}.font(.callout).padding(.horizontal, 24).padding(.vertical, 16)
.background(Color(nsColor: .windowBackgroundColor))
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func policyBasicInformation(policy: Binding<AdminExpensePolicy>) -> some View {
VStack(alignment: .leading, spacing: 16) {
Label("基本信息", systemImage: "text.alignleft").font(.headline)
VStack(alignment: .leading, spacing: 7) {
Text("报销内容说明").font(.callout.weight(.medium))
TextField("例如:项目期间产生的交通与出行费用", text: policy.description, axis: .vertical)
.textFieldStyle(.roundedBorder).lineLimit(2...4)
}
VStack(alignment: .leading, spacing: 7) {
Text("申请提示").font(.callout.weight(.medium))
TextField("告诉申请人需要补充哪些信息", text: policy.tips, axis: .vertical)
.textFieldStyle(.roundedBorder).lineLimit(2...4)
}
}.disabled(model.access?.platform != true)
}
private func policyMaterialsSection(policy: Binding<AdminExpensePolicy>) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label("上传材料", systemImage: "paperclip").font(.headline)
Spacer()
Button {
resetPolicyMaterialEditor()
model.error = nil
showMaterialEditor = true
} label: { Label("添加材料", systemImage: "plus") }
.disabled(model.access?.platform != true)
}
Text("不使用的材料不会出现在申请表中;必填材料至少上传一份。")
.font(.caption).foregroundStyle(.secondary)
VStack(spacing: 0) {
ForEach(model.uploadKinds.filter(\.active)) { kind in
policyMaterialRow(policy: policy, kind: kind)
}
if model.uploadKinds.filter(\.active).isEmpty {
Text("还没有材料类型,点击“添加材料”开始配置。")
.foregroundStyle(.secondary).padding(20).frame(maxWidth: .infinity)
}
}.background(Color(nsColor: .windowBackgroundColor).opacity(0.55))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)))
}
}
private func policyMaterialRow(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind) -> some View {
HStack(spacing: 14) {
Image(systemName: kind.allowPDF ? "doc.text" : "photo")
.foregroundStyle(Color.accentColor).frame(width: 32, height: 36)
VStack(alignment: .leading, spacing: 4) {
Text(kind.name).font(.callout.weight(.medium))
Text(kind.hint.isEmpty ? materialFormats(kind) : kind.hint)
.font(.caption).foregroundStyle(.secondary).lineLimit(2).help(kind.hint)
}.frame(maxWidth: .infinity, alignment: .leading)
materialModeButtons(policy: policy, kind: kind)
.disabled(model.access?.platform != true)
Menu {
Button("编辑材料…") {
editingPolicyMaterialKind = kind.kind
newPolicyMaterialName = kind.name
newPolicyMaterialHint = kind.hint
newPolicyMaterialAllowsImage = kind.allowImage
newPolicyMaterialAllowsPDF = kind.allowPDF
model.error = nil
showMaterialEditor = true
}
Divider()
Button("删除材料类型…", role: .destructive) {
confirm("删除材料类型“\(kind.name)”?将从所有报销类型中移除,历史附件保留。仅暂停使用请选择“不使用”并保存规则。") {
try await model.deleteUploadKind(kind)
if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() }
}
}
} label: { Image(systemName: "ellipsis").frame(width: 20, height: 24) }
.menuStyle(.borderlessButton).frame(width: 28).help("材料操作:编辑、删除")
.disabled(model.access?.platform != true)
}.padding(14)
}
private func policyMaterialEditor(policy: Binding<AdminExpensePolicy>) -> some View {
VStack(alignment: .leading, spacing: 18) {
Text(editingPolicyMaterialKind.isEmpty ? "添加上传材料" : "编辑上传材料").font(.title3.bold())
Text("用于“\(policy.wrappedValue.type)” · 保存时会一并保存当前类型的规则。")
.font(.caption).foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 6) {
Text("材料名称").font(.callout.weight(.medium))
TextField("例如:租赁合同", text: $newPolicyMaterialName).textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 6) {
Text("上传提示").font(.callout.weight(.medium))
TextField("例如:请上传完整合同,包含签字页", text: $newPolicyMaterialHint).textFieldStyle(.roundedBorder)
}
HStack {
Text("允许格式").font(.callout.weight(.medium))
Toggle("图片", isOn: $newPolicyMaterialAllowsImage)
Toggle("PDF", isOn: $newPolicyMaterialAllowsPDF)
}
if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) }
HStack {
Spacer()
Button("取消") { showMaterialEditor = false; resetPolicyMaterialEditor() }
Button("保存材料") {
let name = newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines)
let hint = newPolicyMaterialHint.trimmingCharacters(in: .whitespacesAndNewlines)
let kind = editingPolicyMaterialKind.isEmpty
? "custom-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased())"
: editingPolicyMaterialKind
let nextKind = AdminUploadKind(kind: kind, name: name, hint: hint,
allowImage: newPolicyMaterialAllowsImage, allowPDF: newPolicyMaterialAllowsPDF,
active: true, sortOrder: (model.uploadKinds.map(\.sortOrder).max() ?? 0) + 10)
var nextPolicy = policy.wrappedValue
if let index = nextPolicy.materials.firstIndex(where: { $0.kind == kind }) {
let current = nextPolicy.materials[index]
nextPolicy.materials[index] = AdminExpenseMaterial(kind: kind, name: name, hint: hint,
required: current.required, allowImage: nextKind.allowImage, allowPDF: nextKind.allowPDF,
active: true, sortOrder: current.sortOrder)
} else {
nextPolicy.materials.append(AdminExpenseMaterial(kind: kind, name: name, hint: hint,
required: false, allowImage: nextKind.allowImage, allowPDF: nextKind.allowPDF,
active: true, sortOrder: nextKind.sortOrder))
}
nextPolicy.requiredKinds = nextPolicy.materials.filter(\.required).map(\.kind)
run {
try await model.updateUploadKind(nextKind)
try await model.updatePolicy(nextPolicy)
lastSavedPolicy = model.policies.first { $0.type == nextPolicy.type }
showMaterialEditor = false
resetPolicyMaterialEditor()
}
}.buttonStyle(.borderedProminent)
.disabled(newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (!newPolicyMaterialAllowsImage && !newPolicyMaterialAllowsPDF))
}
}.padding(24).frame(width: 480).disabled(model.busy || model.access?.platform != true)
.interactiveDismissDisabled(model.busy)
}
private func materialFormats(_ kind: AdminUploadKind) -> String {
[kind.allowImage ? "图片" : nil, kind.allowPDF ? "PDF" : nil].compactMap { $0 }.joined(separator: " + ")
}
private func materialModeButtons(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind) -> some View {
let current = policy.wrappedValue.materials.first(where: { $0.kind == kind.kind })
let mode = current == nil ? "none" : (current?.required == true ? "required" : "optional")
return HStack(spacing: 0) {
materialModeButton("不使用", mode: "none", selected: mode == "none") {
setMaterialMode(policy: policy, kind: kind, mode: "none")
}
materialModeButton("选填", mode: "optional", selected: mode == "optional") {
setMaterialMode(policy: policy, kind: kind, mode: "optional")
}
materialModeButton("必填", mode: "required", selected: mode == "required") {
setMaterialMode(policy: policy, kind: kind, mode: "required")
}
}
.frame(width: 250, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(.separator))
}
private func materialModeButton(_ title: String, mode: String, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(selected ? Color.accentColor : Color.clear)
.foregroundStyle(selected ? .white : .primary)
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.help(mode == "none" ? "当前类型不需要此材料" : mode == "optional" ? "当前类型可以不上传此材料" : "当前类型必须上传此材料")
}
private func setMaterialMode(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind, mode: String) {
policy.wrappedValue.materials.removeAll { $0.kind == kind.kind }
if mode != "none" {
policy.wrappedValue.materials.append(AdminExpenseMaterial(
kind: kind.kind, name: kind.name, hint: kind.hint, required: mode == "required",
allowImage: kind.allowImage, allowPDF: kind.allowPDF, active: kind.active, sortOrder: kind.sortOrder
))
}
policy.wrappedValue.requiredKinds = policy.wrappedValue.materials.filter(\.required).map(\.kind)
}
private func resetPolicyMaterialEditor() {
editingPolicyMaterialKind = ""
newPolicyMaterialName = ""
newPolicyMaterialHint = ""
newPolicyMaterialAllowsImage = true
newPolicyMaterialAllowsPDF = true
}
private func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
confirmation = AdminAction(title: title) { run(work) }
}
}
+8
View File
@@ -16,6 +16,8 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
@Published var session: AdminSession? @Published var session: AdminSession?
@Published var access: AdminAccess? @Published var access: AdminAccess?
@Published var error: String? @Published var error: String?
@Published var notice: String?
@Published private(set) var serverHistory = UserDefaults.standard.stringArray(forKey: "admin.serverHistory") ?? []
@Published var busy = false @Published var busy = false
@Published var qr: NSImage? @Published var qr: NSImage?
@Published var invitationImage: NSImage? @Published var invitationImage: NSImage?
@@ -115,6 +117,8 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
do { do {
try keychainSave(try JSONEncoder().encode(value)) try keychainSave(try JSONEncoder().encode(value))
try await reload() try await reload()
serverHistory = Array(([origin] + serverHistory.filter { $0 != origin }).prefix(5))
UserDefaults.standard.set(serverHistory, forKey: "admin.serverHistory")
} catch { } catch {
clearSession() clearSession()
throw error throw error
@@ -200,6 +204,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
guard let r = selected else { return } guard let r = selected else { return }
let _: Bool = try await request("/api/admin/expenses/\(r.id)/review", method: "POST", body: ["state": state, "reason": reason, "version": r.version]) let _: Bool = try await request("/api/admin/expenses/\(r.id)/review", method: "POST", body: ["state": state, "reason": reason, "version": r.version])
try await refreshExpenses() try await refreshExpenses()
notice = state == "approved" ? "报销申请已批准" : "报销申请已退回"
} }
func createInvite(hours: Int, uses: Int) async throws { func createInvite(hours: Int, uses: Int) async throws {
let i: AdminInvite = try await request("/api/admin/invites", method: "POST", body: ["groupId": groupID, "hours": hours, "maxUses": uses]) let i: AdminInvite = try await request("/api/admin/invites", method: "POST", body: ["groupId": groupID, "hours": hours, "maxUses": uses])
@@ -398,6 +403,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。") throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。")
} }
mergeSavedPolicy(saved) mergeSavedPolicy(saved)
notice = "\(saved.type)”规则已保存"
} }
func mergeSavedPolicy(_ saved: AdminExpensePolicy) { func mergeSavedPolicy(_ saved: AdminExpensePolicy) {
if let index = policies.firstIndex(where: { $0.type == saved.type }) { if let index = policies.firstIndex(where: { $0.type == saved.type }) {
@@ -463,6 +469,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
throw AdminFailure(message: error?["msg"] as? String ?? "导出失败") throw AdminFailure(message: error?["msg"] as? String ?? "导出失败")
} }
try data.write(to: destination, options: .atomic) try data.write(to: destination, options: .atomic)
notice = "凭证已保存至 \(destination.path)"
} }
func download(_ file: AdminFile) async throws { func download(_ file: AdminFile) async throws {
let panel = NSSavePanel(); panel.nameFieldStringValue = file.name let panel = NSSavePanel(); panel.nameFieldStringValue = file.name
@@ -495,6 +502,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
grantTask?.cancel(); grantTicket = nil; grantImage = nil; grantState = ""; grantRecipient = "" grantTask?.cancel(); grantTicket = nil; grantImage = nil; grantState = ""; grantRecipient = ""
generation += 1; loginTask?.cancel(); session = nil; access = nil; qr = nil; selected = nil generation += 1; loginTask?.cancel(); session = nil; access = nil; qr = nil; selected = nil
busy = false busy = false
notice = nil
expenses = []; members = []; grants = []; candidates = []; managedGroups = []; invites = []; audit = []; policies = []; uploadKinds = []; invitationImage = nil; invitationPoster = nil expenses = []; members = []; grants = []; candidates = []; managedGroups = []; invites = []; audit = []; policies = []; uploadKinds = []; invitationImage = nil; invitationPoster = nil
inviteScene = nil; invitationExpiresAt = nil; invitationMaxUses = 0 inviteScene = nil; invitationExpiresAt = nil; invitationMaxUses = 0
SecItemDelete(keychainQuery() as CFDictionary) SecItemDelete(keychainQuery() as CFDictionary)
+29 -1279
View File
File diff suppressed because it is too large Load Diff
+127
View File
@@ -0,0 +1,127 @@
import SwiftUI
enum AppTheme {
static let accent = Color.teal
static let success = Color.green
static let warning = Color.orange
static let danger = Color.red
static let surface = Color(nsColor: .controlBackgroundColor)
static let canvas = Color(nsColor: .underPageBackgroundColor)
static let border = Color.primary.opacity(0.12)
static let page: CGFloat = 24
static let gap: CGFloat = 16
static let compact: CGFloat = 8
static let radius: CGFloat = 8
@MainActor static func sheetSize(width: CGFloat, height: CGFloat) -> CGSize {
let screen = NSApp.keyWindow?.screen ?? NSScreen.main
let available = screen?.visibleFrame.size ?? CGSize(width: 1280, height: 800)
return CGSize(width: min(width, max(320, available.width - 64)),
height: min(height, max(300, available.height - 80)))
}
}
enum NoticeTone {
case success, warning, danger, information
var color: Color {
switch self {
case .success: AppTheme.success
case .warning: AppTheme.warning
case .danger: AppTheme.danger
case .information: AppTheme.accent
}
}
var symbol: String {
switch self {
case .success: "checkmark.circle.fill"
case .warning: "exclamationmark.triangle.fill"
case .danger: "exclamationmark.octagon.fill"
case .information: "info.circle.fill"
}
}
}
struct AppNotice: View {
let text: String
var tone: NoticeTone = .information
var actionTitle: String?
var action: (() -> Void)?
var dismiss: (() -> Void)?
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: tone.symbol).foregroundStyle(tone.color)
.symbolEffect(.bounce, value: text)
Text(text).font(.callout).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if let actionTitle, let action { Button(actionTitle, action: action).buttonStyle(.borderless) }
if let dismiss {
Button(action: dismiss) { Image(systemName: "xmark") }
.buttonStyle(.plain).help("关闭提示").accessibilityLabel("关闭提示")
}
}.padding(12).background(tone.color.opacity(0.08))
.accessibilityElement(children: .contain)
}
}
struct AppBadge: View {
let title: String
var symbol: String
var tone: NoticeTone = .information
var body: some View {
Label(title, systemImage: symbol)
.font(.caption.weight(.medium)).monospacedDigit()
.padding(.horizontal, 8).padding(.vertical, 4)
.foregroundStyle(tone.color)
.background(tone.color.opacity(0.1), in: RoundedRectangle(cornerRadius: 5))
}
}
struct AppSearchField: View {
let placeholder: String
@Binding var text: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField(placeholder, text: $text).textFieldStyle(.plain)
.accessibilityLabel(placeholder)
Button { text = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.plain).foregroundStyle(.secondary)
.opacity(text.isEmpty ? 0 : 1).disabled(text.isEmpty).help("清空搜索")
.accessibilityLabel("清空搜索")
}.padding(9).background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
.overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(AppTheme.border))
}
}
struct StatNumber: View {
let title: String
let value: String
let symbol: String
var detail = ""
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Label(title, systemImage: symbol).font(.caption).foregroundStyle(.secondary)
Text(value).font(.system(.title2, design: .rounded).weight(.semibold))
.monospacedDigit().lineLimit(1).minimumScaleFactor(0.7)
if !detail.isEmpty { Text(detail).font(.caption).foregroundStyle(.secondary) }
}.frame(maxWidth: .infinity, alignment: .leading)
}
}
struct AppSection<Content: View>: View {
let title: String
let symbol: String
@ViewBuilder var content: Content
var body: some View {
VStack(alignment: .leading, spacing: AppTheme.gap) {
Label(title, systemImage: symbol).font(.headline)
content
}.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 12)
}
}
enum WorkspaceCommand {
static let find = Notification.Name("ReceiptDesk.find")
}
+39 -22
View File
@@ -3,7 +3,6 @@ import SwiftUI
struct ContentView: View { struct ContentView: View {
@EnvironmentObject var store: WorkspaceStore @EnvironmentObject var store: WorkspaceStore
@State private var confirmClear = false @State private var confirmClear = false
@State private var showPayeeProfile = false
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
@@ -23,7 +22,13 @@ struct ContentView: View {
Text(page.rawValue) Text(page.rawValue)
Spacer() Spacer()
if page == .pair { count(store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count) } if page == .pair { count(store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count) }
if page == .matched { count(store.state.matches.count) } if page == .matched {
count(store.state.matches.count)
if store.presentation.attentionCount > 0 {
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(AppTheme.warning)
.help("\(store.presentation.attentionCount) 组需关注")
}
}
} }
} icon: { Image(systemName: page.symbol) } } icon: { Image(systemName: page.symbol) }
.padding(.vertical, 7).tag(page) .padding(.vertical, 7).tag(page)
@@ -50,11 +55,13 @@ struct ContentView: View {
}.padding().background(.teal.opacity(0.06)) }.padding().background(.teal.opacity(0.06))
} }
if let notice = store.notice { if let notice = store.notice {
HStack { AppNotice(text: notice, tone: notice.hasPrefix("已取消") ? .information : .success,
Label(notice, systemImage: "checkmark.circle.fill").foregroundStyle(.teal) actionTitle: store.lastExportURL == nil ? nil : "在访达中显示",
Spacer() action: { if let url = store.lastExportURL { NSWorkspace.shared.activateFileViewerSelecting([url]) } },
Button { store.notice = nil } label: { Image(systemName: "xmark") }.buttonStyle(.plain) dismiss: { store.notice = nil })
}.font(.callout).padding(12).background(.teal.opacity(0.06)) }
if let error = store.errorMessage {
AppNotice(text: error, tone: .danger, dismiss: { store.errorMessage = nil })
} }
Group { Group {
switch store.page ?? .scan { switch store.page ?? .scan {
@@ -67,7 +74,7 @@ struct ContentView: View {
} }
} }
.tint(.teal) .tint(.teal)
.frame(minWidth: 1050, minHeight: 700) .frame(minWidth: 900, minHeight: 560)
.toolbar { .toolbar {
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: { Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: {
@@ -75,7 +82,7 @@ struct ContentView: View {
}.disabled(store.busy) }.disabled(store.busy)
} }
ToolbarItem(placement: .primaryAction) { ToolbarItem(placement: .primaryAction) {
Button { showPayeeProfile = true } label: { Button { store.showPayeeProfile = true } label: {
Label("收款信息", systemImage: "person.crop.rectangle") Label("收款信息", systemImage: "person.crop.rectangle")
}.disabled(store.busy) }.disabled(store.busy)
} }
@@ -84,9 +91,6 @@ struct ContentView: View {
.disabled(store.busy).keyboardShortcut("o") .disabled(store.busy).keyboardShortcut("o")
} }
} }
.alert("处理未完成", isPresented: Binding(get: { store.errorMessage != nil }, set: { if !$0 { store.errorMessage = nil } })) {
Button("知道了") { store.errorMessage = nil }
} message: { Text(store.errorMessage ?? "") }
.confirmationDialog("清空当前工作区?", isPresented: $confirmClear) { .confirmationDialog("清空当前工作区?", isPresented: $confirmClear) {
Button("仅清空本次报销数据(保留模板)", role: .destructive) { store.clear() } Button("仅清空本次报销数据(保留模板)", role: .destructive) { store.clear() }
if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil { if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil {
@@ -96,26 +100,36 @@ struct ContentView: View {
} message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除。") } } message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除。") }
.sheet(item: $store.preview) { item in MaterialPreview(item: item) } .sheet(item: $store.preview) { item in MaterialPreview(item: item) }
.sheet(isPresented: $store.showExpense) { ExpenseSheet() } .sheet(isPresented: $store.showExpense) { ExpenseSheet() }
.sheet(isPresented: $showPayeeProfile) { PayeeProfileSheet(profileStore: store.payeeProfile) } .sheet(isPresented: $store.showPayeeProfile) {
PayeeProfileSheet(profileStore: store.payeeProfile, onSave: { store.notice = "收款信息已保存" })
}
.sheet(isPresented: $store.showExpenseTemplate) { ExpenseTemplateManager() } .sheet(isPresented: $store.showExpenseTemplate) { ExpenseTemplateManager() }
.task(id: store.notice) {
guard let notice = store.notice else { return }
do { try await Task.sleep(for: .seconds(8)) } catch { return }
if store.notice == notice { store.notice = nil }
}
.onChange(of: store.notice) { _, notice in
if notice == nil || notice?.contains("已导出") != true { store.lastExportURL = nil }
}
} }
private var topbar: some View { private var topbar: some View {
HStack(alignment: .center) { HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text("RECEIPT DESK").font(.caption2.weight(.semibold)).tracking(2).foregroundStyle(.secondary) Text("本地贴票").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
Text((store.page ?? .scan).rawValue).font(.title.bold()) Text((store.page ?? .scan).rawValue).font(.title2.bold())
} }
Spacer() Spacer()
VStack(alignment: .trailing, spacing: 5) { VStack(alignment: .trailing, spacing: 5) {
Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对") Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
HStack(spacing: 18) { HStack(spacing: 18) {
Text("发票 \(currency(store.state.invoiceTotal))") Label(currency(store.state.invoiceTotal), systemImage: "doc.text")
Text("付款 \(currency(store.state.paymentTotal))").foregroundStyle(.teal) Label(currency(store.state.paymentTotal), systemImage: "creditcard")
}.font(.callout.weight(.semibold)).monospacedDigit() }.font(.callout.weight(.semibold)).monospacedDigit()
} }
}.padding(.horizontal, 28).padding(.vertical, 20) }.padding(.horizontal, AppTheme.page).padding(.vertical, 16)
} }
private func count(_ number: Int) -> some View { private func count(_ number: Int) -> some View {
@@ -128,8 +142,8 @@ struct Panel<Content: View>: View {
@ViewBuilder var content: Content @ViewBuilder var content: Content
var body: some View { var body: some View {
content.padding(20).frame(maxWidth: .infinity, alignment: .leading) content.padding(20).frame(maxWidth: .infinity, alignment: .leading)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 16)) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
.overlay(RoundedRectangle(cornerRadius: 16).stroke(.primary.opacity(0.06))) .overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(AppTheme.border))
} }
} }
@@ -137,11 +151,14 @@ struct EmptyPanel: View {
var symbol: String var symbol: String
var title: String var title: String
var message: String var message: String
var actionTitle: String? = nil
var action: (() -> Void)? = nil
var body: some View { var body: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
Image(systemName: symbol).font(.system(size: 42, weight: .light)).foregroundStyle(.teal) Image(systemName: symbol).font(.system(size: 32, weight: .light)).foregroundStyle(.secondary)
Text(title).font(.title3.bold()) Text(title).font(.title3.bold())
Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) if !message.isEmpty { Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) }
if let actionTitle, let action { Button(actionTitle, action: action).buttonStyle(.borderedProminent) }
}.frame(maxWidth: .infinity, maxHeight: .infinity).padding(40) }.frame(maxWidth: .infinity, maxHeight: .infinity).padding(40)
} }
} }
+43
View File
@@ -0,0 +1,43 @@
import AppKit
import SwiftUI
@MainActor final class DockProgressView: NSView {
var progress: Double = 0
override func draw(_ dirtyRect: NSRect) {
NSApp.applicationIconImage.draw(in: bounds)
let track = NSRect(x: bounds.width * 0.12, y: bounds.height * 0.1,
width: bounds.width * 0.76, height: bounds.height * 0.08)
NSColor.black.withAlphaComponent(0.7).setFill()
NSBezierPath(roundedRect: track, xRadius: 4, yRadius: 4).fill()
NSColor.systemTeal.setFill()
let fill = NSRect(x: track.minX + 2, y: track.minY + 2,
width: max(0, track.width - 4) * min(1, max(0, progress)), height: max(1, track.height - 4))
NSBezierPath(roundedRect: fill, xRadius: 2, yRadius: 2).fill()
}
}
struct WorkspaceDockProgress: ViewModifier {
let busy: Bool
let progress: Double
@State private var dockView: DockProgressView?
func body(content: Content) -> some View {
content
.onChange(of: busy) { _, _ in update() }
.onChange(of: progress) { _, _ in update() }
.onDisappear { NSApp.dockTile.contentView = nil; NSApp.dockTile.display() }
}
private func update() {
guard busy else {
NSApp.dockTile.contentView = nil
NSApp.dockTile.display()
dockView = nil
return
}
let view = dockView ?? DockProgressView(frame: NSRect(origin: .zero, size: NSApp.dockTile.size))
view.progress = progress
view.needsDisplay = true
dockView = view
NSApp.dockTile.contentView = view
NSApp.dockTile.display()
}
}
+77 -20
View File
@@ -16,20 +16,21 @@ struct ExpenseTemplateSummary: View {
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
AppBadge(title: "模板", symbol: "doc.text", tone: .information)
Button(store.expenseTemplate == nil ? "选择模板…" : "查看 / 更换…", action: manage) Button(store.expenseTemplate == nil ? "选择模板…" : "查看 / 更换…", action: manage)
.disabled(store.busy) .disabled(store.busy)
} }
.padding(14).background(.teal.opacity(targeted && !store.busy ? 0.12 : 0.05), in: RoundedRectangle(cornerRadius: 10)) .padding(16).background(targeted && !store.busy ? AppTheme.accent.opacity(0.12) : AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
.overlay { .overlay {
RoundedRectangle(cornerRadius: 10) RoundedRectangle(cornerRadius: AppTheme.radius)
.strokeBorder(.teal.opacity(targeted && !store.busy ? 1 : 0.2), style: StrokeStyle(lineWidth: 1, dash: [5, 4])) .strokeBorder(targeted && !store.busy ? AppTheme.accent : AppTheme.border, lineWidth: 1)
.allowsHitTesting(false) .allowsHitTesting(false)
} }
.dropDestination(for: URL.self) { sources, _ in .dropDestination(for: URL.self) { sources, _ in
guard !store.busy else { return false } guard !store.busy else { return false }
manage() manage()
return store.importExpenseTemplates(sources) return store.importExpenseTemplates(sources)
} isTargeted: { targeted = $0 } } isTargeted: { value in withAnimation(.easeInOut(duration: 0.15)) { targeted = value } }
} }
} }
@@ -70,7 +71,7 @@ struct ExpenseTemplateManager: View {
} }
.dropDestination(for: URL.self) { sources, _ in .dropDestination(for: URL.self) { sources, _ in
store.importExpenseTemplates(sources) store.importExpenseTemplates(sources)
} isTargeted: { targeted = $0 } } isTargeted: { value in withAnimation(.easeInOut(duration: 0.15)) { targeted = value } }
if store.busy { if store.busy {
HStack { ProgressView().controlSize(.small); Text(store.status) }.font(.callout) HStack { ProgressView().controlSize(.small); Text(store.status) }.font(.callout)
} }
@@ -92,12 +93,12 @@ struct ExpenseTemplateManager: View {
description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。")) description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。"))
} }
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white, in: RoundedRectangle(cornerRadius: 10)) .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8))
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
Label("当前模板", systemImage: "checkmark.circle.fill").foregroundStyle(.teal) Label("当前模板", systemImage: "checkmark.circle.fill").foregroundStyle(AppTheme.success)
Text(store.expenseTemplateName).font(.headline).textSelection(.enabled) Text(store.expenseTemplateName).font(.headline).textSelection(.enabled)
if let template = store.expenseTemplate { if let template = store.expenseTemplate {
TemplateMappingSummary(mapping: template.mapping) TemplateMappingSummary(mapping: template.mapping)
@@ -120,10 +121,11 @@ struct ExpenseTemplateManager: View {
} }
} }
} }
.padding(22).frame(width: 1000, height: 700) .padding(AppTheme.page).frame(width: AppTheme.sheetSize(width: 1100, height: 760).width,
height: AppTheme.sheetSize(width: 1100, height: 760).height)
.interactiveDismissDisabled(store.busy || store.templateDraft != nil) .interactiveDismissDisabled(store.busy || store.templateDraft != nil)
.confirmationDialog("恢复内置报销单模板?", isPresented: $confirmRemove) { .confirmationDialog("恢复内置报销单模板?", isPresented: $confirmRemove) {
Button("恢复内置模板", role: .destructive, action: store.removeExpenseTemplate) Button("移除本机模板副本", role: .destructive, action: store.removeExpenseTemplate)
Button("取消", role: .cancel) {} Button("取消", role: .cancel) {}
} message: { Text("仅移除本机模板副本,不影响本次报销数据、收款资料或原始 Excel。") } } message: { Text("仅移除本机模板副本,不影响本次报销数据、收款资料或原始 Excel。") }
.task(id: store.expenseTemplate?.fileName) { savedFile = store.expenseTemplateFile } .task(id: store.expenseTemplate?.fileName) { savedFile = store.expenseTemplateFile }
@@ -163,7 +165,7 @@ private struct ExpenseTemplateEditor: View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("原始模板预览").font(.caption).foregroundStyle(.secondary) Text("原始模板预览").font(.caption).foregroundStyle(.secondary)
SpreadsheetFilePreview(url: draft.source, preferredSheet: mapping.sheetName).id(draft.source) SpreadsheetFilePreview(url: draft.source, preferredSheet: mapping.sheetName).id(draft.source)
.background(.white).clipShape(RoundedRectangle(cornerRadius: 10)) .background(AppTheme.surface).clipShape(RoundedRectangle(cornerRadius: 8))
Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。") Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -184,9 +186,9 @@ private struct ExpenseTemplateEditor: View {
Divider() Divider()
Text("请核对上面的填写位置。使用后,明细范围内的对应列及收款字段会被替换;未指定位置的内容会保留。") Text("请核对上面的填写位置。使用后,明细范围内的对应列及收款字段会被替换;未指定位置的内容会保留。")
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
if mapping.columns["purpose"] == nil || mapping.columns["amount"] == nil { if !TemplateMappingValidation.issues(mapping).isEmpty {
Label("尚未识别完整,请先调整填写位置。", systemImage: "exclamationmark.triangle") AppNotice(text: TemplateMappingValidation.issues(mapping).joined(separator: ""), tone: .warning,
.font(.caption).foregroundStyle(.orange) actionTitle: "调整", action: { showFields = true })
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -194,12 +196,12 @@ private struct ExpenseTemplateEditor: View {
Divider() Divider()
Button("确认位置,使用此模板") { store.saveExpenseTemplate(mapping) } Button("确认位置,使用此模板") { store.saveExpenseTemplate(mapping) }
.buttonStyle(.borderedProminent).controlSize(.large) .buttonStyle(.borderedProminent).controlSize(.large)
.disabled(mapping.columns["purpose", default: ""].isEmpty || mapping.columns["amount", default: ""].isEmpty) .disabled(!TemplateMappingValidation.issues(mapping).isEmpty)
Button("放弃更换") { store.discardTemplateDraft(); store.templateMessage = nil } Button("放弃更换") { store.discardTemplateDraft(); store.templateMessage = nil }
}.frame(width: 285, alignment: .leading) }.frame(width: 285, alignment: .leading)
} }
.sheet(isPresented: $showFields) { .sheet(isPresented: $showFields) {
ExpenseTemplateFields(mapping: $mapping, inspection: draft.inspection) ExpenseTemplateFields(mapping: $mapping, inspection: draft.inspection, source: draft.source)
} }
} }
} }
@@ -207,7 +209,12 @@ private struct ExpenseTemplateEditor: View {
private struct ExpenseTemplateFields: View { private struct ExpenseTemplateFields: View {
@Binding var mapping: ExpenseTemplateMapping @Binding var mapping: ExpenseTemplateMapping
let inspection: ExpenseTemplateInspection let inspection: ExpenseTemplateInspection
let source: URL
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@FocusState private var focusedField: String?
@State private var activeField = "column:purpose"
@State private var selectedReference: String?
@State private var hoveredRow: Int?
private let detailFields = [("purpose", "用途 *"), ("amount", "金额 *"), ("count", "单据数量"), private let detailFields = [("purpose", "用途 *"), ("amount", "金额 *"), ("count", "单据数量"),
("invoiceType", "票据类型"), ("sequence", "序号"), ("category", "费用类别"), ("remarks", "备注(清空旧值)")] ("invoiceType", "票据类型"), ("sequence", "序号"), ("category", "费用类别"), ("remarks", "备注(清空旧值)")]
private let cells = [("recipient", "收款人"), ("bankName", "开户行"), ("accountNumber", "账号"), private let cells = [("recipient", "收款人"), ("bankName", "开户行"), ("accountNumber", "账号"),
@@ -218,47 +225,67 @@ private struct ExpenseTemplateFields: View {
HStack { HStack {
Text("调整填写位置").font(.title2.bold()) Text("调整填写位置").font(.title2.bold())
Spacer() Spacer()
Button("关闭") { dismiss() }.keyboardShortcut(.cancelAction)
Button("完成") { dismiss() }.keyboardShortcut(.defaultAction) Button("完成") { dismiss() }.keyboardShortcut(.defaultAction)
.disabled(!TemplateMappingValidation.issues(mapping).isEmpty)
} }
Text("仅格式识别不正确时需要调整。列填字母(如 C),单元格填地址(如 C23),合并区域填左上角。") Text("仅格式识别不正确时需要调整。列填字母(如 C),单元格填地址(如 C23),合并区域填左上角。")
.font(.callout).foregroundStyle(.secondary) .font(.callout).foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 20) {
SpreadsheetFilePreview(url: source, preferredSheet: mapping.sheetName,
selectedReference: selectedReference, highlightedRow: hoveredRow,
onSelectCell: selectCell)
.frame(minWidth: 300, maxWidth: .infinity, maxHeight: .infinity)
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
HStack { HStack {
Text("明细开始行") Text("明细开始行")
TextField("9", value: $mapping.startRow, format: .number.grouping(.never)).frame(width: 70) TextField("9", value: $mapping.startRow, format: .number.grouping(.never)).frame(width: 55).focused($focusedField, equals: "start")
Text("结束行") Text("结束行")
TextField("21", value: $mapping.endRow, format: .number.grouping(.never)).frame(width: 70) TextField("21", value: $mapping.endRow, format: .number.grouping(.never)).frame(width: 55).focused($focusedField, equals: "end")
} }
Text("包括全部旧明细,不包含表头及合计;容量不足时自动复制报销工作表分页。") Text("包括全部旧明细,不包含表头及合计;容量不足时自动复制报销工作表分页。")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
Text("明细列").font(.headline) Text("明细列").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach(detailFields, id: \.0) { key, title in ForEach(detailFields, id: \.0) { key, title in
HStack { HStack {
Text(title).frame(width: 120, alignment: .leading) Text(title).frame(width: 120, alignment: .leading)
.foregroundStyle(activeField == "column:" + key ? AppTheme.accent : .primary)
TextField("不填充", text: Binding(get: { mapping.columns[key] ?? "" }, TextField("不填充", text: Binding(get: { mapping.columns[key] ?? "" },
set: { mapping.columns[key] = $0.uppercased() })) set: { mapping.columns[key] = $0.uppercased() }))
.accessibilityLabel(title + "") .accessibilityLabel(title + "")
.focused($focusedField, equals: "column:" + key)
if (key == "purpose" || key == "amount" || !(mapping.columns[key] ?? "").isEmpty) &&
!TemplateMappingValidation.column(mapping.columns[key, default: ""]) {
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(AppTheme.warning).help("请输入 AIV 之间的列字母")
}
} }
} }
} }
Text("收款与合计").font(.headline) Text("收款与合计").font(.headline)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
ForEach(cells, id: \.0) { key, title in ForEach(cells, id: \.0) { key, title in
HStack { HStack {
Text(title).frame(width: 120, alignment: .leading) Text(title).frame(width: 120, alignment: .leading)
.foregroundStyle(activeField == "cell:" + key ? AppTheme.accent : .primary)
TextField("不填充", text: Binding(get: { mapping.cells[key] ?? "" }, TextField("不填充", text: Binding(get: { mapping.cells[key] ?? "" },
set: { mapping.cells[key] = $0.uppercased() })) set: { mapping.cells[key] = $0.uppercased() }))
.accessibilityLabel(title + "单元格") .accessibilityLabel(title + "单元格")
.focused($focusedField, equals: "cell:" + key)
if let value = mapping.cells[key], !value.isEmpty, !TemplateMappingValidation.address(value) {
Image(systemName: "exclamationmark.circle.fill").foregroundStyle(AppTheme.warning).help("请输入有效单元格地址,例如 C23")
}
} }
} }
} }
LabeledContent("签字岗位") { LabeledContent("签字岗位") {
TextField("例如 A30,D30,A31;最多 6 项", text: $mapping.signatureCells) TextField("例如 A30,D30,A31;最多 6 项", text: $mapping.signatureCells)
.focused($focusedField, equals: "signature")
} }
LabeledContent("额外清空") { LabeledContent("额外清空") {
TextField("例如 C27,I27;多个地址用逗号分隔", text: $mapping.clearCells) TextField("例如 C27,I27;多个地址用逗号分隔", text: $mapping.clearCells)
.focused($focusedField, equals: "clear")
} }
Text("收款位置用本机资料替换,未保存资料则清空;签字岗位按导出弹窗中的顺序填入。留空的配置不改动模板原文。") Text("收款位置用本机资料替换,未保存资料则清空;签字岗位按导出弹窗中的顺序填入。留空的配置不改动模板原文。")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
@@ -266,10 +293,40 @@ private struct ExpenseTemplateFields: View {
ForEach(Array((inspection.sheets.first(where: { $0.name == mapping.sheetName })?.preview ?? []).enumerated()), id: \.offset) { _, row in ForEach(Array((inspection.sheets.first(where: { $0.name == mapping.sheetName })?.preview ?? []).enumerated()), id: \.offset) { _, row in
Text(row).font(.caption.monospaced()).textSelection(.enabled) Text(row).font(.caption.monospaced()).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 3)
.onHover { inside in
hoveredRow = inside ? Int(row.prefix(while: \.isNumber)) : nil
}
} }
} }
}.padding(.trailing, 8) }.padding(.trailing, 8)
}.frame(width: 360)
}
if !TemplateMappingValidation.issues(mapping).isEmpty {
AppNotice(text: "还需调整 \(TemplateMappingValidation.issues(mapping).count) 项:" +
TemplateMappingValidation.issues(mapping).joined(separator: ""), tone: .warning)
}
}.padding(AppTheme.page)
.frame(width: AppTheme.sheetSize(width: 1200, height: 760).width,
height: AppTheme.sheetSize(width: 1200, height: 760).height).textFieldStyle(.roundedBorder)
.onChange(of: focusedField) { _, field in if let field { activeField = field } }
}
private func selectCell(_ reference: String) {
selectedReference = reference
if activeField.hasPrefix("column:") {
mapping.columns[String(activeField.dropFirst(7))] = String(reference.prefix(while: \.isLetter))
} else if activeField.hasPrefix("cell:") {
mapping.cells[String(activeField.dropFirst(5))] = reference
} else if activeField == "start" {
mapping.startRow = Int(reference.filter(\.isNumber)) ?? mapping.startRow
} else if activeField == "end" {
mapping.endRow = Int(reference.filter(\.isNumber)) ?? mapping.endRow
} else {
let previous = activeField == "signature" ? mapping.signatureCells : mapping.clearCells
var values = TemplateMappingValidation.references(previous)
if !values.contains(reference) { values.append(reference) }
if activeField == "signature" { mapping.signatureCells = values.joined(separator: ",") }
else { mapping.clearCells = values.joined(separator: ",") }
} }
}.padding(24).frame(width: 750, height: 600).textFieldStyle(.roundedBorder)
} }
} }
+276
View File
@@ -0,0 +1,276 @@
import SwiftUI
struct MatchedCategoryBoard: View {
@EnvironmentObject var store: WorkspaceStore
let columns: [MatchCategoryGroup]
@Binding var selection: MatchedPPTSelection
@State private var inspected: MatchGroup?
var body: some View {
GeometryReader { geometry in
let width = MatchedBoardLayout.columnWidth(available: geometry.size.width - MatchedBoardLayout.scrollGutter,
count: columns.count)
let contentWidth = width * CGFloat(columns.count) + CGFloat(max(0, columns.count - 1)) * MatchedBoardLayout.gap
let rowCount = columns.map(\.matches.count).max() ?? 0
ScrollView(.horizontal) {
VStack(alignment: .leading, spacing: 0) {
HStack(spacing: MatchedBoardLayout.gap) {
ForEach(columns) { column in
categoryHeader(column).frame(width: width, height: MatchedBoardLayout.headerHeight)
}
}
Divider()
ScrollView(.vertical) {
LazyVStack(alignment: .leading, spacing: MatchedBoardLayout.gap) {
ForEach(0..<rowCount, id: \.self) { row in
HStack(alignment: .top, spacing: MatchedBoardLayout.gap) {
ForEach(columns) { column in
Group {
if column.matches.indices.contains(row) {
let match = column.matches[row]
MatchedReceiptTile(match: match, selection: $selection) { inspected = match }
} else if row == 0 {
Text("暂无").font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.padding(.top, 24)
} else {
Color.clear
}
}.frame(width: width, height: MatchedBoardLayout.tileHeight)
}
}
}
}.padding(.vertical, 10)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
}.frame(width: contentWidth + MatchedBoardLayout.scrollGutter, height: max(1, geometry.size.height - 14))
}.scrollIndicators(.visible)
}.frame(minHeight: 220)
.overlay(alignment: .bottom) { Divider() }
.sheet(item: $inspected) { match in
MatchedGroupInspector(matchID: match.id, orderedIDs: columns.flatMap(\.matches).map(\.id),
selection: $selection) { id in
inspected = store.state.matches.first { $0.id == id }
}
}
}
private func categoryHeader(_ column: MatchCategoryGroup) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Toggle("全选\(column.title)列当前结果", isOn: Binding(
get: { selection.containsAll(column.matches) },
set: { selection.setVisible(column.matches, selected: $0) }
)).toggleStyle(.checkbox).labelsHidden().disabled(column.matches.isEmpty)
.help("全选或取消本列当前结果")
Text(column.title).font(.callout.weight(.semibold)).lineLimit(1).help(column.title)
Spacer(minLength: 0)
Text("\(column.matches.count)").font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
Text(currency(column.invoiceTotal)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
.lineLimit(1).minimumScaleFactor(0.8)
.help("发票合计 \(currency(column.invoiceTotal)) · 付款合计 \(currency(column.paymentTotal))")
}.padding(.horizontal, 6).frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .contain)
.accessibilityLabel("\(column.title)\(column.matches.count)")
}
}
private struct MatchedReceiptTile: View {
@EnvironmentObject var store: WorkspaceStore
let match: MatchGroup
@Binding var selection: MatchedPPTSelection
let inspect: () -> Void
@State private var hovered = false
private var selected: Bool { selection.ids.contains(match.id) }
private var number: Int { store.presentation.matchNumbers[match.id, default: 1] }
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 5) {
Toggle("选择第 \(number)", isOn: Binding(
get: { selected },
set: { if $0 { selection.ids.insert(match.id) } else { selection.ids.remove(match.id) } }
)).toggleStyle(.checkbox).labelsHidden()
Text(String(format: "%02d", number)).font(.caption2.monospacedDigit()).foregroundStyle(.secondary)
Spacer(minLength: 0)
Image(systemName: match.needsAttention ? "exclamationmark.circle.fill" :
match.matchType == "auto" ? "checkmark.seal" : "person.crop.circle")
.font(.caption).foregroundStyle(match.needsAttention ? AppTheme.warning : .secondary)
.help(match.needsAttention ? "需关注,点击凭证查看核对依据" : match.matchType == "auto" ? "自动核对 \(match.evidenceLabel)" : "人工确认")
}
Button(action: inspect) {
VStack(alignment: .leading, spacing: 5) {
HStack(spacing: 4) {
thumbnail(match.invoices.first, symbol: "doc.text")
thumbnail(match.payments.first, symbol: "creditcard")
}.frame(height: 52).accessibilityHidden(true)
Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal))
.font(.headline).monospacedDigit().foregroundStyle(.primary)
.lineLimit(1).minimumScaleFactor(0.75)
Text(match.date.isEmpty || match.date == "9999-12-31" ? "未识别日期" : match.date)
.font(.caption2.monospacedDigit()).foregroundStyle(.secondary).lineLimit(1)
HStack(spacing: 8) {
Label("\(match.invoices.count)", systemImage: "doc.text")
Label("\(match.payments.count)", systemImage: "creditcard")
Spacer(minLength: 0)
Image(systemName: "arrow.up.left.and.arrow.down.right")
}.font(.caption2).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle())
}.buttonStyle(.plain).help("查看本组凭证")
.accessibilityLabel("\(number) 组,\(match.category.isEmpty ? "未分类" : match.category)\(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)),查看 \(match.invoices.count) 张发票和 \(match.payments.count) 张付款凭证")
}.padding(8).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(selected ? AppTheme.accent.opacity(0.08) : hovered ? Color.primary.opacity(0.035) : AppTheme.surface,
in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(selected ? AppTheme.accent : AppTheme.border, lineWidth: selected ? 1.5 : 1))
.onHover { hovered = $0 }
.contextMenu {
Button("查看凭证", systemImage: "arrow.up.left.and.arrow.down.right", action: inspect)
Menu("修改类型") {
ForEach(expenseCategories, id: \.self) { category in
Button(category) { store.updateCategory(match, category: category) }
}
}.disabled(store.busy)
Button("撤销配对", systemImage: "arrow.uturn.backward") { store.undo(match) }.disabled(store.busy)
}
}
@ViewBuilder private func thumbnail(_ item: Material?, symbol: String) -> some View {
if let item {
MaterialThumbnail(item: item, height: 52, pixels: 240)
} else {
Image(systemName: symbol).foregroundStyle(.tertiary)
.frame(maxWidth: .infinity).frame(height: 52).background(AppTheme.canvas)
}
}
}
struct MatchedGroupInspector: View {
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss
let matchID: String
let orderedIDs: [String]
@Binding var selection: MatchedPPTSelection
let navigate: (String) -> Void
@State private var preview: Material?
@State private var hoveredMaterialID: String?
private var match: MatchGroup? { store.state.matches.first { $0.id == matchID } }
private var position: Int? { orderedIDs.firstIndex(of: matchID) }
var body: some View {
VStack(spacing: 16) {
if let match {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 4) {
Text("\(store.presentation.matchNumbers[match.id, default: 1]) 组凭证").font(.title2.bold())
Text(match.date.isEmpty || match.date == "9999-12-31" ? "未识别日期" : match.date)
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Button { move(-1) } label: { Image(systemName: "chevron.left") }
.disabled(position == nil || position == 0).help("上一组")
Button { move(1) } label: { Image(systemName: "chevron.right") }
.disabled(position == nil || position == orderedIDs.count - 1).help("下一组")
Button { dismiss() } label: { Image(systemName: "xmark") }
.keyboardShortcut(.cancelAction).help("关闭凭证详情")
}
HStack(spacing: 24) {
amount("发票合计", value: match.invoiceTotal)
amount("付款合计", value: match.paymentTotal)
Spacer()
MatchConfidenceCell(match: match)
Picker("类型", selection: Binding(get: { match.category }, set: { store.updateCategory(match, category: $0) })) {
ForEach(expenseCategories + (expenseCategories.contains(match.category) ? [] : [match.category]), id: \.self) {
Text($0.isEmpty ? "未分类" : $0).tag($0)
}
}.frame(width: 170).disabled(store.busy)
}
Divider()
HStack(alignment: .top, spacing: 20) {
materials("发票", items: match.invoices, symbol: "doc.text")
Divider()
materials("付款凭证", items: match.payments, symbol: "creditcard")
}.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
HStack {
Button {
dismiss()
store.undo(match)
} label: { Label("撤销配对", systemImage: "arrow.uturn.backward") }.disabled(store.busy)
Spacer()
Toggle("加入导出选择", isOn: Binding(
get: { selection.ids.contains(match.id) },
set: { if $0 { selection.ids.insert(match.id) } else { selection.ids.remove(match.id) } }
)).toggleStyle(.checkbox)
Button("完成") { dismiss() }.buttonStyle(.borderedProminent)
}
} else {
EmptyPanel(symbol: "doc.badge.exclamationmark", title: "此组材料已撤销", message: "",
actionTitle: "关闭") { dismiss() }
}
}.padding(AppTheme.page)
.frame(width: AppTheme.sheetSize(width: 1040, height: 740).width,
height: AppTheme.sheetSize(width: 1040, height: 740).height)
.sheet(item: $preview) { MaterialPreview(item: $0) }
}
private func move(_ offset: Int) {
guard let position, orderedIDs.indices.contains(position + offset) else { return }
navigate(orderedIDs[position + offset])
}
private func amount(_ title: String, value: Double) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(currency(value)).font(.headline).monospacedDigit()
}
}
private func materials(_ title: String, items: [Material], symbol: String) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Label(title, systemImage: symbol).font(.headline)
Spacer()
Text("\(items.count)").font(.caption).foregroundStyle(.secondary)
}
ScrollView {
LazyVStack(spacing: 16) {
ForEach(items) { item in
Button { preview = item } label: {
MaterialThumbnail(item: item, height: 240)
.frame(maxWidth: .infinity)
.contentShape(Rectangle())
}.buttonStyle(.plain)
.overlay(alignment: .bottom) {
if hoveredMaterialID == item.id {
Text(item.name)
.font(.caption).foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
.padding(.horizontal, 10).padding(.vertical, 6)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 6))
.overlay(RoundedRectangle(cornerRadius: 6).stroke(AppTheme.border))
.padding(8)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
}
.onHover { hovering in
if hovering {
hoveredMaterialID = item.id
} else if hoveredMaterialID == item.id {
hoveredMaterialID = nil
}
}
.onDisappear {
if hoveredMaterialID == item.id { hoveredMaterialID = nil }
}
.accessibilityLabel("放大预览 \(item.name)")
}
if items.isEmpty {
Text("没有\(title)").font(.callout).foregroundStyle(.secondary).padding(.vertical, 40)
}
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}
+148 -149
View File
@@ -2,165 +2,112 @@ 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() @State private var pptSelection = MatchedPPTSelection()
@State private var query = ""
@State private var attentionOnly = false
@State private var order: MatchOrder = .date
@State private var descending = false
@State private var showExportHelp = false
@FocusState private var searchFocused: Bool
private var selectedMatches: [MatchGroup] { private var selectedMatches: [MatchGroup] {
store.state.matches.filter { pptSelection.ids.contains($0.id) } store.state.matches.filter { pptSelection.ids.contains($0.id) }
} }
private var groups: [String] { private var visibleMatches: [MatchGroup] {
["全部"] + Array(Set(store.state.matches.map(\.category))).sorted() WorkspaceFilter.matches(store.presentation.sortedMatches, query: query, category: "全部",
attentionOnly: attentionOnly, order: order, descending: descending)
} }
var body: some View { var body: some View {
VStack(spacing: 18) { let matches = visibleMatches
HStack { let selected = selectedMatches
VStack(alignment: .leading, spacing: 4) { let columns = store.presentation.categoryColumns(for: matches)
Text("已核对材料").font(.title2.bold()) VStack(spacing: 12) {
Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary) HStack(spacing: 12) {
} AppSearchField(placeholder: "金额、商户、日期或文件名", text: $query).focused($searchFocused)
Spacer() Toggle("需关注 \(store.presentation.attentionCount)", isOn: $attentionOnly)
Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0) .toggleStyle(.checkbox).fixedSize()
Button { store.openExpense(selectedMatchIDs: pptSelection.ids) } label: { Picker("排序", selection: $order) {
Label("所选报销单", systemImage: "tablecells") ForEach(MatchOrder.allCases) { Text($0.rawValue).tag($0) }
}.disabled(selectedMatches.isEmpty) }.labelsHidden().frame(width: 100).help("列内排序")
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: { Button { descending.toggle() } label: {
Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up") Image(systemName: descending ? "arrow.down" : "arrow.up")
} }.help(descending ? "降序,点击改为升序" : "升序,点击改为降序")
.buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty)
} }
Group {
if store.state.matches.isEmpty { if store.state.matches.isEmpty {
EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。") EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "",
actionTitle: "前往人工配对") { store.page = .pair }
} else if matches.isEmpty {
EmptyPanel(symbol: "magnifyingglass", title: "没有符合筛选的材料", message: "",
actionTitle: "清除筛选") { query = ""; attentionOnly = false }
} else { } else {
Picker("分组", selection: $selectedGroup) { MatchedCategoryBoard(columns: columns, selection: $pptSelection)
ForEach(groups, id: \.self) { group in }
Text(group == "全部" ? "全部(\(store.state.matches.count)" : "\(group)\(count(for: group))") }.frame(maxWidth: .infinity, minHeight: 220, maxHeight: .infinity)
.tag(group) ViewThatFits(in: .horizontal) {
HStack(spacing: 16) {
selectionSummary(matches: matches, selected: selected)
Spacer(minLength: 12)
exportActions
}
VStack(alignment: .leading, spacing: 12) {
selectionSummary(matches: matches, selected: selected)
HStack { Spacer(); exportActions }
} }
} }
.pickerStyle(.segmented) }.padding(AppTheme.page)
.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("已选 \(selectedMatches.count) 组 · 发票合计 \(currency(selectedMatches.reduce(0) { $0 + $1.invoiceTotal }))")
.monospacedDigit()
Spacer()
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)
Text("PPT 使用竖版 3:4:每页最多两张发票,付款续页最多六张,实物照片留空供手动粘贴。")
.font(.caption).foregroundStyle(.secondary)
}
}.padding(28).disabled(store.busy)
.onChange(of: store.state.matches.map(\.id)) { _, identifiers in .onChange(of: store.state.matches.map(\.id)) { _, identifiers in
pptSelection.retain(identifiers) pptSelection.retain(identifiers)
} }
.onReceive(NotificationCenter.default.publisher(for: WorkspaceCommand.find)) { _ in searchFocused = true }
} }
private func matches(for group: String) -> [MatchGroup] { private func selectionSummary(matches: [MatchGroup], selected: [MatchGroup]) -> some View {
let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group } HStack(spacing: 12) {
return matches Toggle("全选当前结果", isOn: Binding(
} get: { pptSelection.containsAll(matches) },
set: { pptSelection.setVisible(matches, selected: $0) }
private func count(for group: String) -> Int { )).toggleStyle(.checkbox).labelsHidden().disabled(matches.isEmpty).help("全选当前结果")
matches(for: group).count VStack(alignment: .leading, spacing: 3) {
} Text(selected.isEmpty ? "\(matches.count)" : "已选 \(selected.count)")
.font(.callout.weight(.medium))
private func baseIndex(for group: String) -> Int { Text("\(selected.isEmpty ? "当前发票" : "所选发票") \(currency((selected.isEmpty ? matches : selected).reduce(0) { $0 + $1.invoiceTotal }))")
guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 }
return first + 1
}
}
struct MatchGroupPage: View {
@EnvironmentObject var store: WorkspaceStore
let title: String
let matches: [MatchGroup]
let baseIndex: Int
@Binding var classified: Bool
@Binding var selection: MatchedPPTSelection
var body: some View {
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) .font(.caption).foregroundStyle(.secondary)
Spacer() }.monospacedDigit().fixedSize()
Text("按发票日期排列").font(.caption).foregroundStyle(.secondary) let hidden = pptSelection.ids.subtracting(Set(matches.map(\.id))).count
if hidden > 0 { Text("\(hidden) 组在筛选外").font(.caption).foregroundStyle(.secondary).fixedSize() }
if !selected.isEmpty {
Button { pptSelection.ids.removeAll() } label: { Image(systemName: "xmark.circle") }
.buttonStyle(.plain).help("清空选择").accessibilityLabel("清空选择")
} }
if matches.isEmpty { if !query.isEmpty || attentionOnly {
EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。") Button("清除筛选") { query = ""; attentionOnly = false }.buttonStyle(.borderless)
} 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
MatchConfidenceCell(match: match)
}.width(min: 160, ideal: 195)
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)
}
.frame(minHeight: 330)
} }
} }
} }
private func index(of match: MatchGroup) -> Int { private var exportActions: some View {
guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex } HStack(spacing: 10) {
return baseIndex + index Menu {
Button("导出全部行程 Excel", systemImage: "tram") { store.export(.travel) }
.disabled(store.busy || store.state.travelCount == 0)
Divider()
Button("导出说明", systemImage: "questionmark.circle") { showExportHelp = true }
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize().help("其他导出与说明")
.popover(isPresented: $showExportHelp) {
Text("跨类型和筛选保留勾选。每列表头的全选只作用于该列当前结果。PPT 和报销单仅导出所选材料,报销单按费用类型合并;行程 Excel 使用全部已核对材料。\n\nPPT 为竖版 3:4,实物照片留空。列内排序不改变原有导出顺序。")
.font(.callout).padding(20).frame(width: 360)
}
Button { store.openExpense(selectedMatchIDs: pptSelection.ids) } label: {
Label("报销单", systemImage: "tablecells")
}.disabled(store.busy || pptSelection.ids.isEmpty)
Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: {
Label("导出 PPT", systemImage: "square.and.arrow.up")
}.buttonStyle(.borderedProminent).disabled(store.busy || pptSelection.ids.isEmpty)
}
} }
} }
@@ -172,22 +119,22 @@ struct MatchConfidenceCell: View {
if match.matchType == "auto" { if match.matchType == "auto" {
Button { showExplanation = true } label: { Button { showExplanation = true } label: {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 4) { AppBadge(title: "自动 · \(match.evidenceLabel)",
Text("自动 · \(match.evidenceLabel)").monospacedDigit() symbol: match.needsAttention ? "exclamationmark.circle" : "checkmark.seal",
Image(systemName: "info.circle").font(.caption) tone: match.needsAttention ? .warning : .success)
}.foregroundStyle((match.evidenceScore ?? 0) >= 90 ? .teal : .orange)
Text(match.explanation?.summary ?? "查看匹配依据") Text(match.explanation?.summary ?? "查看匹配依据")
.font(.caption).foregroundStyle(.secondary).lineLimit(1) .font(.caption).foregroundStyle(.secondary).lineLimit(1)
}.padding(.vertical, 4) }.padding(.vertical, 4)
} }
.buttonStyle(.plain) .buttonStyle(.plain)
.help((match.explanation?.summary ?? "查看匹配依据") + ";点击查看详细得分和核对建议") .help((match.explanation?.summary ?? "查看匹配依据") + ";点击查看详细得分和核对建议")
.accessibilityLabel("自动匹配证据评分 \(match.evidenceLabel),查看原因") .accessibilityLabel("自动匹配证据评分 \(match.evidenceLabel)\(match.needsAttention ? "需关注" : "评分高")查看原因")
.popover(isPresented: $showExplanation) { .popover(isPresented: $showExplanation) {
MatchExplanationPopover(match: match) MatchExplanationPopover(match: match)
} }
} else { } else {
Text("人工确认").foregroundStyle(.blue).help("此组由人工确认,不使用自动评分") AppBadge(title: "人工确认", symbol: "person.crop.circle.badge.checkmark", tone: .information)
.help("此组由人工确认,不使用自动评分")
} }
} }
} }
@@ -230,7 +177,7 @@ struct MatchExplanationPopover: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack { HStack {
Image(systemName: item.points == item.maximum ? "checkmark.circle.fill" : "exclamationmark.circle") Image(systemName: item.points == item.maximum ? "checkmark.circle.fill" : "exclamationmark.circle")
.foregroundStyle(item.points == item.maximum ? .teal : .orange) .foregroundStyle(item.points == item.maximum ? AppTheme.success : AppTheme.warning)
Text(item.title).fontWeight(.medium) Text(item.title).fontWeight(.medium)
Spacer() Spacer()
Text("\(item.points) / \(item.maximum)").monospacedDigit() Text("\(item.points) / \(item.maximum)").monospacedDigit()
@@ -263,18 +210,36 @@ struct MatchExplanationPopover: View {
struct MaterialCell: View { struct MaterialCell: View {
let items: [Material] let items: [Material]
@ObservedObject var store: WorkspaceStore @ObservedObject var store: WorkspaceStore
@State private var expanded = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text("\(items.count)").font(.caption.weight(.medium)) Button { expanded = true } label: {
Label("\(items.count)", systemImage: items.count > 1 ? "square.stack" : "doc")
}.buttonStyle(.borderless).font(.caption.weight(.medium)).help("查看全部材料")
ForEach(items.prefix(2)) { item in ForEach(items.prefix(2)) { item in
Button(item.name) { store.preview = item } Button(item.name) { store.preview = item }
.buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)") .buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)")
} }
if items.count > 2 { if items.count > 2 {
Text("还有 \(items.count - 2)").font(.caption).foregroundStyle(.secondary) Button("+\(items.count - 2)") { expanded = true }
.buttonStyle(.borderless).font(.caption).help("查看其余材料")
} }
}.padding(.vertical, 4) }.padding(.vertical, 4)
.popover(isPresented: $expanded) {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 170))], spacing: 12) {
ForEach(items) { item in
Button { expanded = false; store.preview = item } label: {
VStack(alignment: .leading, spacing: 8) {
MaterialThumbnail(item: item, height: 150)
Text(item.name).font(.caption).lineLimit(2)
}
}.buttonStyle(.plain).help("预览 \(item.name)")
}
}.padding(16)
}.frame(width: 420, height: min(500, CGFloat((items.count + 1) / 2) * 210))
}
} }
} }
@@ -282,8 +247,9 @@ struct ExpenseSheet: View {
@EnvironmentObject var store: WorkspaceStore @EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showTemplate = false @State private var showTemplate = false
@State private var removedSignature: (index: Int, value: String)?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text("个人报销单").font(.title2.bold()) Text("个人报销单").font(.title2.bold())
@@ -292,9 +258,10 @@ struct ExpenseSheet: View {
} }
Spacer() Spacer()
} }
ScrollView {
VStack(alignment: .leading, spacing: 16) {
PayeeProfileSummary(profileStore: store.payeeProfile) PayeeProfileSummary(profileStore: store.payeeProfile)
ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true } ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true }
ScrollView {
VStack(spacing: 12) { VStack(spacing: 12) {
ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in
HStack(spacing: 16) { HStack(spacing: 16) {
@@ -304,10 +271,14 @@ struct ExpenseSheet: View {
Text("\(summary.groupCount) 组 · \(summary.invoiceCount) 张单据").font(.caption).foregroundStyle(.secondary) Text("\(summary.groupCount) 组 · \(summary.invoiceCount) 张单据").font(.caption).foregroundStyle(.secondary)
}.frame(width: 120, alignment: .leading) }.frame(width: 120, alignment: .leading)
Text(currency(summary.amountValue)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing) Text(currency(summary.amountValue)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing)
VStack(alignment: .leading, spacing: 4) {
TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 })) TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 }))
}.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8)) if store.purposes[summary.category]?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true {
Text("将使用“\(summary.category)").font(.caption).foregroundStyle(.secondary)
} }
} }
}.padding(10).background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
}
} }
HStack { HStack {
Text("合计 \(currency(NSDecimalNumber(decimal: store.expenseRows.reduce(.zero) { $0 + $1.amount }).doubleValue))").fontWeight(.semibold).monospacedDigit() Text("合计 \(currency(NSDecimalNumber(decimal: store.expenseRows.reduce(.zero) { $0 + $1.amount }).doubleValue))").fontWeight(.semibold).monospacedDigit()
@@ -315,20 +286,42 @@ struct ExpenseSheet: View {
Text("金额沿用报销单的发票计算口径,不使用付款截图金额。").font(.caption).foregroundStyle(.secondary) Text("金额沿用报销单的发票计算口径,不使用付款截图金额。").font(.caption).foregroundStyle(.secondary)
} }
if !store.expenseSelectionValid { if !store.expenseSelectionValid {
Text("勾选材料已变化,请关闭弹窗后重新选择。").foregroundStyle(.red) AppNotice(text: "勾选材料已变化", tone: .danger,
actionTitle: "返回重新选择", action: { dismiss(); store.page = .matched })
}
if duplicatePurposes {
AppNotice(text: "多个费用类型使用了相同用途;金额仍按费用类型分别汇总。", tone: .warning)
} }
Divider() Divider()
HStack { HStack {
Text("签字岗位").font(.headline) Text("签字岗位").font(.headline)
Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary) Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary)
Spacer() Spacer()
Menu {
ForEach(defaultSignatures, id: \.self) { name in
Button(name) { store.signatures.append(name) }.disabled(store.signatures.contains(name))
}
} label: { Image(systemName: "text.badge.plus") }
.help("选择签字岗位").disabled(store.signatures.count >= 6)
Button { store.signatures.append("") } label: { Label("添加", systemImage: "plus") }.disabled(store.signatures.count >= 6) Button { store.signatures.append("") } label: { Label("添加", systemImage: "plus") }.disabled(store.signatures.count >= 6)
} }
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) {
ForEach(store.signatures.indices, id: \.self) { index in ForEach(store.signatures.indices, id: \.self) { index in
HStack { HStack {
TextField("岗位名称", text: Binding(get: { store.signatures.indices.contains(index) ? store.signatures[index] : "" }, set: { if store.signatures.indices.contains(index) { store.signatures[index] = $0 } })) TextField("岗位名称", text: Binding(get: { store.signatures.indices.contains(index) ? store.signatures[index] : "" }, set: { if store.signatures.indices.contains(index) { store.signatures[index] = $0 } }))
Button { store.signatures.remove(at: index) } label: { Image(systemName: "minus.circle") }.buttonStyle(.borderless) Button {
removedSignature = (index, store.signatures.remove(at: index))
} label: { Image(systemName: "minus.circle") }.buttonStyle(.borderless).help("移除岗位")
}
}
}
if let removedSignature {
AppNotice(text: "已移除“\(removedSignature.value.isEmpty ? "空岗位" : removedSignature.value)",
actionTitle: store.signatures.count < 6 ? "撤销" : nil, action: {
guard store.signatures.count < 6 else { return }
store.signatures.insert(removedSignature.value, at: min(removedSignature.index, store.signatures.count))
self.removedSignature = nil
}, dismiss: { self.removedSignature = nil })
} }
} }
} }
@@ -342,10 +335,16 @@ struct ExpenseSheet: View {
}.buttonStyle(.borderedProminent) }.buttonStyle(.borderedProminent)
.disabled(!store.expenseSelectionValid) .disabled(!store.expenseSelectionValid)
} }
}.padding(26).frame(width: 870, height: 620).textFieldStyle(.roundedBorder) }.padding(AppTheme.page)
.frame(width: AppTheme.sheetSize(width: 900, height: 700).width,
height: AppTheme.sheetSize(width: 900, height: 700).height).textFieldStyle(.roundedBorder)
.disabled(store.busy && !store.showExpensePreview) .disabled(store.busy && !store.showExpensePreview)
.interactiveDismissDisabled(store.busy) .interactiveDismissDisabled(store.busy)
.sheet(isPresented: $showTemplate) { ExpenseTemplateManager() } .sheet(isPresented: $showTemplate) { ExpenseTemplateManager() }
.sheet(isPresented: $store.showExpensePreview) { ExpensePreviewSheet() } .sheet(isPresented: $store.showExpensePreview) { ExpensePreviewSheet() }
} }
private var duplicatePurposes: Bool {
let values = store.expenseRows.map { store.purposes[$0.category]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? $0.category }.filter { !$0.isEmpty }
return Set(values).count < values.count
}
} }
+94 -24
View File
@@ -1,31 +1,66 @@
import SwiftUI import SwiftUI
import ImageIO import ImageIO
enum MaterialImageLoader {
static func load(_ url: URL, pixels: Int) async -> NSImage? {
let task = Task.detached(priority: .utility) { () -> CGImage? in
guard !Task.isCancelled else { return nil }
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else {
return pdfFirstPage(url, pixels: pixels)
}
let bitmap = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: pixels,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true
] as CFDictionary)
return Task.isCancelled ? nil : bitmap
}
let bitmap = await withTaskCancellationHandler { await task.value } onCancel: { task.cancel() }
guard !Task.isCancelled, let bitmap else { return nil }
return NSImage(cgImage: bitmap, size: .zero)
}
nonisolated private static func pdfFirstPage(_ url: URL, pixels: Int) -> CGImage? {
guard let document = CGPDFDocument(url as CFURL), let page = document.page(at: 1) else { return nil }
let box = page.getBoxRect(.mediaBox)
guard box.width > 0, box.height > 0 else { return nil }
let rotated = abs(page.rotationAngle) % 180 == 90
let size = rotated ? CGSize(width: box.height, height: box.width) : box.size
let scale = CGFloat(max(1, pixels)) / max(size.width, size.height)
let width = max(1, Int(ceil(size.width * scale))), height = max(1, Int(ceil(size.height * scale)))
guard !Task.isCancelled,
let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil }
let rect = CGRect(x: 0, y: 0, width: width, height: height)
context.setFillColor(CGColor(gray: 1, alpha: 1))
context.fill(rect)
context.concatenate(page.getDrawingTransform(.mediaBox, rect: rect, rotate: 0, preserveAspectRatio: true))
context.drawPDFPage(page)
return Task.isCancelled ? nil : context.makeImage()
}
}
struct MaterialThumbnail: View { struct MaterialThumbnail: View {
let item: Material let item: Material
var height: CGFloat = 155 var height: CGFloat = 155
var pixels: Int = 700
@State private var image: NSImage? @State private var image: NSImage?
var body: some View { var body: some View {
ZStack { ZStack {
Color.white AppTheme.surface
if let image { if let image {
Image(nsImage: image).resizable().scaledToFit().padding(6) Image(nsImage: image).resizable().scaledToFit().padding(6)
} else { } else {
Image(systemName: "doc.richtext").font(.largeTitle).foregroundStyle(.gray) Image(systemName: "doc.richtext").font(.largeTitle).foregroundStyle(.gray)
} }
}.frame(height: height).clipShape(RoundedRectangle(cornerRadius: 9)) }.frame(height: height).clipShape(RoundedRectangle(cornerRadius: AppTheme.radius))
.task(id: item.previewURL) { .task(id: item.previewURL) {
let url = item.previewURL image = nil
image = await Task.detached(priority: .utility) { let loaded = await MaterialImageLoader.load(item.previewURL, pixels: pixels)
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), guard !Task.isCancelled else { return }
let bitmap = CGImageSourceCreateThumbnailAtIndex(source, 0, [ image = loaded
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 700,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return NSImage(contentsOf: url) }
return NSImage(cgImage: bitmap, size: .zero)
}.value
} }
} }
} }
@@ -35,10 +70,12 @@ struct MaterialCard: View {
let item: Material let item: Material
var selected = false var selected = false
var selectable = true var selectable = true
var thumbnailHeight: CGFloat = 155
var counterpartCount = 0
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 9) { VStack(alignment: .leading, spacing: 9) {
MaterialThumbnail(item: item) MaterialThumbnail(item: item, height: thumbnailHeight)
.overlay(alignment: .topTrailing) { .overlay(alignment: .topTrailing) {
Button { store.preview = item } label: { Button { store.preview = item } label: {
Image(systemName: "arrow.up.left.and.arrow.down.right").padding(7) Image(systemName: "arrow.up.left.and.arrow.down.right").padding(7)
@@ -49,34 +86,44 @@ struct MaterialCard: View {
Spacer(minLength: 2) Spacer(minLength: 2)
if selected { Image(systemName: "checkmark.circle.fill").foregroundStyle(.teal) } if selected { Image(systemName: "checkmark.circle.fill").foregroundStyle(.teal) }
} }
Text(item.amountLabel).font(.headline).monospacedDigit().foregroundStyle(.teal) Text(item.amountLabel).font(.headline).monospacedDigit()
Text(item.ocr.merchants.first ?? "未识别商户").font(.caption).foregroundStyle(.secondary).lineLimit(1) Text(item.ocr.merchants.first ?? "未识别商户").font(.caption).foregroundStyle(.secondary).lineLimit(1)
HStack { HStack {
Text(item.dateLabel) Text(item.dateLabel)
Spacer() Spacer()
if item.ocr.status == "failed" { Label("识别失败", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) } if item.ocr.status == "failed" { Label("识别失败", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) }
}.font(.caption2).foregroundStyle(.secondary) }.font(.caption2).foregroundStyle(.secondary)
}.padding(11) }.padding(12)
.background(selected ? Color.teal.opacity(0.08) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 13)) .background(selected ? AppTheme.accent.opacity(0.08) : AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius))
.overlay(RoundedRectangle(cornerRadius: 13).stroke(selected ? .teal : .primary.opacity(0.08), lineWidth: selected ? 2 : 1)) .overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(selected ? AppTheme.accent : AppTheme.border, lineWidth: selected ? 2 : 1))
.contentShape(Rectangle()) .contentShape(Rectangle())
.focusable(selectable) .focusable(selectable)
.onKeyPress(.return) { .onKeyPress(.return) {
guard !store.busy else { return .ignored }
if selectable { store.toggle(item) } else { store.preview = item }
return .handled
}
.onKeyPress(.space) {
guard !store.busy else { return .ignored }
if selectable { store.toggle(item) } else { store.preview = item } if selectable { store.toggle(item) } else { store.preview = item }
return .handled return .handled
} }
.onTapGesture { if selectable { store.toggle(item) } else { store.preview = item } } .onTapGesture { if selectable { store.toggle(item) } else { store.preview = item } }
.accessibilityElement(children: .contain) .accessibilityElement(children: .contain)
.accessibilityLabel("\(item.name)\(item.amountLabel)\(selected ? "已选择" : "未选择")") .accessibilityLabel("\(item.name)\(item.amountLabel)\(selected ? "已选择,与 \(counterpartCount) 份材料配对" : "未选择")")
.accessibilityAction { if selectable { store.toggle(item) } else { store.preview = item } } .accessibilityAction { if selectable { store.toggle(item) } else { store.preview = item } }
} }
} }
struct MaterialPreview: View { struct MaterialPreview: View {
let item: Material let item: Material
@EnvironmentObject var store: WorkspaceStore
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var zoom: CGFloat = 1 @State private var zoom: CGFloat = 1
@State private var showText = false @State private var showText = false
@State private var image: NSImage?
@State private var loading = true
@State private var attempt = 0
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
@@ -85,25 +132,33 @@ struct MaterialPreview: View {
Text(item.amountLabel + " · " + item.dateLabel).font(.caption).foregroundStyle(.secondary) Text(item.amountLabel + " · " + item.dateLabel).font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
if !item.matched && item.type != "photo" {
Button { store.toggle(item) } label: {
Label(isSelected ? "取消选择" : "选中此材料", systemImage: isSelected ? "checkmark.circle.fill" : "circle")
}.disabled(store.busy)
}
Toggle("识别文本", isOn: $showText).toggleStyle(.button) Toggle("识别文本", isOn: $showText).toggleStyle(.button)
Button { zoom = max(0.5, zoom - 0.25) } label: { Image(systemName: "minus.magnifyingglass") } Button { zoom = max(0.5, zoom - 0.25) } label: { Image(systemName: "minus.magnifyingglass") }.help("缩小")
Text("\(Int(zoom * 100))%").font(.caption.monospacedDigit()).frame(width: 44) Text("\(Int(zoom * 100))%").font(.caption.monospacedDigit()).frame(width: 44)
Button { zoom = min(4, zoom + 0.25) } label: { Image(systemName: "plus.magnifyingglass") } Button { zoom = min(4, zoom + 0.25) } label: { Image(systemName: "plus.magnifyingglass") }.help("放大")
Button("完成") { dismiss() }.keyboardShortcut(.cancelAction) Button("完成") { dismiss() }.keyboardShortcut(.cancelAction)
}.padding(18) }.padding(18)
Divider() Divider()
HStack(spacing: 0) { HStack(spacing: 0) {
GeometryReader { geometry in GeometryReader { geometry in
ScrollView([.horizontal, .vertical]) { ScrollView([.horizontal, .vertical]) {
if let image = NSImage(contentsOf: item.previewURL) { if let image {
Image(nsImage: image).resizable().scaledToFit() Image(nsImage: image).resizable().scaledToFit()
.frame(width: max(1, geometry.size.width - 30) * zoom, height: max(1, geometry.size.height - 30) * zoom) .frame(width: max(1, geometry.size.width - 30) * zoom, height: max(1, geometry.size.height - 30) * zoom)
.padding(15) .padding(15)
} else if loading {
ProgressView().frame(width: geometry.size.width, height: geometry.size.height)
} else { } else {
EmptyPanel(symbol: "exclamationmark.triangle", title: "无法预览此文件", message: item.ocr.message) EmptyPanel(symbol: "exclamationmark.triangle", title: "无法预览此文件", message: item.ocr.message,
actionTitle: "重试") { attempt += 1 }
} }
} }
}.background(.black.opacity(0.04)) }.background(AppTheme.canvas)
if showText { if showText {
Divider() Divider()
ScrollView { ScrollView {
@@ -115,6 +170,21 @@ struct MaterialPreview: View {
}.frame(width: 280) }.frame(width: 280)
} }
} }
}.frame(width: 1000, height: 700) }.frame(width: AppTheme.sheetSize(width: 1100, height: 760).width,
height: AppTheme.sheetSize(width: 1100, height: 760).height)
.task(id: "\(item.previewURL.path):\(attempt)") {
loading = true
image = nil
let thumbnail = await MaterialImageLoader.load(item.previewURL, pixels: 700)
guard !Task.isCancelled else { return }
image = thumbnail
let full = await MaterialImageLoader.load(item.previewURL, pixels: 6000)
guard !Task.isCancelled else { return }
image = full ?? thumbnail
loading = false
}
}
private var isSelected: Bool {
(item.type == "invoice" ? store.selectedInvoices : store.selectedPayments).contains(item.id)
} }
} }
+114
View File
@@ -0,0 +1,114 @@
import Foundation
import CoreGraphics
enum PairConnectionOverflow {
case above, below
}
struct PairConnectionEndpoint: Identifiable {
let id: String
let point: CGPoint
let count: Int
let overflow: PairConnectionOverflow?
let route: [CGPoint]
}
struct PairConnectionLine: Equatable {
let start: CGPoint
let end: CGPoint
}
struct PairConnectionLayout {
let invoices: [PairConnectionEndpoint]
let payments: [PairConnectionEndpoint]
let clipRect: CGRect
var representedPairCount: Int {
invoices.reduce(0) { $0 + $1.count } * payments.reduce(0) { $0 + $1.count }
}
var lines: [PairConnectionLine] {
guard !invoices.isEmpty, !payments.isEmpty else { return [] }
let endpoints = invoices + payments
var result = endpoints.flatMap { endpoint in
zip(endpoint.route, endpoint.route.dropFirst()).map { PairConnectionLine(start: $0, end: $1) }
}
let joins = endpoints.compactMap { $0.route.last }
if let top = joins.min(by: { $0.y < $1.y }), let bottom = joins.max(by: { $0.y < $1.y }), top != bottom {
result.append(PairConnectionLine(start: top, end: bottom))
}
return result
}
init(invoiceIDs: [String], paymentIDs: [String], selectedInvoices: Set<String>, selectedPayments: Set<String>,
frames: [String: CGRect], invoiceViewport: CGRect, paymentViewport: CGRect) {
let top = max(invoiceViewport.minY, paymentViewport.minY)
let bottom = min(invoiceViewport.maxY, paymentViewport.maxY)
guard !invoiceViewport.isEmpty, !paymentViewport.isEmpty, bottom > top else {
invoices = []; payments = []; clipRect = .zero
return
}
clipRect = CGRect(x: invoiceViewport.minX, y: top,
width: paymentViewport.maxX - invoiceViewport.minX, height: bottom - top)
let spineX = (invoiceViewport.maxX + paymentViewport.minX) / 2
invoices = Self.endpoints(ids: invoiceIDs, selected: selectedInvoices, frames: frames,
viewport: invoiceViewport.intersection(clipRect), invoice: true, spineX: spineX)
payments = Self.endpoints(ids: paymentIDs, selected: selectedPayments, frames: frames,
viewport: paymentViewport.intersection(clipRect), invoice: false, spineX: spineX)
}
private static func endpoints(ids: [String], selected: Set<String>, frames: [String: CGRect],
viewport: CGRect, invoice: Bool, spineX: CGFloat) -> [PairConnectionEndpoint] {
let firstVisible = ids.firstIndex { id in
guard let rect = frames[id] else { return false }
let visible = rect.intersection(viewport)
return !visible.isNull && !visible.isEmpty
}
var result: [PairConnectionEndpoint] = []
var above = 0, below = 0
for (index, id) in ids.enumerated() where selected.contains(id) {
if let rect = frames[id] {
let visible = rect.intersection(viewport)
if !visible.isNull && !visible.isEmpty {
let point = CGPoint(x: invoice ? visible.maxX : visible.minX, y: visible.midY)
let rowFrames = ids.compactMap { frames[$0] }.filter { $0.minY < point.y && $0.maxY > point.y }
let blocked = rowFrames.contains { invoice ? $0.minX >= point.x : $0.maxX <= point.x }
var route = [point]
if blocked {
// Route around neighboring cards through the grid's 12-point row/column gaps.
let bottom = (rowFrames.map(\.maxY).max() ?? rect.maxY) + 6
let top = (rowFrames.map(\.minY).min() ?? rect.minY) - 6
let laneY = bottom < viewport.maxY - 2 ? bottom : max(viewport.minY + 2, top)
let laneX = point.x + (invoice ? 6 : -6)
route += [CGPoint(x: laneX, y: point.y), CGPoint(x: laneX, y: laneY),
CGPoint(x: spineX, y: laneY)]
} else {
route.append(CGPoint(x: spineX, y: point.y))
}
result.append(PairConnectionEndpoint(
id: "material:\(id)", point: point, count: 1, overflow: nil, route: route))
} else if rect.maxY <= viewport.minY {
above += 1
} else {
below += 1
}
} else {
// Lazy grids discard offscreen cells; their sorted positions still identify the scroll direction.
if let firstVisible, index < firstVisible { above += 1 } else { below += 1 }
}
}
let inset = min(12, viewport.height / 2)
let edgeX = invoice ? viewport.maxX - 24 : viewport.minX + 24
if above > 0 {
let point = CGPoint(x: edgeX, y: viewport.minY + inset)
result.append(PairConnectionEndpoint(id: "above", point: point, count: above, overflow: .above,
route: [point, CGPoint(x: spineX, y: point.y)]))
}
if below > 0 {
let point = CGPoint(x: edgeX, y: viewport.maxY - inset)
result.append(PairConnectionEndpoint(id: "below", point: point, count: below, overflow: .below,
route: [point, CGPoint(x: spineX, y: point.y)]))
}
return result
}
}
+145 -46
View File
@@ -1,79 +1,178 @@
import SwiftUI import SwiftUI
enum PairAnchor: Hashable {
case material(String), invoiceViewport, paymentViewport
}
struct MaterialAnchorKey: PreferenceKey { struct MaterialAnchorKey: PreferenceKey {
static var defaultValue: [String: Anchor<CGRect>] = [:] static var defaultValue: [PairAnchor: Anchor<CGRect>] = [:]
static func reduce(value: inout [String: Anchor<CGRect>], nextValue: () -> [String: Anchor<CGRect>]) { static func reduce(value: inout [PairAnchor: Anchor<CGRect>], nextValue: () -> [PairAnchor: Anchor<CGRect>]) {
value.merge(nextValue(), uniquingKeysWith: { _, next in next }) value.merge(nextValue(), uniquingKeysWith: { _, next in next })
} }
} }
struct PairPage: View { struct PairPage: View {
@EnvironmentObject var store: WorkspaceStore @EnvironmentObject var store: WorkspaceStore
@State private var invoiceQuery = ""
@State private var paymentQuery = ""
@State private var invoiceOrder: MaterialOrder = .date
@State private var paymentOrder: MaterialOrder = .date
@State private var thumbnailSize = 1.0
@State private var scrollRevision = 0
@FocusState private var focusedMaterial: String?
@FocusState private var searchFocused: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 18) { let invoices = WorkspaceFilter.materials(store.state.unmatchedInvoices, query: invoiceQuery, order: invoiceOrder)
let payments = WorkspaceFilter.materials(store.state.unmatchedPayments, query: paymentQuery, order: paymentOrder)
VStack(alignment: .leading, spacing: AppTheme.gap) {
HStack { HStack {
VStack(alignment: .leading, spacing: 6) { Text("人工配对").font(.title2.bold())
Text("人工连连对").font(.title3.bold())
Text("左右均可多选,确认后作为一个核对组处理。人工确认不限制金额相等。")
.font(.caption).foregroundStyle(.secondary)
}
Spacer() Spacer()
Image(systemName: "photo").foregroundStyle(.secondary)
Slider(value: $thumbnailSize, in: 0...2, step: 1).frame(width: 100)
.help("缩略图大小").accessibilityLabel("缩略图大小")
Picker("分类", selection: $store.category) { Picker("分类", selection: $store.category) {
ForEach(expenseCategories, id: \.self) { Text($0) } ForEach(expenseCategories, id: \.self) { Text($0) }
}.frame(width: 160) }.frame(width: 160).disabled(store.busy)
Button("确认配对", action: store.confirmPair).buttonStyle(.borderedProminent).disabled(!store.canPair)
} }
HStack { HStack(alignment: .top, spacing: 32) {
Label("已选 \(store.selectedInvoices.count) 张发票", systemImage: "doc.text") column(invoice: true, items: invoices)
Image(systemName: "link").foregroundStyle(.teal) column(invoice: false, items: payments)
Label("已选 \(store.selectedPayments.count) 张付款", systemImage: "creditcard")
Spacer()
if !store.selectedInvoices.isEmpty || !store.selectedPayments.isEmpty {
Button("取消选择") { store.selectedInvoices = []; store.selectedPayments = [] }.buttonStyle(.borderless)
}
}.font(.callout).foregroundStyle(.secondary)
HStack(alignment: .top, spacing: 40) {
column(title: "待配对发票", items: store.state.unmatchedInvoices, selected: store.selectedInvoices)
column(title: "待配对付款", items: store.state.unmatchedPayments, selected: store.selectedPayments)
} }
.overlayPreferenceValue(MaterialAnchorKey.self) { anchors in .overlayPreferenceValue(MaterialAnchorKey.self) { anchors in
GeometryReader { geometry in GeometryReader { geometry in
Canvas { context, _ in // Resolve anchors in the view update, not in Canvas's retained drawing closure.
for invoice in store.selectedInvoices { let _ = scrollRevision
for payment in store.selectedPayments { if let left = anchors[.invoiceViewport], let right = anchors[.paymentViewport] {
if let left = anchors[invoice], let right = anchors[payment] { let frames = Dictionary(uniqueKeysWithValues: anchors.compactMap { key, anchor -> (String, CGRect)? in
let start = CGPoint(x: geometry[left].maxX, y: geometry[left].midY) guard case .material(let id) = key else { return nil }
let end = CGPoint(x: geometry[right].minX, y: geometry[right].midY) return (id, geometry[anchor])
var line = Path() })
line.move(to: start) let layout = PairConnectionLayout(
let bend = max(30, (end.x - start.x) * 0.42) invoiceIDs: invoices.map(\.id), paymentIDs: payments.map(\.id),
line.addCurve(to: end, control1: CGPoint(x: start.x + bend, y: start.y), control2: CGPoint(x: end.x - bend, y: end.y)) selectedInvoices: store.selectedInvoices, selectedPayments: store.selectedPayments,
context.stroke(line, with: .color(.teal.opacity(0.6)), style: StrokeStyle(lineWidth: 2, dash: [6, 4])) frames: frames, invoiceViewport: geometry[left], paymentViewport: geometry[right])
PairConnectionOverlay(layout: layout)
} }
}.allowsHitTesting(false).clipped().accessibilityHidden(true)
} }
} Divider()
} HStack(spacing: 16) {
}.allowsHitTesting(false).clipped() Label("\(store.selectedInvoices.count) 张发票", systemImage: "doc.text")
} Image(systemName: "link").foregroundStyle(AppTheme.accent)
}.padding(28).disabled(store.busy) Label("\(store.selectedPayments.count) 张付款", systemImage: "creditcard")
Spacer()
Button("取消选择") { store.selectedInvoices = []; store.selectedPayments = [] }
.disabled(store.busy || (store.selectedInvoices.isEmpty && store.selectedPayments.isEmpty))
Button { store.confirmPair() } label: {
Label("确认配对(\(store.selectedInvoices.count) × \(store.selectedPayments.count)", systemImage: "link")
}.buttonStyle(.borderedProminent).disabled(!store.canPair)
}.font(.callout)
}.padding(AppTheme.page)
.onReceive(NotificationCenter.default.publisher(for: WorkspaceCommand.find)) { _ in searchFocused = true }
} }
private func column(title: String, items: [Material], selected: Set<String>) -> some View { private func column(invoice: Bool, items: [Material]) -> some View {
VStack(alignment: .leading, spacing: 14) { let source = invoice ? store.state.unmatchedInvoices : store.state.unmatchedPayments
HStack { Text(title).font(.headline); Spacer(); Text("\(items.count)").foregroundStyle(.secondary) } let query = invoice ? $invoiceQuery : $paymentQuery
let order = invoice ? $invoiceOrder : $paymentOrder
let selected = invoice ? store.selectedInvoices : store.selectedPayments
let counterpart = invoice ? store.selectedPayments.count : store.selectedInvoices.count
return VStack(alignment: .leading, spacing: 12) {
HStack {
Text(invoice ? "待配对发票" : "待配对付款").font(.headline)
Spacer()
Text("\(items.count) / \(source.count)").monospacedDigit().foregroundStyle(.secondary)
Picker("排序", selection: order) {
ForEach(MaterialOrder.allCases) { Text($0.rawValue).tag($0) }
}.labelsHidden().frame(width: 90).help("排序")
}
Group {
if invoice {
AppSearchField(placeholder: "文件名、商户、金额或日期", text: query).focused($searchFocused)
} else { AppSearchField(placeholder: "文件名、商户、金额或日期", text: query) }
}
if items.isEmpty { if items.isEmpty {
EmptyPanel(symbol: "checkmark.circle", title: "材料已全部处理", message: "导入材料或撤销核对组后,会显示在这里。") EmptyPanel(symbol: source.isEmpty ? "checkmark.circle" : "magnifyingglass",
title: source.isEmpty ? (invoice ? "所有发票已处理或已核对" : "所有付款已处理或已核对") : "没有匹配的材料",
message: "", actionTitle: source.isEmpty ? "查看已核对" : "清空搜索") {
if source.isEmpty { store.page = .matched } else { query.wrappedValue = "" }
}
} else { } else {
ScrollViewReader { proxy in
ScrollView { ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 155 + thumbnailSize * 30), spacing: 12)], spacing: 12) {
ForEach(items) { item in ForEach(items) { item in
MaterialCard(item: item, selected: selected.contains(item.id)) MaterialCard(item: item, selected: selected.contains(item.id),
.anchorPreference(key: MaterialAnchorKey.self, value: .bounds) { selected.contains(item.id) ? [item.id: $0] : [:] } thumbnailHeight: 110 + thumbnailSize * 45, counterpartCount: counterpart)
.focused($focusedMaterial, equals: item.id)
.focusEffectDisabled()
.onKeyPress(.downArrow) { moveFocus(item.id, in: items, offset: 1, proxy: proxy) }
.onKeyPress(.upArrow) { moveFocus(item.id, in: items, offset: -1, proxy: proxy) }
.anchorPreference(key: MaterialAnchorKey.self, value: .bounds) {
[.material(item.id): $0]
} }
}.padding(3) .id(item.id)
} }
} }
.padding(3)
}
.transformAnchorPreference(key: MaterialAnchorKey.self, value: .bounds) { anchors, viewport in
anchors[invoice ? .invoiceViewport : .paymentViewport] = viewport
}
.onScrollGeometryChange(for: CGPoint.self) { $0.contentOffset } action: { old, new in
if old != new { scrollRevision &+= 1 }
}
}
}
let hidden = selected.subtracting(Set(items.map(\.id))).count
Text(hidden > 0 ? "另有 \(hidden) 份已选材料不在当前结果中" : "已选 \(selected.count)")
.font(.caption).foregroundStyle(.secondary).frame(height: 18)
}.frame(maxWidth: .infinity, maxHeight: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity)
} }
private func moveFocus(_ id: String, in items: [Material], offset: Int, proxy: ScrollViewProxy) -> KeyPress.Result {
guard let index = items.firstIndex(where: { $0.id == id }) else { return .ignored }
let next = max(0, min(items.count - 1, index + offset))
focusedMaterial = items[next].id
withAnimation(.easeInOut(duration: 0.15)) { proxy.scrollTo(items[next].id) }
return .handled
}
}
private struct PairConnectionOverlay: View {
let layout: PairConnectionLayout
var body: some View {
if !layout.lines.isEmpty {
Canvas { context, _ in
context.clip(to: Path(layout.clipRect))
var path = Path()
for connection in layout.lines {
path.move(to: connection.start)
path.addLine(to: connection.end)
}
context.stroke(path, with: .color(AppTheme.accent.opacity(0.8)),
style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
for endpoint in layout.invoices + layout.payments where endpoint.overflow == nil {
context.fill(Path(ellipseIn: CGRect(x: endpoint.point.x - 3, y: endpoint.point.y - 3, width: 6, height: 6)),
with: .color(AppTheme.accent))
}
}
overflowBadges(layout.invoices)
overflowBadges(layout.payments)
}
}
private func overflowBadges(_ endpoints: [PairConnectionEndpoint]) -> some View {
ForEach(endpoints.filter { $0.overflow != nil }) { endpoint in
Label("\(endpoint.count)", systemImage: endpoint.overflow == .above ? "arrow.up" : "arrow.down")
.font(.caption2.weight(.semibold)).monospacedDigit().foregroundStyle(AppTheme.accent)
.frame(width: 44, height: 22)
.background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5).stroke(AppTheme.accent.opacity(0.5)))
.position(endpoint.point)
}
}
} }
+15 -2
View File
@@ -7,6 +7,7 @@ struct PayeeProfileSheet: View {
@State private var showAccount = false @State private var showAccount = false
@State private var confirmDelete = false @State private var confirmDelete = false
@State private var errorMessage: String? @State private var errorMessage: String?
var onSave: (() -> Void)?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
@@ -54,6 +55,7 @@ struct PayeeProfileSheet: View {
Button("保存") { Button("保存") {
do { do {
try profileStore.save(draft) try profileStore.save(draft)
onSave?()
dismiss() dismiss()
} catch { errorMessage = error.localizedDescription } } catch { errorMessage = error.localizedDescription }
} }
@@ -61,7 +63,7 @@ struct PayeeProfileSheet: View {
.disabled(profileStore.loadError != nil) .disabled(profileStore.loadError != nil)
} }
} }
.padding(26).frame(width: 580) .padding(AppTheme.page).frame(width: AppTheme.sheetSize(width: 580, height: 560).width)
.onAppear { draft = profileStore.profile ?? PayeeProfile() } .onAppear { draft = profileStore.profile ?? PayeeProfile() }
.confirmationDialog("删除本机保存的收款信息?", isPresented: $confirmDelete) { .confirmationDialog("删除本机保存的收款信息?", isPresented: $confirmDelete) {
Button("删除收款信息", role: .destructive) { Button("删除收款信息", role: .destructive) {
@@ -81,6 +83,7 @@ struct PayeeProfileSheet: View {
struct PayeeProfileSummary: View { struct PayeeProfileSummary: View {
@ObservedObject var profileStore: PayeeProfileStore @ObservedObject var profileStore: PayeeProfileStore
@State private var editing = false @State private var editing = false
@State private var saved = false
var body: some View { var body: some View {
HStack { HStack {
@@ -89,14 +92,24 @@ struct PayeeProfileSummary: View {
if let profile = profileStore.profile { if let profile = profileStore.profile {
Text("\(profile.recipient) · \(profile.bankName) · 尾号 \(profile.accountNumber.suffix(4))") Text("\(profile.recipient) · \(profile.bankName) · 尾号 \(profile.accountNumber.suffix(4))")
.font(.caption).textSelection(.enabled) .font(.caption).textSelection(.enabled)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(String(profile.accountNumber.suffix(4)), forType: .string)
} label: { Image(systemName: "doc.on.doc") }.buttonStyle(.borderless).help("复制账号尾号")
} else { } else {
Text(profileStore.loadError ?? "尚未填写,导出的收款信息将留空,不使用模板示例账号。") Text(profileStore.loadError ?? "尚未填写,导出的收款信息将留空,不使用模板示例账号。")
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
} }
Spacer() Spacer()
if saved { AppBadge(title: "已保存", symbol: "checkmark", tone: .success) }
Button("设置收款信息") { editing = true } Button("设置收款信息") { editing = true }
} }
.sheet(isPresented: $editing) { PayeeProfileSheet(profileStore: profileStore) } .sheet(isPresented: $editing) { PayeeProfileSheet(profileStore: profileStore, onSave: { saved = true }) }
.task(id: saved) {
guard saved else { return }
do { try await Task.sleep(for: .seconds(5)) } catch { return }
saved = false
}
} }
} }
+59 -42
View File
@@ -4,13 +4,20 @@ struct ScanPage: View {
@EnvironmentObject var store: WorkspaceStore @EnvironmentObject var store: WorkspaceStore
@Binding var confirmClear: Bool @Binding var confirmClear: Bool
@State private var folderTargeted = false @State private var folderTargeted = false
@State private var dismissedWarnings: Set<Int> = []
@State private var expandedWarnings = false
private var hasMaterials: Bool { !(store.state.invoices + store.state.payments + store.state.photos).isEmpty }
private var pendingCount: Int { store.state.unmatchedInvoices.count + store.state.unmatchedPayments.count }
private var warningIndices: [Int] { store.state.warnings.indices.filter { !dismissedWarnings.contains($0) } }
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 22) { VStack(alignment: .leading, spacing: 24) {
Panel { Panel {
HStack(spacing: 20) { HStack(spacing: 20) {
Image(systemName: folderTargeted && !store.busy ? "folder.badge.plus" : "folder.fill").font(.system(size: 44)).foregroundStyle(.teal.gradient) Image(systemName: folderTargeted && !store.busy ? "folder.badge.plus" : "folder.fill")
.font(.system(size: 36)).foregroundStyle(AppTheme.accent)
.scaleEffect(folderTargeted ? 1.08 : 1)
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("文件夹内请分别放置:发票 / 付款截图 / 实物照片")
@@ -18,99 +25,109 @@ struct ScanPage: View {
Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹") Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹")
.font(.callout.weight(.medium)).foregroundStyle(.teal) .font(.callout.weight(.medium)).foregroundStyle(.teal)
Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区") Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区")
.font(.caption).foregroundStyle(.tertiary) .font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy) Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy)
} }
} }
.overlay { .overlay {
RoundedRectangle(cornerRadius: 16) RoundedRectangle(cornerRadius: AppTheme.radius)
.strokeBorder(.teal.opacity(folderTargeted && !store.busy ? 1 : 0), style: StrokeStyle(lineWidth: 2, dash: [8, 5])) .strokeBorder(.teal.opacity(folderTargeted && !store.busy ? 1 : 0), style: StrokeStyle(lineWidth: 2, dash: [8, 5]))
.allowsHitTesting(false) .allowsHitTesting(false)
} }
.dropDestination(for: URL.self) { sources, _ in .dropDestination(for: URL.self) { sources, _ in
store.importFolders(sources) store.importFolders(sources)
} isTargeted: { targeted in } isTargeted: { targeted in
folderTargeted = targeted withAnimation(.easeInOut(duration: 0.15)) { folderTargeted = targeted }
}
if hasMaterials && pendingCount > 0 {
AppNotice(text: "\(pendingCount) 份材料待配对 · 已核对 \(store.state.matches.count)",
actionTitle: "前往人工配对", action: { store.page = .pair })
} }
ExpenseTemplateSummary { ExpenseTemplateSummary {
store.templateMessage = nil store.templateMessage = nil
store.showExpenseTemplate = true store.showExpenseTemplate = true
} }
if !store.state.warnings.isEmpty { if !warningIndices.isEmpty {
Panel { VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 7) { HStack {
Label("需要留意", systemImage: "exclamationmark.triangle").foregroundStyle(.orange).font(.headline) Label("\(warningIndices.count) 条提醒", systemImage: "exclamationmark.triangle")
ForEach(store.state.warnings, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) } .foregroundStyle(AppTheme.warning).font(.headline)
Spacer()
if warningIndices.count > 3 {
Button(expandedWarnings ? "收起" : "展开全部") { expandedWarnings.toggle() }
}
}
ForEach(expandedWarnings ? warningIndices : Array(warningIndices.prefix(3)), id: \.self) { index in
let warning = store.state.warnings[index]
let material = (store.state.invoices + store.state.payments + store.state.photos)
.first { warning.hasPrefix($0.name + "") }
AppNotice(text: warning, tone: .warning,
actionTitle: material == nil ? nil : "查看材料",
action: { store.preview = material },
dismiss: { dismissedWarnings.insert(index) })
} }
} }
} }
HStack(spacing: 16) { if hasMaterials {
summary("发票", symbol: "doc.text", count: store.state.invoices.count, subtitle: "\(store.state.unmatchedInvoices.count) 份待配对")
summary("付款截图", symbol: "creditcard", count: store.state.payments.count, subtitle: "\(store.state.unmatchedPayments.count) 份待配对")
summary("实物照片", symbol: "photo.on.rectangle", count: store.state.photos.count, subtitle: "供 PPT 手工贴入")
}
Text("金额概览").font(.headline)
HStack(alignment: .top, spacing: 16) { HStack(alignment: .top, spacing: 16) {
amountPanel("本次导入材料", subtitle: "包含已核对和待核对材料", invoice: store.state.directoryInvoiceTotal, payment: store.state.directoryPaymentTotal) StatNumber(title: "发票", value: "\(store.state.invoices.count)", symbol: "doc.text", detail: "\(store.state.unmatchedInvoices.count) 份待配对")
amountPanel("已核对材料", subtitle: "自动匹配与人工确认均计入", invoice: store.state.invoiceTotal, payment: store.state.paymentTotal) StatNumber(title: "付款截图", value: "\(store.state.payments.count)", symbol: "creditcard", detail: "\(store.state.unmatchedPayments.count) 份待配对")
StatNumber(title: "实物照片", value: "\(store.state.photos.count)", symbol: "photo")
StatNumber(title: "已核对", value: "\(store.state.matches.count)", symbol: "checkmark.seal",
detail: "自动 \(store.state.autoCount) · 人工 \(store.state.manualCount)")
}
Divider()
AppSection(title: "金额概览", symbol: "yensign.circle") {
HStack(alignment: .top, spacing: 32) {
amountPanel("本次导入", subtitle: "全部材料", invoice: store.state.directoryInvoiceTotal, payment: store.state.directoryPaymentTotal)
Divider()
amountPanel("已核对", subtitle: "\(store.state.matches.count)", invoice: store.state.invoiceTotal, payment: store.state.paymentTotal)
}
} }
if !store.state.matches.isEmpty { if !store.state.matches.isEmpty {
Panel { Divider()
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("已核对付款 · 分类汇总").font(.headline) Text("已核对付款 · 分类汇总").font(.headline)
LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], alignment: .leading, spacing: 18) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], alignment: .leading, spacing: 18) {
ForEach(expenseCategories, id: \.self) { category in ForEach(expenseCategories, id: \.self) { category in
let total = store.state.matches.filter { $0.category == category }.reduce(0) { $0 + $1.paymentTotal } let total = store.presentation.categoryPayments[category, default: 0]
if total > 0 { if total > 0 {
VStack(alignment: .leading, spacing: 7) { VStack(alignment: .leading, spacing: 7) {
Text(category).font(.caption).foregroundStyle(.secondary) Text(category).font(.caption).foregroundStyle(.secondary)
Text(currency(total)).font(.headline).monospacedDigit() Text(currency(total)).font(.headline).monospacedDigit()
ProgressView(value: total, total: max(total, store.state.paymentTotal))
.tint(AppTheme.accent).accessibilityLabel("\(category)付款金额占比")
} }
} }
} }
} }
} }
} }
}
HStack(spacing: 24) {
Label("自动核对 \(store.state.autoCount)", systemImage: "sparkles")
Label("人工核对 \(store.state.manualCount)", systemImage: "hand.draw")
Label("识别完成 \(store.state.recognizedCount)", systemImage: "text.viewfinder")
Spacer()
Button("前往人工配对") { store.page = .pair }.disabled(store.busy)
}.font(.callout).foregroundStyle(.secondary)
Divider() Divider()
HStack { HStack {
Text("工作区自动保存在本机,不修改原始材料。").font(.caption).foregroundStyle(.secondary) Label("识别完成 \(store.state.recognizedCount)", systemImage: "text.viewfinder")
.font(.caption).foregroundStyle(.secondary)
Spacer() Spacer()
Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy) Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy)
} }
}.padding(28)
} }
}.padding(AppTheme.page)
} }
.onChange(of: store.state.invoices.map(\.id) + store.state.payments.map(\.id) + store.state.photos.map(\.id)) { _, _ in
private func summary(_ title: String, symbol: String, count: Int, subtitle: String) -> some View { dismissedWarnings = []; expandedWarnings = false
Panel {
VStack(alignment: .leading, spacing: 12) {
Label(title, systemImage: symbol).font(.callout).foregroundStyle(.teal)
Text("\(count)").font(.system(size: 34, weight: .semibold, design: .rounded))
Text(subtitle).font(.caption).foregroundStyle(.secondary)
}
} }
} }
private func amountPanel(_ title: String, subtitle: String, invoice: Double, payment: Double) -> some View { private func amountPanel(_ title: String, subtitle: String, invoice: Double, payment: Double) -> some View {
Panel {
VStack(alignment: .leading, spacing: 17) { VStack(alignment: .leading, spacing: 17) {
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text(title).font(.headline) Text(title).font(.headline)
Text(subtitle).font(.caption).foregroundStyle(.secondary) Text(subtitle).font(.caption).foregroundStyle(.secondary)
} }
HStack { Text("发票总额").foregroundStyle(.secondary); Spacer(); Text(currency(invoice)).fontWeight(.semibold) } HStack { Text("发票总额").foregroundStyle(.secondary); Spacer(); Text(currency(invoice)).fontWeight(.semibold) }
HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold).foregroundStyle(.teal) } HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold) }
}.monospacedDigit() }.monospacedDigit().frame(maxWidth: .infinity)
}
} }
} }
+54 -9
View File
@@ -101,6 +101,9 @@ enum SpreadsheetZoom: String, CaseIterable {
struct ExpenseSpreadsheetPreview: View { struct ExpenseSpreadsheetPreview: View {
let layout: SpreadsheetLayout let layout: SpreadsheetLayout
var preferredSheet: String? var preferredSheet: String?
var selectedReference: String?
var highlightedRow: Int?
var onSelectCell: ((String) -> Void)?
var onExpand: (() -> Void)? var onExpand: (() -> Void)?
@State private var sheetIndex = 0 @State private var sheetIndex = 0
@State private var zoom: SpreadsheetZoom = .width @State private var zoom: SpreadsheetZoom = .width
@@ -115,13 +118,18 @@ struct ExpenseSpreadsheetPreview: View {
HStack(spacing: 10) { HStack(spacing: 10) {
Picker("工作表", selection: $sheetIndex) { Picker("工作表", selection: $sheetIndex) {
ForEach(layout.sheets.indices, id: \.self) { Text(layout.sheets[$0].name).tag($0) } ForEach(layout.sheets.indices, id: \.self) { Text(layout.sheets[$0].name).tag($0) }
}.labelsHidden().frame(minWidth: 90, maxWidth: 200) }.labelsHidden().frame(minWidth: 70, maxWidth: geometry.size.width < 540 ? 130 : 200)
Spacer(minLength: 0) Spacer(minLength: 0)
Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: { if geometry.size.width >= 540 {
if let mode = $0 { zoom = mode; customScale = nil } zoomPicker.pickerStyle(.segmented).frame(width: 190)
})) { } else {
ForEach(SpreadsheetZoom.allCases, id: \.self) { Text($0.rawValue).tag(Optional($0)) } Menu {
}.pickerStyle(.segmented).labelsHidden().frame(width: 190) ForEach(SpreadsheetZoom.allCases, id: \.self) { mode in
Button(mode.rawValue) { zoom = mode; customScale = nil }
}
} label: { Image(systemName: "arrow.up.left.and.down.right.magnifyingglass") }
.menuStyle(.borderlessButton).fixedSize().help("缩放方式")
}
Button { customScale = max(0.1, scale - 0.1) } label: { Button { customScale = max(0.1, scale - 0.1) } label: {
Image(systemName: "minus.magnifyingglass") Image(systemName: "minus.magnifyingglass")
}.help("缩小").disabled(scale <= 0.1) }.help("缩小").disabled(scale <= 0.1)
@@ -135,14 +143,15 @@ struct ExpenseSpreadsheetPreview: View {
Image(systemName: "arrow.up.left.and.arrow.down.right") Image(systemName: "arrow.up.left.and.arrow.down.right")
}.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口") }.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口")
} }
}.padding(.horizontal, 16).frame(height: 44) }.padding(.horizontal, geometry.size.width < 540 ? 8 : 16).frame(height: 44)
.background(Color(nsColor: .windowBackgroundColor)) .background(Color(nsColor: .windowBackgroundColor))
ScrollView([.horizontal, .vertical]) { ScrollView([.horizontal, .vertical]) {
SpreadsheetCanvas(sheet: sheet, scale: scale) SpreadsheetCanvas(sheet: sheet, scale: scale, selectedReference: selectedReference,
highlightedRow: highlightedRow, onSelectCell: onSelectCell)
.frame(width: sheet.width * scale, height: sheet.height * scale) .frame(width: sheet.width * scale, height: sheet.height * scale)
.background(.white).padding(24) .background(.white).padding(24)
.frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top) .frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top)
}.background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1))) }.background(Color(nsColor: .underPageBackgroundColor))
.id(sheetIndex) .id(sheetIndex)
}.onChange(of: sheetIndex) { _, _ in customScale = nil } }.onChange(of: sheetIndex) { _, _ in customScale = nil }
.onChange(of: preferredSheet, initial: true) { _, name in .onChange(of: preferredSheet, initial: true) { _, name in
@@ -152,15 +161,29 @@ struct ExpenseSpreadsheetPreview: View {
} }
} }
} }
private var zoomPicker: some View {
Picker("缩放", selection: Binding(get: { customScale == nil ? zoom : nil }, set: {
if let mode = $0 { zoom = mode; customScale = nil }
})) {
ForEach(SpreadsheetZoom.allCases, id: \.self) { Text($0.rawValue).tag(Optional($0)) }
}.labelsHidden()
}
} }
struct SpreadsheetCanvas: NSViewRepresentable { struct SpreadsheetCanvas: NSViewRepresentable {
let sheet: SpreadsheetSheet let sheet: SpreadsheetSheet
let scale: CGFloat let scale: CGFloat
var selectedReference: String?
var highlightedRow: Int?
var onSelectCell: ((String) -> Void)?
func makeNSView(context: Context) -> SpreadsheetCanvasView { SpreadsheetCanvasView(sheet: sheet, scale: scale) } func makeNSView(context: Context) -> SpreadsheetCanvasView { SpreadsheetCanvasView(sheet: sheet, scale: scale) }
func updateNSView(_ view: SpreadsheetCanvasView, context: Context) { func updateNSView(_ view: SpreadsheetCanvasView, context: Context) {
view.sheet = sheet view.sheet = sheet
view.scale = scale view.scale = scale
view.selectedReference = selectedReference
view.highlightedRow = highlightedRow
view.onSelectCell = onSelectCell
view.needsDisplay = true view.needsDisplay = true
} }
} }
@@ -175,6 +198,9 @@ struct SpreadsheetCanvas: NSViewRepresentable {
var sheet: SpreadsheetSheet var sheet: SpreadsheetSheet
var scale: CGFloat var scale: CGFloat
var selectedReference: String?
var highlightedRow: Int?
var onSelectCell: ((String) -> Void)?
override var isFlipped: Bool { true } override var isFlipped: Bool { true }
override var isOpaque: Bool { true } override var isOpaque: Bool { true }
init(sheet: SpreadsheetSheet, scale: CGFloat) { init(sheet: SpreadsheetSheet, scale: CGFloat) {
@@ -186,6 +212,15 @@ struct SpreadsheetCanvas: NSViewRepresentable {
} }
required init?(coder: NSCoder) { nil } required init?(coder: NSCoder) { nil }
override func mouseUp(with event: NSEvent) {
guard let onSelectCell else { super.mouseUp(with: event); return }
let point = convert(event.locationInWindow, from: nil)
let unscaled = NSPoint(x: point.x / scale, y: point.y / scale)
if let cell = sheet.cells.first(where: { $0.rect.contains(unscaled) }) {
onSelectCell(cell.reference)
}
}
static func color(_ hex: String) -> NSColor { static func color(_ hex: String) -> NSColor {
let value = UInt32(hex, radix: 16) ?? 0 let value = UInt32(hex, radix: 16) ?? 0
return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255, return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255,
@@ -305,6 +340,16 @@ struct SpreadsheetCanvas: NSViewRepresentable {
} }
context.restoreGState() context.restoreGState()
} }
if onSelectCell != nil {
for cell in cells where cell.reference == selectedReference ||
(highlightedRow != nil && Int(cell.reference.filter(\.isNumber)) == highlightedRow) {
context.setFillColor(NSColor.systemTeal.withAlphaComponent(0.14).cgColor)
context.fill(cell.rect)
context.setStrokeColor(NSColor.systemTeal.cgColor)
context.setLineWidth(2 / scale)
context.stroke(cell.rect.insetBy(dx: 1 / scale, dy: 1 / scale))
}
}
} }
private func drawBorder(_ cell: SpreadsheetCell, edge: String, border: SpreadsheetBorder, private func drawBorder(_ cell: SpreadsheetCell, edge: String, border: SpreadsheetBorder,
+13 -4
View File
@@ -5,6 +5,9 @@ import SwiftUI
struct SpreadsheetFilePreview: View { struct SpreadsheetFilePreview: View {
let url: URL let url: URL
var preferredSheet: String? var preferredSheet: String?
var selectedReference: String?
var highlightedRow: Int?
var onSelectCell: ((String) -> Void)?
@State private var layout: SpreadsheetLayout? @State private var layout: SpreadsheetLayout?
@State private var error: String? @State private var error: String?
@State private var attempt = 0 @State private var attempt = 0
@@ -17,7 +20,8 @@ struct SpreadsheetFilePreview: View {
var body: some View { var body: some View {
Group { Group {
if let layout { if let layout {
ExpenseDocumentPreview(layout: layout, title: "报销单模板", preferredSheet: preferredSheet) ExpenseDocumentPreview(layout: layout, title: "报销单模板", preferredSheet: preferredSheet,
selectedReference: selectedReference, highlightedRow: highlightedRow, onSelectCell: onSelectCell)
} else if let error { } else if let error {
VStack(spacing: 12) { VStack(spacing: 12) {
ContentUnavailableView("模板预览暂不可用", systemImage: "doc.badge.exclamationmark", ContentUnavailableView("模板预览暂不可用", systemImage: "doc.badge.exclamationmark",
@@ -59,6 +63,9 @@ struct ExpenseDocumentPreview: View {
let layout: SpreadsheetLayout let layout: SpreadsheetLayout
var title = "报销单预览" var title = "报销单预览"
var preferredSheet: String? var preferredSheet: String?
var selectedReference: String?
var highlightedRow: Int?
var onSelectCell: ((String) -> Void)?
@StateObject private var expanded = SpreadsheetPreviewWindow() @StateObject private var expanded = SpreadsheetPreviewWindow()
private var warnings: [String] { private var warnings: [String] {
@@ -73,11 +80,13 @@ struct ExpenseDocumentPreview: View {
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet) { ExpenseSpreadsheetPreview(layout: layout, preferredSheet: preferredSheet,
selectedReference: selectedReference, highlightedRow: highlightedRow,
onSelectCell: onSelectCell, onExpand: onSelectCell == nil ? {
expanded.show(layout: layout, title: title, preferredSheet: preferredSheet) expanded.show(layout: layout, title: title, preferredSheet: preferredSheet)
} } : nil)
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.white) .background(Color(nsColor: .textBackgroundColor))
if !warnings.isEmpty { if !warnings.isEmpty {
Text(warnings.joined(separator: "")) Text(warnings.joined(separator: ""))
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
+41
View File
@@ -0,0 +1,41 @@
import Foundation
enum TemplateMappingValidation {
static func column(_ value: String) -> Bool {
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard !value.isEmpty, value.count <= 3, value.utf8.allSatisfy({ (65...90).contains($0) }) else { return false }
return value.utf8.reduce(0) { $0 * 26 + Int($1) - 64 } <= 256
}
static func address(_ value: String) -> Bool {
let value = value.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard let range = value.range(of: #"^[A-Z]{1,3}[1-9][0-9]{0,3}$"#, options: .regularExpression),
range == value.startIndex..<value.endIndex else { return false }
return column(String(value.prefix(while: { $0.isLetter }))) &&
(Int(value.filter(\.isNumber)).map { $0 <= 2000 } ?? false)
}
static func references(_ value: String) -> [String] {
value.components(separatedBy: CharacterSet(charactersIn: ",;").union(.whitespacesAndNewlines)).filter { !$0.isEmpty }
}
static func issues(_ mapping: ExpenseTemplateMapping) -> [String] {
var result: [String] = []
for (key, label) in [("purpose", "用途"), ("amount", "金额")] {
if !column(mapping.columns[key, default: ""]) { result.append("请指定有效的\(label)") }
}
if mapping.startRow < 1 || mapping.endRow < mapping.startRow || mapping.endRow > 2000 || mapping.endRow - mapping.startRow >= 200 {
result.append("明细范围须为 1–2000 行,每页最多 200 行")
}
let columns = mapping.columns.values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() }.filter { !$0.isEmpty }
if columns.contains(where: { !column($0) }) { result.append("列地址须在 AIV 之间") }
if Set(columns).count != columns.count { result.append("明细列不能重复") }
let cells = mapping.cells.values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
let references = cells + references(mapping.signatureCells) + references(mapping.clearCells)
if references.contains(where: { !address($0) }) { result.append("单元格地址无效,例如 C23") }
if references.contains(where: { ref in Int(ref.filter(\.isNumber)).map { (mapping.startRow...max(mapping.startRow, mapping.endRow)).contains($0) } ?? false }) {
result.append("收款、合计、签字及清空位置不能放在明细行内")
}
let normalized = references.map { $0.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() }
if Set(normalized).count != normalized.count { result.append("单元格地址不能重复") }
if self.references(mapping.signatureCells).count > 6 { result.append("签字岗位最多 6 项") }
return result
}
}
+117
View File
@@ -0,0 +1,117 @@
import Foundation
enum MaterialOrder: String, CaseIterable, Identifiable {
case date = "日期", amount = "金额", name = "文件名"
var id: Self { self }
}
enum MatchOrder: String, CaseIterable, Identifiable {
case date = "日期", amount = "金额", confidence = "证据评分"
var id: Self { self }
}
extension MatchGroup {
var needsAttention: Bool {
(matchType == "auto" && (evidenceScore ?? 0) < 90) ||
(invoices + payments).contains { $0.ocr.status == "failed" }
}
}
enum MatchedBoardLayout {
static let gap: CGFloat = 8
static let headerHeight: CGFloat = 68
static let tileHeight: CGFloat = 164
static let scrollGutter: CGFloat = 16
static func columnWidth(available: CGFloat, count: Int) -> CGFloat {
max(132, (available - CGFloat(max(0, count - 1)) * gap) / CGFloat(max(1, count)))
}
}
enum WorkspaceFilter {
static func contains(_ query: String, fields: [String]) -> Bool {
query.split(whereSeparator: \.isWhitespace).allSatisfy { word in
fields.contains { $0.localizedCaseInsensitiveContains(String(word)) }
}
}
static func fields(_ item: Material) -> [String] {
[item.name, item.sortDate, item.issueDate, String(format: "%.2f", item.displayAmount)] +
item.ocr.merchants + item.ocr.amounts + item.ocr.dates
}
static func materials(_ items: [Material], query: String, order: MaterialOrder, descending: Bool = false) -> [Material] {
items.filter { contains(query, fields: fields($0)) }.sorted { left, right in
let comparison: ComparisonResult
switch order {
case .date: comparison = left.sortDate.compare(right.sortDate)
case .amount:
comparison = left.displayAmount == right.displayAmount ? .orderedSame :
left.displayAmount < right.displayAmount ? .orderedAscending : .orderedDescending
case .name: comparison = left.name.localizedStandardCompare(right.name)
}
if comparison == .orderedSame { return left.id < right.id }
return descending ? comparison == .orderedDescending : comparison == .orderedAscending
}
}
static func matches(_ items: [MatchGroup], query: String, category: String, attentionOnly: Bool,
order: MatchOrder, descending: Bool = false) -> [MatchGroup] {
items.enumerated().filter { _, match in
(category == "全部" || match.category == category) && (!attentionOnly || match.needsAttention) &&
contains(query, fields: [match.category, match.date, String(format: "%.2f", match.invoiceTotal),
String(format: "%.2f", match.paymentTotal), String(format: "%.2f", match.expenseAmount)] +
(match.invoices + match.payments).flatMap(fields))
}.sorted { left, right in
let comparison: ComparisonResult
switch order {
case .date: comparison = left.element.date.compare(right.element.date)
case .amount:
let a = left.element.expenseAmount > 0 ? left.element.expenseAmount : left.element.invoiceTotal
let b = right.element.expenseAmount > 0 ? right.element.expenseAmount : right.element.invoiceTotal
comparison = a == b ? .orderedSame : a < b ? .orderedAscending : .orderedDescending
case .confidence:
let a = left.element.evidenceScore ?? -1, b = right.element.evidenceScore ?? -1
comparison = a == b ? .orderedSame : a < b ? .orderedAscending : .orderedDescending
}
if comparison == .orderedSame { return left.offset < right.offset }
return descending ? comparison == .orderedDescending : comparison == .orderedAscending
}.map(\.element)
}
}
struct WorkspacePresentation {
var sortedMatches: [MatchGroup] = []
var matchNumbers: [String: Int] = [:]
var categoryCounts: [String: Int] = [:]
var categoryPayments: [String: Double] = [:]
var attentionCount = 0
init(_ state: Workspace = Workspace()) {
sortedMatches = state.sortedMatches
for (index, match) in sortedMatches.enumerated() {
matchNumbers[match.id] = index + 1
categoryCounts[match.category, default: 0] += 1
categoryPayments[match.category, default: 0] += match.paymentTotal
if match.needsAttention { attentionCount += 1 }
}
}
func categoryColumns(for matches: [MatchGroup]) -> [MatchCategoryGroup] {
let grouped = Dictionary(grouping: matches, by: \.category)
let extraCategories = Set(grouped.keys).subtracting(expenseCategories).sorted()
return (expenseCategories + extraCategories).compactMap { category in
guard let items = grouped[category], !items.isEmpty else { return nil }
return MatchCategoryGroup(category: category, matches: items)
}
}
}
struct MatchCategoryGroup: Identifiable {
let category: String
let matches: [MatchGroup]
var id: String { category }
var title: String { category.isEmpty ? "未分类" : category }
var invoiceTotal: Double { matches.reduce(0) { $0 + $1.invoiceTotal } }
var paymentTotal: Double { matches.reduce(0) { $0 + $1.paymentTotal } }
}
+13 -4
View File
@@ -4,7 +4,10 @@ import UniformTypeIdentifiers
@MainActor @MainActor
final class WorkspaceStore: ObservableObject { final class WorkspaceStore: ObservableObject {
@Published var state = Workspace() @Published var state = Workspace() {
didSet { presentation = WorkspacePresentation(state) }
}
private(set) var presentation = WorkspacePresentation()
@Published var page: WorkspacePage? = .scan @Published var page: WorkspacePage? = .scan
@Published var selectedInvoices: Set<String> = [] @Published var selectedInvoices: Set<String> = []
@Published var selectedPayments: Set<String> = [] @Published var selectedPayments: Set<String> = []
@@ -21,6 +24,8 @@ final class WorkspaceStore: ObservableObject {
@Published var purposes: [String: String] = [:] @Published var purposes: [String: String] = [:]
@Published var signatures = defaultSignatures @Published var signatures = defaultSignatures
@Published var showExpenseTemplate = false @Published var showExpenseTemplate = false
@Published var showPayeeProfile = false
@Published var lastExportURL: URL?
@Published private(set) var expenseTemplate: SavedExpenseTemplate? @Published private(set) var expenseTemplate: SavedExpenseTemplate?
@Published private(set) var expenseTemplateLoadError: String? @Published private(set) var expenseTemplateLoadError: String?
@Published var templateDraft: ExpenseTemplateDraft? @Published var templateDraft: ExpenseTemplateDraft?
@@ -28,14 +33,15 @@ final class WorkspaceStore: ObservableObject {
@Published var showExpensePreview = false @Published var showExpensePreview = false
@Published private(set) var expensePreview: PreparedExpensePreview? @Published private(set) var expensePreview: PreparedExpensePreview?
@Published private(set) var expensePreviewError: String? @Published private(set) var expensePreviewError: String?
let payeeProfile = PayeeProfileStore() let payeeProfile: PayeeProfileStore
private let engine = EngineBridge() private let engine = EngineBridge()
private let storage: URL private let storage: URL
private var cancelRequested = false private var cancelRequested = false
private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) } private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) }
init() { init(storageURL: URL? = nil, payeeStorage: any PayeeStorage = KeychainPayeeStorage()) {
storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true) payeeProfile = PayeeProfileStore(storage: payeeStorage)
storage = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true)
do { do {
try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true)
let session = storage.appendingPathComponent("workspace.json") let session = storage.appendingPathComponent("workspace.json")
@@ -51,6 +57,7 @@ final class WorkspaceStore: ObservableObject {
} catch { } catch {
expenseTemplateLoadError = error.localizedDescription expenseTemplateLoadError = error.localizedDescription
} }
presentation = WorkspacePresentation(state)
restoreMatchExplanations() restoreMatchExplanations()
} }
@@ -481,6 +488,7 @@ final class WorkspaceStore: ObservableObject {
let data = try prepared.verifiedData(request: expenseExportRequest().request) let data = try prepared.verifiedData(request: expenseExportRequest().request)
try data.write(to: destination, options: .atomic) try data.write(to: destination, options: .atomic)
notice = "个人报销单已导出到 \(destination.lastPathComponent)" notice = "个人报销单已导出到 \(destination.lastPathComponent)"
lastExportURL = destination
showExpensePreview = false showExpensePreview = false
NSWorkspace.shared.activateFileViewerSelecting([destination]) NSWorkspace.shared.activateFileViewerSelecting([destination])
} catch { expensePreviewError = error.localizedDescription } } catch { expensePreviewError = error.localizedDescription }
@@ -523,6 +531,7 @@ final class WorkspaceStore: ObservableObject {
_ = try await run(request) _ = try await run(request)
try Data(contentsOf: temporary).write(to: destination, options: .atomic) try Data(contentsOf: temporary).write(to: destination, options: .atomic)
notice = "\(kind.title)已导出到 \(destination.lastPathComponent)" notice = "\(kind.title)已导出到 \(destination.lastPathComponent)"
lastExportURL = destination
NSWorkspace.shared.activateFileViewerSelecting([destination]) NSWorkspace.shared.activateFileViewerSelecting([destination])
} catch is CancellationError { } catch is CancellationError {
notice = "已取消导出" notice = "已取消导出"
+23 -3
View File
@@ -10,14 +10,17 @@ import SwiftUI
@main @main
struct reimburseApp: App { struct reimburseApp: App {
@StateObject private var store = WorkspaceStore() @StateObject private var store = WorkspaceStore()
@State private var area = "local"
var body: some Scene { var body: some Scene {
Window("贴票台", id: "workspace") { Window("贴票台", id: "workspace") {
TabView { TabView(selection: $area) {
AdminView().tabItem { Label("项目管理", systemImage: "person.3") } ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") }.tag("local")
ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") } AdminView().tabItem { Label("项目管理", systemImage: "person.3") }.tag("admin")
} }
.tint(AppTheme.accent)
.environmentObject(store) .environmentObject(store)
.modifier(WorkspaceDockProgress(busy: store.busy, progress: store.progress))
.task { .task {
#if DEBUG #if DEBUG
if ProcessInfo.processInfo.arguments.contains("--verify-local-engine") { if ProcessInfo.processInfo.arguments.contains("--verify-local-engine") {
@@ -32,6 +35,23 @@ struct reimburseApp: App {
Button("导入报账材料…", action: store.chooseFolder) Button("导入报账材料…", action: store.chooseFolder)
.keyboardShortcut("o").disabled(store.busy) .keyboardShortcut("o").disabled(store.busy)
} }
CommandMenu("本地贴票") {
Button("材料扫描") { area = "local"; store.page = .scan }.keyboardShortcut("1")
Button("人工配对") { area = "local"; store.page = .pair }.keyboardShortcut("2")
Button("已核对") { area = "local"; store.page = .matched }.keyboardShortcut("3")
Divider()
Button("搜索材料") {
NotificationCenter.default.post(name: WorkspaceCommand.find, object: nil)
}.keyboardShortcut("f").disabled(area != "local" || store.page == .scan)
Button("撤销最近核对组") {
if let match = store.state.matches.last { store.undo(match) }
}.keyboardShortcut("z", modifiers: [.command, .option])
.disabled(area != "local" || store.busy || store.state.matches.isEmpty)
Divider()
Button("报销模板…") { area = "local"; store.showExpenseTemplate = true }
.keyboardShortcut(",").disabled(store.busy)
Button("收款信息…") { area = "local"; store.showPayeeProfile = true }.disabled(store.busy)
}
} }
} }
} }
+306
View File
@@ -0,0 +1,306 @@
import AppKit
import SwiftUI
private struct EmptyPayeeStorage: PayeeStorage {
func read() throws -> Data? { nil }
func write(_ data: Data) throws {}
func delete() throws {}
}
private final class TestWindow: NSWindow {
override var canBecomeKey: Bool { true }
}
@main
struct InterfaceSmokeTests {
@MainActor static func main() throws {
_ = NSApplication.shared
NSApp.setActivationPolicy(.accessory)
let output = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true)
let store = WorkspaceStore(storageURL: output.appendingPathComponent("isolated-workspace"), payeeStorage: EmptyPayeeStorage())
let fixture = output.appendingPathComponent("receipt.png")
let receipt = NSImage(size: NSSize(width: 640, height: 420))
receipt.lockFocus()
NSColor.white.setFill()
NSRect(x: 0, y: 0, width: 640, height: 420).fill()
("电子发票\n\n测试材料 · 交通费用\n\n金额 ¥128.00\n\n2026-09-20" as NSString).draw(
in: NSRect(x: 36, y: 36, width: 560, height: 340),
withAttributes: [.font: NSFont.systemFont(ofSize: 25), .foregroundColor: NSColor.black])
receipt.unlockFocus()
let bitmap = NSBitmapImageRep(data: receipt.tiffRepresentation!)!
try bitmap.representation(using: .png, properties: [:])!.write(to: fixture)
try checkImageLoading(fixture: fixture, output: output)
let layout = try checkCellSelection()
var ocr = OCRData()
ocr.status = "success"; ocr.amounts = ["128.00"]; ocr.merchants = ["测试交通服务有限公司"]; ocr.dates = ["2026-09-20"]
func material(_ index: Int, type: String) -> Material {
Material(id: "\(type)-\(index)", name: "\(index)-测试交通费用凭证.pdf", path: fixture.path, type: type,
size: 2048, matched: false, previewPath: fixture.path, ocr: ocr,
displayAmount: 128, sortDate: "2026-09-20", issueDate: "2026-09-20")
}
var workspace = Workspace(rootPath: "九月项目报销")
workspace.invoices = (1...12).map { material($0, type: "invoice") }
workspace.payments = (1...10).map { material($0, type: "payment") }
workspace.warnings = (1...5).map { "材料 \($0) 日期待核对" }
workspace.directoryPaymentTotal = 1280
for index in 0..<7 {
var invoice = workspace.invoices[index], payment = workspace.payments[index]
invoice.matched = true; payment.matched = true
workspace.invoices[index] = invoice; workspace.payments[index] = payment
workspace.matches.append(MatchGroup(id: "match-\(index)", invoices: [invoice], payments: [payment],
category: expenseCategories[index], matchType: index == 0 ? "auto" : "manual",
score: 100, reasons: [], paymentTotal: 128, expenseAmount: 128))
}
store.state = workspace
store.updateCategory(workspace.matches[0], category: "住宿")
let moved = store.presentation.categoryColumns(for: store.state.matches)
precondition(!moved.contains(where: { $0.category == "餐饮" }))
precondition(moved.first(where: { $0.category == "住宿" })!.matches.count == 2)
precondition(store.state.matches.map(\.id) == workspace.matches.map(\.id))
store.updateCategory(store.state.matches[0], category: "餐饮")
precondition(store.presentation.categoryColumns(for: store.state.matches).map(\.id) == expenseCategories)
print("Changing category updates columns without changing match identifiers or export order")
let model = AdminStore()
model.access = AdminAccess(userId: "test", platform: true, admin: true, projects: [], groups: [])
model.expenses = (1...6).map { index in
AdminExpense(id: "\(index)", groupId: 1, projectId: 1, userId: "fixture",
name: "测试申请人\(index)", team: "制片组", projectName: "测试项目",
type: "交通差旅", amountCents: 12800, amountText: "128.00", note: "",
status: "待审核", state: "pending", date: "2026-09-20", version: 1, files: [:], events: [])
}
model.policies = [AdminExpensePolicy(type: "交通差旅", description: "项目交通费用", tips: "提供行程材料",
requiredKinds: [], materials: [], active: true)]
for dark in [false, true] {
NSApp.appearance = NSAppearance(named: dark ? .darkAqua : .aqua)
var pairWorkspace = workspace
pairWorkspace.matches = []
pairWorkspace.invoices = (1...12).map { material($0, type: "invoice") }
pairWorkspace.payments = (1...12).map { material($0, type: "payment") }
store.state = pairWorkspace
let invoiceIDs = WorkspaceFilter.materials(pairWorkspace.invoices, query: "", order: .date).map(\.id)
let paymentIDs = WorkspaceFilter.materials(pairWorkspace.payments, query: "", order: .date).map(\.id)
for (invoiceCount, paymentCount) in [(1, 1), (1, 6), (6, 1), (6, 6)] {
store.selectedInvoices = Set(invoiceIDs.prefix(invoiceCount))
store.selectedPayments = Set(paymentIDs.prefix(paymentCount))
try render(PairPage().environmentObject(store), size: CGSize(width: 1280, height: 800),
name: "pair-connections-\(invoiceCount)x\(paymentCount)-\(dark)", output: output)
}
store.selectedInvoices = []; store.selectedPayments = []
for size in [CGSize(width: 1280, height: 800), CGSize(width: 1024, height: 640), CGSize(width: 900, height: 560)] {
let suffix = "\(dark ? "dark" : "light")-\(Int(size.width))"
store.state = Workspace()
try render(ScanPage(confirmClear: .constant(false)).environmentObject(store), size: size, name: "empty-\(suffix)", output: output)
store.state = workspace
try render(ScanPage(confirmClear: .constant(false)).environmentObject(store), size: size, name: "scan-\(suffix)", output: output)
try render(PairPage().environmentObject(store), size: size, name: "pair-\(suffix)", output: output)
try render(MatchedPage().environmentObject(store), size: size, name: "matched-\(suffix)", output: output)
try render(AdminExpensesTab(model: model), size: size, name: "admin-\(suffix)", output: output)
try render(AdminPoliciesTab(model: model), size: size, name: "policies-\(suffix)", output: output)
for page in WorkspacePage.allCases {
store.page = page
try render(ContentView().environmentObject(store), size: size, name: "workspace-\(page.id)-\(suffix)", output: output)
}
}
var columnWorkspace = workspace
columnWorkspace.matches = (0..<12).map { index in
var match = workspace.matches[index % 3]
match.id = "column-\(index)"
return match
}
columnWorkspace.matches[0].payments = Array(workspace.payments.prefix(4))
let noisyCandidates = ["载次数", "名称:测试购买方有限公司", "全称", "一站式企业出行与商旅平台"]
for index in columnWorkspace.matches.indices {
columnWorkspace.matches[index].invoices[0].ocr.merchants = [noisyCandidates[index % noisyCandidates.count]]
}
store.state = columnWorkspace
precondition(store.presentation.categoryColumns(for: store.state.matches).map(\.id) == Array(expenseCategories.prefix(3)))
try render(MatchedPage().environmentObject(store), size: CGSize(width: 1024, height: 640),
name: "matched-populated-types-\(dark)", output: output)
try render(MatchedCategoryBoard(columns: store.presentation.categoryColumns(for: store.state.matches),
selection: .constant(MatchedPPTSelection(ids: ["column-0", "column-1"])))
.environmentObject(store), size: CGSize(width: 1900, height: 720), name: "category-columns-\(dark)", output: output)
try render(MatchedCategoryBoard(columns: store.presentation.categoryColumns(for: store.state.matches),
selection: .constant(MatchedPPTSelection(ids: ["column-0", "column-1"])))
.environmentObject(store), size: CGSize(width: 1000, height: 560), name: "category-compact-\(dark)", output: output)
try render(MatchedGroupInspector(matchID: "column-0", orderedIDs: columnWorkspace.matches.map(\.id),
selection: .constant(MatchedPPTSelection(ids: ["column-0"])),
navigate: { _ in }).environmentObject(store),
size: AppTheme.sheetSize(width: 1040, height: 740), name: "matched-inspector-\(dark)", output: output)
store.state = Workspace()
try render(MatchedPage().environmentObject(store), size: CGSize(width: 900, height: 560),
name: "matched-empty-\(dark)", output: output)
store.state = workspace
store.openExpense(selectedMatchIDs: Set(workspace.matches.map(\.id)))
let sheetSize = AppTheme.sheetSize(width: 900, height: 700)
try render(ExpenseSheet().environmentObject(store), size: sheetSize, name: "expense-\(dark)", output: output)
try render(AdminLoginView(model: AdminStore()), size: CGSize(width: 1024, height: 640), name: "login-\(dark)", output: output)
try render(ExpenseTemplateManager().environmentObject(store),
size: AppTheme.sheetSize(width: 1100, height: 760), name: "templates-\(dark)", output: output)
try render(ExpenseSpreadsheetPreview(layout: layout, selectedReference: "A1", highlightedRow: 2,
onSelectCell: { _ in }),
size: CGSize(width: 320, height: 500), name: "interactive-sheet-\(dark)", output: output)
}
print("Native light/dark compact/desktop screenshots passed: \(output.path)")
}
@MainActor static func checkCellSelection() throws -> SpreadsheetLayout {
let style = SpreadsheetStyle(font: "Arial", fontSize: 14, bold: false, italic: false,
underline: false, strike: false, color: "000000", fill: "FFFFFF",
horizontal: "left", vertical: "center", wrap: false, shrink: false,
rotation: 0, indent: 0, borders: [:], accounting: false)
let cells = (0..<8).map { row in
SpreadsheetCell(reference: "A\(row + 1)", x: 0, y: Double(row * 50), width: 240, height: 50,
text: "A\(row + 1) 测试单元格", style: style, overflowLeft: 0, overflowRight: 240)
}
let sheet = SpreadsheetSheet(name: "测试报销模板", width: 240, height: 400, cells: cells)
for scale in [0.5, 1, 1.5] {
let canvas = SpreadsheetCanvasView(sheet: sheet, scale: scale)
let window = NSWindow(contentRect: canvas.frame, styleMask: [.borderless], backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = canvas
var selected: String?
canvas.onSelectCell = { selected = $0 }
let location = canvas.convert(NSPoint(x: 100 * scale, y: 75 * scale), to: nil)
let event = NSEvent.mouseEvent(with: .leftMouseUp, location: location, modifierFlags: [],
timestamp: 0, windowNumber: window.windowNumber, context: nil,
eventNumber: 1, clickCount: 1, pressure: 1)!
canvas.mouseUp(with: event)
precondition(selected == "A2", "Scaled cell hit testing failed")
window.contentView = nil
window.close()
}
print("Spreadsheet cell selection passed at 50%, 100% and 150% zoom")
return try SpreadsheetLayout(version: 4, sheets: [sheet], warnings: []).validated()
}
@MainActor static func checkImageLoading(fixture: URL, output: URL) throws {
let pdf = output.appendingPathComponent("two-pages.pdf")
let data = NSMutableData()
var page = CGRect(x: 0, y: 0, width: 400, height: 600)
let context = CGContext(consumer: CGDataConsumer(data: data)!, mediaBox: &page, nil)!
for color in [CGColor(red: 1, green: 0, blue: 0, alpha: 1), CGColor(red: 0, green: 0, blue: 1, alpha: 1)] {
context.beginPDFPage(nil)
context.setFillColor(color)
context.fill(page)
context.endPDFPage()
}
context.closePDF()
try (data as Data).write(to: pdf)
var completed = false
Task { @MainActor in
let thumbnail = await MaterialImageLoader.load(fixture, pixels: 160)
precondition(thumbnail != nil && max(thumbnail!.size.width, thumbnail!.size.height) <= 160)
let image = await MaterialImageLoader.load(pdf, pixels: 300)
precondition(image != nil)
let bitmap = NSBitmapImageRep(data: image!.tiffRepresentation!)!
let center = bitmap.colorAt(x: bitmap.pixelsWide / 2, y: bitmap.pixelsHigh / 2)!.usingColorSpace(.deviceRGB)!
precondition(center.redComponent > 0.9 && center.blueComponent < 0.1, "Preview must use the first PDF page")
let missing = await MaterialImageLoader.load(output.appendingPathComponent("missing.png"), pixels: 160)
precondition(missing == nil)
let cancelled = Task { await MaterialImageLoader.load(fixture, pixels: 160) }
cancelled.cancel()
let result = await cancelled.value
precondition(result == nil)
completed = true
}
let deadline = Date().addingTimeInterval(15)
while !completed && Date() < deadline { RunLoop.current.run(until: Date().addingTimeInterval(0.02)) }
precondition(completed, "Image loading timed out")
print("Async image downsampling, PDF first page, missing files and cancellation passed")
}
@MainActor static func render<V: View>(_ view: V, size: CGSize, name: String, output: URL) throws {
let dark = NSApp.appearance?.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
let host = NSHostingView(rootView: view.tint(AppTheme.accent)
.environment(\.colorScheme, dark ? .dark : .light)
.background(Color(nsColor: .windowBackgroundColor)))
let window = TestWindow(contentRect: NSRect(origin: .zero, size: size), styleMask: [.borderless],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
window.contentView = host
window.appearance = NSApp.appearance
window.backgroundColor = .windowBackgroundColor
window.orderFront(nil)
host.layoutSubtreeIfNeeded()
RunLoop.current.run(until: Date().addingTimeInterval(0.35))
host.layoutSubtreeIfNeeded()
if name == "pair-light-900" || name == "matched-light-900" {
window.makeKey()
NotificationCenter.default.post(name: WorkspaceCommand.find, object: nil)
RunLoop.current.run(until: Date().addingTimeInterval(0.1))
precondition((window.firstResponder as? NSTextView)?.isFieldEditor == true,
"Find command did not focus the search field: \(name)")
print("Search keyboard focus passed: \(name)")
}
precondition(host.bounds.width <= size.width + 1 && host.bounds.height <= size.height + 1,
"View exceeds viewport: \(name), \(host.bounds.size)")
let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds)!
host.cacheDisplay(in: host.bounds, to: bitmap)
if name.hasPrefix("pair-connections-") {
try checkPairScrolling(host: host, baseline: bitmap, name: name, output: output)
}
var shades = Set<Int>()
for x in stride(from: 0, to: bitmap.pixelsWide, by: 11) {
for y in stride(from: 0, to: bitmap.pixelsHigh, by: 11) {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) {
shades.insert(Int((color.redComponent + color.greenComponent + color.blueComponent) * 85))
}
}
}
precondition(shades.count > 8, "Blank or incomplete rendering: \(name)")
try bitmap.representation(using: .png, properties: [:])!.write(to: output.appendingPathComponent(name + ".png"))
window.orderOut(nil)
window.contentView = nil
window.close()
}
@MainActor static func checkPairScrolling(host: NSView, baseline: NSBitmapImageRep, name: String, output: URL) throws {
func descendants(_ view: NSView) -> [NSView] {
view.subviews.flatMap { [$0] + descendants($0) }
}
let scrolls = descendants(host).compactMap { $0 as? NSScrollView }
.filter { $0.documentView != nil && $0.bounds.width > 100 }
.sorted { host.convert($0.bounds, from: $0).minX < host.convert($1.bounds, from: $1).minX }
precondition(scrolls.count == 2, "Expected two native material scroll views")
func connectorPixels(_ bitmap: NSBitmapImageRep) -> Set<Int> {
let scale = CGFloat(bitmap.pixelsWide) / host.bounds.width
let center = Int(host.bounds.midX * scale)
let top = Int(150 * scale), bottom = Int((host.bounds.height - 90) * scale)
var pixels = Set<Int>()
for x in (center - 8)...(center + 8) {
for y in top..<bottom {
if let color = bitmap.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB),
color.greenComponent > color.redComponent + 0.12,
color.blueComponent > color.redComponent + 0.12 {
pixels.insert(y * bitmap.pixelsWide + x)
}
}
}
return pixels
}
var previous = connectorPixels(baseline)
precondition(previous.count > 10, "Selected pair lines missing without hover: \(name)")
let firstRowLimit = Int(host.bounds.height * 0.7 * CGFloat(baseline.pixelsWide) / host.bounds.width)
precondition(previous.contains { $0 / baseline.pixelsWide < firstRowLimit },
"Visible first-row cards are not connected: \(name)")
for (step, scroll) in [scrolls[1], scrolls[0], scrolls[1]].enumerated() {
let target = step == 2 ? CGFloat(0) : CGFloat(330)
scroll.contentView.scroll(to: NSPoint(x: 0, y: target))
scroll.reflectScrolledClipView(scroll.contentView)
RunLoop.current.run(until: Date().addingTimeInterval(0.2))
host.layoutSubtreeIfNeeded()
precondition(abs(scroll.contentView.bounds.minY - target) < 2, "Native scroll did not move")
let snapshot = host.bitmapImageRepForCachingDisplay(in: host.bounds)!
host.cacheDisplay(in: host.bounds, to: snapshot)
let current = connectorPixels(snapshot)
precondition(current.count > 10, "Pair lines vanished on scroll: \(name), step \(step)")
precondition(current != previous, "Pair line positions did not update on scroll: \(name), step \(step)")
previous = current
try snapshot.representation(using: .png, properties: [:])!
.write(to: output.appendingPathComponent("\(name)-scroll-\(step).png"))
}
print("Persistent unhovered pair lines and independent native scrolling passed: \(name)")
}
}
+82
View File
@@ -0,0 +1,82 @@
import Foundation
import CoreGraphics
@main
struct PairConnectionLayoutTests {
static func main() {
let left = CGRect(x: 0, y: 80, width: 300, height: 500)
let right = CGRect(x: 340, y: 80, width: 300, height: 500)
let invoices = (0..<4).map { "i\($0)" }, payments = (0..<4).map { "p\($0)" }
var frames: [String: CGRect] = [:]
for index in 0..<4 {
frames[invoices[index]] = CGRect(x: 3, y: 90 + index * 110, width: 290, height: 100)
frames[payments[index]] = CGRect(x: 343, y: 90 + index * 110, width: 290, height: 100)
}
func layout(_ selectedInvoices: Set<String>, _ selectedPayments: Set<String>,
_ currentFrames: [String: CGRect]) -> PairConnectionLayout {
PairConnectionLayout(invoiceIDs: invoices, paymentIDs: payments,
selectedInvoices: selectedInvoices, selectedPayments: selectedPayments,
frames: currentFrames, invoiceViewport: left, paymentViewport: right)
}
for (invoiceCount, paymentCount) in [(1, 1), (1, 4), (4, 1), (4, 4)] {
let result = layout(Set(invoices.prefix(invoiceCount)), Set(payments.prefix(paymentCount)), frames)
precondition(result.representedPairCount == invoiceCount * paymentCount)
precondition(result.invoices.count == invoiceCount && result.payments.count == paymentCount)
precondition(result.invoices.allSatisfy { $0.overflow == nil })
precondition(result.payments.allSatisfy { $0.overflow == nil })
precondition(result.lines.allSatisfy { result.clipRect.contains($0.start) && result.clipRect.contains($0.end) })
}
precondition(layout([], Set(payments), frames).lines.isEmpty)
precondition(layout(Set(invoices), [], frames).lines.isEmpty)
let initial = layout(["i0"], Set(payments), frames)
var scrolled = frames
for id in payments { scrolled[id] = frames[id]!.offsetBy(dx: 0, dy: -160) }
let afterScroll = layout(["i0"], Set(payments), scrolled)
precondition(afterScroll.invoices.map(\.point) == initial.invoices.map(\.point))
precondition(afterScroll.payments.map(\.point) != initial.payments.map(\.point))
precondition(afterScroll.payments.reduce(0) { $0 + $1.count } == 4)
precondition(afterScroll.payments.first(where: { $0.overflow == .above })?.count == 1)
precondition(afterScroll.representedPairCount == 4)
// Offscreen anchors can disappear from LazyVGrid without removing their selected relationships.
scrolled.removeValue(forKey: "p0")
let recycled = layout(["i0"], Set(payments), scrolled)
precondition(recycled.lines == afterScroll.lines)
scrolled.removeValue(forKey: "p3")
let bothEdges = layout(Set(invoices), Set(payments), scrolled)
precondition(bothEdges.payments.first(where: { $0.overflow == .above })?.count == 1)
precondition(bothEdges.payments.first(where: { $0.overflow == .below })?.count == 1)
precondition(bothEdges.invoices.reduce(0) { $0 + $1.count } * bothEdges.payments.reduce(0) { $0 + $1.count } == 16)
var partial = frames
partial["p0"] = CGRect(x: 343, y: 20, width: 290, height: 100)
let clipped = layout(["i0"], ["p0"], partial)
precondition(clipped.payments[0].point.y == 100)
precondition(clipped.payments[0].overflow == nil)
let allRecycled = layout(Set(invoices), Set(payments), [:])
precondition(allRecycled.invoices[0].count == 4 && allRecycled.payments[0].count == 4)
precondition(allRecycled.representedPairCount == 16 && allRecycled.lines.count <= 3)
let filtered = PairConnectionLayout(invoiceIDs: ["i0"], paymentIDs: ["p1"], selectedInvoices: Set(invoices),
selectedPayments: Set(payments), frames: frames, invoiceViewport: left, paymentViewport: right)
precondition(filtered.representedPairCount == 1)
let empty = PairConnectionLayout(invoiceIDs: invoices, paymentIDs: payments, selectedInvoices: Set(invoices),
selectedPayments: Set(payments), frames: frames, invoiceViewport: .zero, paymentViewport: right)
precondition(empty.lines.isEmpty)
let grid: [String: CGRect] = [
"i0": CGRect(x: 3, y: 90, width: 140, height: 150),
"i1": CGRect(x: 155, y: 90, width: 140, height: 150),
"p0": CGRect(x: 343, y: 90, width: 140, height: 150),
"p1": CGRect(x: 495, y: 90, width: 140, height: 150)
]
let routed = layout(["i0", "i1"], ["p0", "p1"], grid)
precondition(routed.representedPairCount == 4)
for line in routed.lines {
let stroke = CGRect(x: min(line.start.x, line.end.x) - 0.5, y: min(line.start.y, line.end.y) - 0.5,
width: abs(line.end.x - line.start.x) + 1, height: abs(line.end.y - line.start.y) + 1)
precondition(grid.values.allSatisfy { !stroke.intersects($0.insetBy(dx: 1, dy: 1)) },
"Connection crosses a card's contents")
}
print("Pair connections: 1:1, 1:N, N:1, N:M, independent scrolling, clipping, recycled cells and filtering passed")
}
}
@@ -0,0 +1,29 @@
import Foundation
@main
struct TemplateMappingValidationTests {
static func main() {
for value in ["A", "c", "IV"] { precondition(TemplateMappingValidation.column(value)) }
for value in ["", "A1", "IW", "1", "=A"] { precondition(!TemplateMappingValidation.column(value)) }
for value in ["C23", "iv2000", "A1"] { precondition(TemplateMappingValidation.address(value)) }
for value in ["A0", "A2001", "A01", "IW1", "C", "C23:D24"] { precondition(!TemplateMappingValidation.address(value)) }
var mapping = ExpenseTemplateMapping(sheetName: "Sheet", columns: ["purpose": "C", "amount": "H"],
cells: ["recipient": "C23"], signatureCells: "A30,D30")
precondition(TemplateMappingValidation.issues(mapping).isEmpty)
mapping.cells["bankName"] = " "
precondition(TemplateMappingValidation.issues(mapping).isEmpty)
mapping.columns["amount"] = "C"
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
mapping.columns["amount"] = "H"
mapping.cells["recipient"] = "C10"
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
mapping.cells["recipient"] = "C23"
mapping.clearCells = "C23"
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
mapping.clearCells = ""
mapping.endRow = 8
precondition(!TemplateMappingValidation.issues(mapping).isEmpty)
precondition(TemplateMappingValidation.references("A1B2; C3\nD4") == ["A1", "B2", "C3", "D4"])
print("Template column/address bounds, duplicate targets and row validation passed")
}
}
+105
View File
@@ -0,0 +1,105 @@
import Foundation
@main
struct WorkspacePresentationTests {
static func material(_ id: String, amount: Double, date: String, merchant: String = "测试商户") -> Material {
var ocr = OCRData()
ocr.status = "success"
ocr.amounts = [String(format: "%.2f", amount)]
ocr.merchants = [merchant]
return Material(id: id, name: id + ".pdf", path: "", type: "invoice", size: 0, matched: false,
previewPath: "", ocr: ocr, displayAmount: amount, sortDate: date, issueDate: date)
}
static func main() throws {
let a = material("a", amount: 10, date: "2026-09-02", merchant: "铁路")
let b = material("b", amount: 100, date: "2026-09-01")
let c = material("c", amount: 20, date: "2026-09-03")
let items = [a, b, c]
precondition(WorkspaceFilter.materials(items, query: "", order: .amount).map(\.id) == ["a", "c", "b"])
precondition(WorkspaceFilter.materials(items, query: "", order: .date).map(\.id) == ["b", "a", "c"])
precondition(WorkspaceFilter.materials(items, query: "铁路 10.00", order: .name).map(\.id) == ["a"])
precondition(WorkspaceFilter.materials(items, query: "B.PDF", order: .name).map(\.id) == ["b"])
precondition(WorkspaceFilter.materials(items, query: "2026-09-03", order: .name).map(\.id) == ["c"])
precondition(WorkspaceFilter.materials(items, query: "", order: .amount, descending: true).map(\.id) == ["b", "c", "a"])
var automatic = MatchGroup(id: "auto", invoices: [a], payments: [], category: "交通", matchType: "auto", score: 100, reasons: [])
let manual = MatchGroup(id: "manual", invoices: [b], payments: [], category: "其他", matchType: "manual", score: 100, reasons: [])
precondition(automatic.needsAttention && !manual.needsAttention)
automatic.explanation = try JSONDecoder().decode(MatchExplanation.self, from: Data("""
{"version":2,"title":"test","rawScore":55,"checks":[{"title":"amount","points":55,"maximum":100,"detail":"","issue":""}],"note":""}
""".utf8))
precondition(automatic.needsAttention)
var failed = manual
failed.id = "failed"
failed.invoices[0].ocr.status = "failed"
precondition(failed.needsAttention)
let matches = [automatic, manual, failed]
precondition(WorkspaceFilter.matches(matches, query: "", category: "全部", attentionOnly: true, order: .date).count == 2)
let visible = WorkspaceFilter.matches(matches, query: "铁路", category: "交通", attentionOnly: false, order: .date)
precondition(visible.map(\.id) == ["auto"])
var selection = MatchedPPTSelection(ids: ["manual"])
selection.setVisible(visible, selected: true)
precondition(selection.ids == ["manual", "auto"])
selection.setVisible(visible, selected: false)
precondition(selection.ids == ["manual"])
var workspace = Workspace()
workspace.matches = matches
let presentation = WorkspacePresentation(workspace)
precondition(presentation.matchNumbers.count == 3)
precondition(presentation.categoryCounts["其他"] == 2 && presentation.attentionCount == 2)
precondition(workspace.matches.map(\.id) == ["auto", "manual", "failed"])
let columns = presentation.categoryColumns(for: matches)
precondition(columns.map(\.category) == ["交通", "其他"])
precondition(columns.allSatisfy { !$0.matches.isEmpty })
let traffic = columns.first(where: { $0.category == "交通" })!
let other = columns.first(where: { $0.category == "其他" })!
precondition(traffic.matches.map(\.id) == ["auto"] && traffic.invoiceTotal == 10)
precondition(other.matches.map(\.id) == ["manual", "failed"] && other.invoiceTotal == 200)
precondition(columns.flatMap(\.matches).count == matches.count)
selection.setVisible(traffic.matches, selected: true)
selection.setVisible(other.matches, selected: true)
selection.setVisible(traffic.matches, selected: false)
precondition(selection.ids == ["manual", "failed"])
let filteredColumns = presentation.categoryColumns(for: visible)
precondition(filteredColumns.map(\.id) == ["交通"])
precondition(selection.ids == ["manual", "failed"])
precondition(presentation.categoryColumns(for: matches).map(\.id) == ["交通", "其他"])
let sorted = WorkspaceFilter.matches(matches, query: "", category: "全部", attentionOnly: false, order: .date)
precondition(presentation.categoryColumns(for: sorted).first(where: { $0.category == "其他" })!.matches.map(\.id) == ["manual", "failed"])
workspace.matches[1].category = "住宿"
var updated = WorkspacePresentation(workspace)
precondition(updated.categoryColumns(for: workspace.matches).first(where: { $0.category == "住宿" })!.matches.map(\.id) == ["manual"])
precondition(selection.ids == ["manual", "failed"])
let exported = try selection.exportWorkspace(from: workspace)
precondition(exported.matches.map(\.id) == ["manual", "failed"])
workspace.matches[2].category = "住宿"
updated = WorkspacePresentation(workspace)
precondition(updated.categoryColumns(for: workspace.matches).map(\.id) == ["住宿", "交通"])
workspace.matches.removeAll { $0.category == "住宿" }
precondition(WorkspacePresentation(workspace).categoryColumns(for: workspace.matches).map(\.id) == ["交通"])
workspace.matches = matches
workspace.matches[1].category = "住宿"
workspace.matches[0].category = "历史类型"
workspace.matches[2].category = ""
updated = WorkspacePresentation(workspace)
let extra = updated.categoryColumns(for: workspace.matches)
precondition(extra.map(\.id) == ["住宿", "", "历史类型"])
precondition(extra.first(where: { $0.category == "" })!.title == "未分类")
precondition(extra.first(where: { $0.category == "历史类型" })!.matches.map(\.id) == ["auto"])
precondition(updated.categoryColumns(for: []).isEmpty)
precondition(WorkspacePresentation().categoryColumns(for: []).isEmpty)
var noisy = manual
noisy.invoices[0].ocr.merchants = ["载次数", "名称:测试购买方有限公司", "全称", "一站式企业出行与商旅平台"]
let originalCandidates = noisy.invoices[0].ocr.merchants
precondition(WorkspaceFilter.matches([noisy], query: "测试购买方", category: "全部",
attentionOnly: false, order: .date).map(\.id) == [manual.id])
precondition(noisy.invoices[0].ocr.merchants == originalCandidates)
precondition(MatchedBoardLayout.columnWidth(available: 900, count: 7) == 132)
let desktopWidth = MatchedBoardLayout.columnWidth(available: 1100, count: 7)
precondition(abs(desktopWidth * 7 + MatchedBoardLayout.gap * 6 - 1100) < 0.001)
precondition(MatchedBoardLayout.columnWidth(available: 700, count: 1) == 700)
precondition(MatchedBoardLayout.columnWidth(available: 0, count: 0).isFinite)
print("Original OCR search preservation and compact category layout sizing passed")
print("Search, numeric sorting, attention filtering, cached summaries and hidden selection retention passed")
print("Automatic empty-column removal, category reappearance, filtering, selection and unchanged export order passed")
}
}
+50
View File
@@ -0,0 +1,50 @@
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const output = mkdtempSync(join(tmpdir(), 'receipt-ui-checks-'));
console.log(`Validation output: ${output}`);
const domain = ['Models', 'MatchExplanation'];
const cases = {
PairConnectionLayout: ['PairConnectionLayout'],
WorkspacePresentation: [...domain, 'WorkspacePresentation', 'MatchedPPTSelection'],
TemplateMappingValidation: ['ExpenseTemplate', 'TemplateMappingValidation'],
MatchedPPTSelection: [...domain, 'MatchedPPTSelection'],
ExpenseSummary: [...domain, 'MatchedPPTSelection', 'ExpenseSummary'],
MatchExplanation: domain,
ExpenseTemplate: ['ExpenseTemplate'],
ExpensePreview: ['ExpenseTemplate', 'ExpensePreview', 'SpreadsheetLayout'],
FolderImportSource: ['FolderImportSource'],
PayeeProfile: ['PayeeProfile'],
AdminAttachments: ['AdminModels'],
AdminPPTSelection: ['AdminModels'],
AdminPolicyEditor: ['AdminModels', 'AdminStore'],
};
function run(command, args) {
const result = spawnSync(command, args, { cwd: root, stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
if (!process.argv.includes('--render-only')) {
for (const [name, sources] of Object.entries(cases)) {
const binary = join(output, name);
run('swiftc', [...sources.map(source => `reimburse/${source}.swift`), `tests/${name}Tests.swift`, '-o', binary]);
run(binary, []);
}
console.log(`${Object.keys(cases).length} Swift test suites passed.`);
}
if (process.argv.includes('--render') || process.argv.includes('--render-only')) {
const sources = readdirSync(join(root, 'reimburse'))
.filter(name => name.endsWith('.swift') && name !== 'reimburseApp.swift')
.map(name => `reimburse/${name}`);
const binary = join(output, 'InterfaceSmokeTests');
run('swiftc', [...sources, 'tests/InterfaceSmokeTests.swift', '-o', binary]);
run(binary, [join(output, 'screenshots')]);
}
console.log(`Validation output: ${output}`);
+158
View File
@@ -0,0 +1,158 @@
# 贴票台 · 界面优化方案
> 依据当前代码(`reimburse/` 下 26 个 Swift 视图文件)整理,只提优化建议,不改动代码。
> 应用结构:顶层 TabView`项目管理 AdminView` + `本地贴票 ContentView`),本地贴票内含 `材料扫描 ScanPage``人工配对 PairPage``已核对 MatchedPage` 三个页面,以及报销单模板、收款信息、报销单预览等弹窗。
---
## 一、现状盘点
### 信息架构
| 层级 | 内容 | 现状评价 |
|---|---|---|
| 顶层 Tab | 项目管理 / 本地贴票 | 应用名为"贴票台"且主功能是本地贴票,但项目管理排在第一个 Tab,与产品定位略有错位 |
| 本地贴票 | 侧栏 3 页 + 顶栏统计 + 工具栏 3 按钮 | 结构清晰,导航合理 |
| 弹窗 | 报销模板(1000×700)、收款信息(宽 580)、个人报销单(870×620)、预览(动态)、材料预览(1000×700) | 尺寸规格不统一,部分固定尺寸在小屏上有溢出风险 |
### 已做好的地方(应保留)
- `MaterialCard` 卡片 + Canvas 贝塞尔连线的配对交互,直观且具辨识度;
- `MatchExplanationPopover` 的 100 分制证据评分明细,透明度高;
- 拖拽导入的 dropDestination 高亮反馈、`.help()` 悬停提示、`accessibilityLabel` 覆盖较全;
- Table + 分组 Picker + 跨分组保留勾选的选择模型,业务逻辑扎实。
---
## 二、设计系统层面(全局问题)
### 2.1 颜色语义不统一 ⭐ 高优先级
- **现状**:teal 同时承担「品牌色、强调色、成功色、进行中状态」四种语义(侧栏图标、付款金额、匹配满分、通知横幅全是 teal);警告用 orange、错误用 red、AdminView 里成功又用 green。同一语义在两个 Tab 中颜色不同。
- **建议**
1. 建立语义色枚举(如 `AppTheme.success / warning / danger / accent`),收敛到一处定义;
2. teal 保留为品牌/强调色;成功态用系统 green、警告 orange、危险 red,与项目管理端对齐;
3. 「自动匹配 ≥90 分」与「人工确认」目前用 teal / blue 区分,建议改为统一的徽章组件(形状+图标+色),而非仅靠文字前缀。
- 金额显示可遵循国内财务习惯:发票与付款两列总额(topbar)目前一灰一 teal,对比弱,建议付款列加图标或用同色不同字重,避免用户误读为涨跌。
### 2.2 深色模式适配缺失 ⭐ 高优先级
- **现状**`MaterialThumbnail``Color.white` 背景、`SpreadsheetPreview / ExpenseTemplateManager` 大量 `.background(.white)`、预览区 `.black.opacity(0.04)`。深色模式下会出现大面积刺眼的白色块与文字对比不足。
- **建议**:表格/缩略图区保留白底是合理的(还原纸张观感),但外层容器、边框、占位图标应改用 `Color(nsColor: .textBackgroundColor)` 或材质色;至少验证 `Window` 在深色外观下所有页面无纯白大色块、无低对比 caption。
### 2.3 排版与间距 token 化
- **现状**`padding(28) / (26) / (22) / (20)``cornerRadius 16 / 13 / 12 / 10 / 9 / 8`、字号 `.title2 / .title3 / .system(size: 34 / 44 / 42)` 混用,页面间无对应关系。
- **建议**:定义间距(4 的倍数:8/12/16/24/28)与圆角(8 卡片内、12 卡片、16 面板)两档规格;页面主标题统一为 `.title2.bold()`(当前 PairPage 是 title3、MatchedPage 是 title2)。
### 2.4 组件重复实现
- `AdminView` 内自建了 `statusBadge / sectionCard / searchField / emptyState``ContentView` 一侧有 `Panel / EmptyPanel`,两套体系风格接近但参数不同。
- **建议**:抽出共享组件库(Badge、Card、SearchField、EmptyState、StatNumber),两个 Tab 复用,减少后续维护分叉。
---
## 三、页面级优化
### 3.1 材料扫描 ScanPage
| 问题 | 建议 | 优先级 |
|---|---|---|
| 空工作区时仍显示全部统计面板(全是 0),首屏信息密度高但价值低 | 无材料时展示「导入引导」大空状态:仅保留拖拽卡片 + 模板卡片,统计面板在导入后再出现 | P1 |
| 单列滚动太长:导入卡、模板卡、警告、3 个统计卡、2 个金额面板、分类汇总、底部计数,关键动作「前往人工配对」沉底 | ① 统计卡片改为一行 4 列紧凑指标条;②「前往人工配对」在有待配对材料时上浮为醒目入口(如导入完成后的成功横幅内嵌跳转按钮);③ 分类汇总与金额概览合并为一个「金额」面板 | P1 |
| 拖拽高亮 `opacity 0→1` 突变 | 加 `withAnimation(.easeInOut(duration: 0.15))`,并让图标/文字随 targeted 状态做位移或缩放微动效 | P2 |
| warnings 面板无法关闭、数量多时占据大量空间 | 逐条可关闭 + 超过 3 条折叠为「N 条提醒」;每条加对应的跳转动作(如跳转到识别失败的材料) | P2 |
| 三个统计卡数字 `size: 34` 硬编码,视觉与「金额概览」面板重复 | 数字统一走 `StatNumber` 组件(`.rounded` 数字字体 + tabular),金额面板可考虑用小图表(分类占比条)替代纯文本 | P3 |
### 3.2 人工配对 PairPage
| 问题 | 建议 | 优先级 |
|---|---|---|
| 材料多时无法定位:无搜索、无排序切换、无金额/日期过滤 | 每栏顶部加搜索框(文件名/商户/金额)+ 排序菜单(日期/金额/文件名),复用 AdminView 的 `searchField` 样式 | P0 |
| 已选 N 张发票 × N 张付款为全连接,选 5×5 时 25 条虚线交叉难以阅读 | 连线改为:hover/聚焦某卡片时只高亮与它相关的连线;或选中的卡片显示序号,连线带序号标签 | P1 |
| 只有 Return 键可操作选中,无方向键导航 | 利用现有 `.focusable()` 补充 ↑↓ 移动焦点、Space 选中,形成完整键盘流;说明文字中标注快捷键 | P1 |
| 缩略图固定 180pt 网格,看不清细节需逐个开预览 | 增加缩放滑杆(紧凑/标准/大图三档),或双击卡片直接放大预览后可从预览中「选中此材料」 | P2 |
| 确认配对按钮位于右上角,与底部的选中信息分离 | 选中数 >0 时在两栏之间显示居中的「确认配对(N×M)」悬浮按钮,动线更短 | P2 |
| 空状态文案两栏雷同 | 左栏空时提示「所有发票已处理或已核对」,右栏提示对应付款文案,并可放「撤销上一组」入口 | P3 |
### 3.3 已核对 MatchedPage
| 问题 | 建议 | 优先级 |
|---|---|---|
| 无搜索/过滤:几十组材料时找特定金额/商户困难 | 表格上方加搜索框(金额、商户、日期),支持过滤后再勾选 | P0 |
| `MaterialCell` 只列前 2 个文件名链接,「还有 N 份」不可展开 | 点击单元格弹出 popover 缩略图网格,与 PairPage 的卡片视觉统一;>2 份时显示「+N」叠图效果 | P1 |
| 底部三行 caption 说明文字(勾选规则、PPT 版式)常驻 | 收敛为一个「导出说明」帮助按钮(`questionmark.circle`)弹出;常用信息已在按钮禁用态与选中计数中体现 | P2 |
| 分组多时 segmented Picker 挤压变形 | 分类 ≥5 个时改用 Menu + 下拉,或横向滚动 chips | P2 |
| 「操作」列撤销仅图标按钮,与分类 Picker 挤在一列 | 撤销改为 hover 行时出现的 trailing 按钮或右键菜单;分类修改也可考虑双击单元格弹出,减少列宽 | P2 |
| 表格无小计:分组切换后看不到当前分组金额合计 | 表格底部(或分组标题旁)显示「本分组合计:发票 X · 付款 Y」 | P2 |
| 评分 <90 分为橙色但没有聚合入口(「哪些组需要人工复核」) | 增加「需关注」过滤(评分 <90 或识别失败),配合 P0 搜索框;侧栏「已核对」徽标可显示低分组数量 | P1 |
### 3.4 个人报销单 ExpenseSheet 与预览
| 问题 | 建议 | 优先级 |
|---|---|---|
| 固定 `frame(width: 870, height: 620)`,小屏(MacBook Air 13" 缩放)可能顶出屏幕 | 改为 `min` 上限策略(参照 `ExpensePreviewSheet``min(1200, visibleFrame - 80)` 写法) | P1 |
| 费用行用途 TextField 无校验反馈(空值、重复用途不易发现) | 空用途时行内灰字提示;重复用途合并提示 | P3 |
| 签字岗位 minus.circle 无确认,误触即删 | 删除加轻量确认或支持撤销 toast;输入框加默认岗位快选 Menu | P3 |
| `expenseSelectionValid` 失效只显示一行红字,动作指引弱 | 失效时禁用预览按钮并把红字放进醒目横幅,附「返回重新选择」按钮 | P2 |
| 预览弹窗已较好;「返回修改」与「确认并保存」并排但视觉主次不明 | 确认按钮保持 prominent 即可;建议保存成功后用横幅告知保存路径并提供「在访达中显示」 | P2 |
### 3.5 报销模板管理 ExpenseTemplateManager / ExpenseTemplateFields
| 问题 | 建议 | 优先级 |
|---|---|---|
| 模板字段调整(列字母/单元格地址)要求用户手工填 "C" "C23",认知门槛高 | ① 输入框支持点选:在左侧表格预览上点击单元格即回填地址(SwiftUI 可用 onTapGesture + 布局信息);② 输入时实时校验字母/地址格式并给出示例反馈 | P1 |
| 「查看单元格地址与原文」DisclosureGroup 是纯文本行,对照困难 | 与左侧预览联动:hover 某行时高亮预览中对应行;保留行号列 | P3 |
| 未识别完整时只有一行橙色警告,确认按钮置灰原因不直观 | 每个缺失必填项(用途/金额列)在对应输入框旁标橙点,按钮旁注明「还差 N 项」 | P2 |
| 模板卡片(ExpenseTemplateSummary)与导入卡片视觉相似,都是 teal 虚线框 | 模板卡片改为普通卡片 + 右上「模板」徽标,虚线拖拽样式只留给导入场景,减少误拖 | P3 |
### 3.6 收款信息 PayeeProfileSheet
- 现状已经较好(SecureField + 显隐切换 + 删除确认)。可补充:保存成功后 toast 确认;`PayeeProfileSummary` 中「尾号 XXXX」处加复制按钮。
- 优先级 P3。
### 3.7 项目管理 AdminView
| 问题 | 建议 | 优先级 |
|---|---|---|
| 单文件 1364 行、50+ 个 `@State`,六个页签全部内联 | 拆分为独立 View 文件(`AdminExpensesTab` 等);这不是纯界面问题,但直接影响后续所有 UI 迭代效率 | P1(技术债) |
| 自绘 tab 条(navigationItem)与 macOS 惯例不符,窗口宽度不足时六个标签挤压 | 采用 macOS 15 可用的 `TabView` 新样式或保留但允许横向滚动 + 更多收纳进 Menu | P2 |
| 登录页固定 400 宽输入区 + ScrollView,视觉重心偏上 | 居中卡片式布局;服务地址输入记忆历史地址下拉;微信扫码过期自动刷新提示 | P2 |
| 报销审批表无分页指示、无列排序 | Table 补充排序(金额/日期);行数多时显示计数 | P2 |
| 错误横幅红色整条较突兀 | 收敛为顶部统一通知组件(同本地贴票端的 notice 样式),成功/警告/错误三态 | P2 |
---
## 四、全局交互与反馈
1. **统一通知体系**(P1):目前成功提示是自定义 teal 横幅、错误是 `alert`、Admin 是红横幅。建议统一为三态通知条(成功/警告/错误),支持多条堆叠与自动消失(错误不自动消失),并复用 macOS 15 的 `SymbolEffect`
2. **菜单栏与快捷键**(P2):已有 ⌘O 导入。建议补充:⌘Z 撤销最近核对组(复用 store.undo)、⌘F 聚焦搜索(配合 P0 搜索项)、⌘1/2/3 切换三个页面、⌘, 打开设置类弹窗(模板/收款信息)。
3. **空状态体系**P1):EmptyPanel 目前只有图标+标题+说明,可按页面补一个行动按钮(如「选择文件夹」「前往人工配对」),形成闭环。
4. **进度反馈**P2):busy 时是整页 `disabled` + 顶部进度条;扫描大目录时建议在 Dock 图标显示进度(NSApp.dockTile.progress),并保留页面滚动可读。
5. **危险操作确认**(P2):「清空重置」的 confirmationDialog 文案已很详细,但「恢复内置模板」「删除收款信息」等破坏性操作可统一加「需要输入确认/二次点击」模式中的轻量档:按钮文字改为具体后果(如「移除本机模板副本」)。
---
## 五、性能相关(影响体感的界面项)
| 问题 | 建议 | 优先级 |
|---|---|---|
| `MaterialPreview``NSImage(contentsOf:)` 在主线程同步解码整图,大图/PDF 首页可能卡顿 | 改为 `task` 异步加载 + 占位 ProgressView;预览窗口打开先显示缩略图再换全图 | P1 |
| `Workspace.sortedMatches``matches(for:)``groups`、表格行的 `index(of:)` 都是每次重算的 computed 属性,匹配组多时 Table 滚动会重复计算 | 在 Store 层缓存排序结果(matches 变更时失效);`index(of:)``Dictionary(id→index)` 替代 | P2 |
| ScanPage 分类汇总对每个 category 全量 filter7 × N | 一次遍历 reduce 成字典再渲染 | P3 |
| `MaterialThumbnail` 700px 缩略图生成已异步(好);但 `task(id: previewURL)` 取消旧任务的行为依赖 detached task,建议显式检查 `Task.isCancelled` | 补充取消检查,避免快速滚动时旧图覆盖新图 | P2 |
---
## 六、无障碍与本地化细节
- caption / caption2 + `.tertiary` 的组合在浅色模式下对比度偏低(如「支持图片和 PDF · PDF 处理首页」),建议 tertiary 只用于辅助信息,主说明用 secondary。
- `MatchConfidenceCell` 的颜色(teal/orange)承载了语义,建议在 accessibilityLabel 中补充「评分高/需关注」文字(目前已有百分比,可再补状态词)。
- PairPage 连线是 Canvas 绘制,VoiceOver 完全不可见;建议在读出的标签中描述配对关系(已有 accessibilityLabel 基础,补充「已与 N 张付款连线」)。
---
## 七、实施路线图
| 阶段 | 内容 | 预期收益 |
|---|---|---|
| **P0(先做)** | PairPage / MatchedPage 搜索与排序;低分组「需关注」过滤 | 解决材料量大时的核心查找痛点 |
| **P1** | 颜色语义统一 + 深色模式适配;语义色/间距 token 化;空状态行动按钮;ScanPage 信息层级重组;MaterialPreview 异步加载;AdminView 拆分 | 视觉一致性与小屏/深色体验 |
| **P2** | 统一通知组件;键盘快捷键;表格 MaterialCell 缩略图 popover;分组小计;模板字段可视化点选;拖拽动效;进度 Dock 反馈 | 效率与细节打磨 |
| **P3** | 统计卡组件化、金额小图表、模板卡片徽标、签字岗位快选 | 锦上添花 |
> 约束提醒:以上均为界面层建议,实施时须保持 README 中列明的业务口径不变(PPT 版式、金额计算口径、勾选保留规则等),尤其 P1 中 ScanPage 重组不要改变「再次导入替换工作区」的确认流程。