diff --git a/.workbuddy/memory/2026-09-20.md b/.workbuddy/memory/2026-09-20.md new file mode 100644 index 0000000..7b2ab1b --- /dev/null +++ b/.workbuddy/memory/2026-09-20.md @@ -0,0 +1,5 @@ +# 2026-09-20 + +- 通读 reimburse 应用全部 SwiftUI 视图代码(ContentView/ScanPage/PairPage/MatchedPage/ExpenseSheet/ExpenseTemplateSheet/PayeeProfileSheet/AdminView 等 26 个文件),产出《界面优化方案.md》于项目根目录。 +- 方案要点:P0 为配对页与已核对页的搜索/排序/低分组过滤;P1 为颜色语义统一、深色模式适配、ScanPage 信息层级重组、MaterialPreview 异步加载、AdminView(1364 行)拆分;P2/P3 为通知组件、快捷键、模板字段可视化点选等。 +- 未改任何代码。 diff --git a/README.md b/README.md index 93c72d4..8151810 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ 2. 材料复制到应用自己的本地工作区,原始文件不会修改。再次导入会先确认是否替换当前工作区;取消确认保留当前材料和核对结果。 3. 本地 OCR 提取信息,按旧项目规则自动匹配并分类。扫描显示真实处理进度。 -4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。 -5. “已核对”中可改分类、预览材料、撤销及导出。首列勾选一组或多组材料,或使用表格上方“全选当前分组”,再点击“导出所选 N 组 PPT”或“所选报销单”;切换分组保留勾选,取消当前分组全选不影响其他分组,未勾选时不能导出这两类文件。“全部”页可全选所有已核对组。行程 Excel 仍使用全部已核对材料。自动核对结果不代表已人工复核。 +4. 在“人工配对”两侧多选材料,选择分类后确认;识别失败的材料仍可人工配对。一对一、一对多、多对一及多对多的已选连线均持续显示,不依赖鼠标悬停。两侧独立滚动时连线跟随材料,屏幕外的已选材料以顶部或底部箭头和数量标记表示;搜索隐藏的选择仍保留并在列底部提示。 +5. “已核对”以费用类型为固定表头并排分列,以紧凑缩略图、金额、日期和凭证份数展示核对组,各列共用纵向滚动区。不将未经确认的 OCR 商户候选或文件名作为卡片标题;详情按组号标识,商户识别内容仍可搜索。仅显示当前结果中有材料的类型,空列自动隐藏,有材料后自动出现,其余列自动分配可用宽度;搜索和筛选也遵循此规则,无结果时只显示空状态,窄窗口支持横向滚动。点击凭证进入详情,可放大原件、切换前后组、修改类型或撤销配对。可逐组勾选、通过类型表头全选本列当前结果,或使用底部“全选当前结果”,再点击“导出 PPT”或“报销单”。选择数量和发票金额在底部汇总。跨类型及搜索筛选保留勾选,取消一列的全选不影响其他列或筛选外的勾选。修改类型后材料移动至对应列。未勾选时不能导出这两类文件。其他导出菜单中的行程 Excel 仍使用全部已核对材料;列内排序不改变导出顺序。自动核对结果不代表已人工复核。 6. 工作区自动持久化到应用沙盒内的 Application Support/ReceiptDesk;清空只删除工作区副本,不删除原始材料。 ## 自定义个人报销单模板 @@ -56,7 +56,7 @@ xcodebuild -project reimburse.xcodeproj -scheme reimburse \ ### 导出前预览 -在“已核对”勾选材料 → “所选报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 +在“已核对”勾选材料 → “报销单” → 编辑用途、签字及收款资料 → “预览报销单”。此处只显示已填写的报销单,不再提供原始模板切换。应用先按当前模板生成实际 Excel,再读取该文件的单元格内容与样式,以统一比例显示列宽、行高、合并区域、边框和文字;支持工作表选择、适合宽度、整页及手动缩放。右上角放大按钮打开可调整大小、可全屏的预览窗口,返回修改或关闭报销单时一并关闭。确认后保存的字节与预览源文件一致,不重新生成另一份报销单。取消保存仍留在预览页面。 跨单元格文字占用空白单元格时,按实际字宽隐藏被跨过的竖边框;保留其他边框,不擅自加线或改变模板合并关系。支持堆叠竖排、底部对齐及会计格式的右侧金额填充,合并区域使用外围边框。这些规则按实际工作表、单元格和样式读取,不绑定内置模板的具体地址。优先使用本机模板字体,缺失字体采用系统替代并明确提示;图片、图表、条件格式等高级效果会提示在办公软件核对,不承诺任意模板与 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`),界面不再将内部筛选分数当作百分比。历史依据不足时不显示未经验证的百分比。 diff --git a/UI-OPTIMIZATION.md b/UI-OPTIMIZATION.md new file mode 100644 index 0000000..9c99e31 --- /dev/null +++ b/UI-OPTIMIZATION.md @@ -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 版本的系统材质效果仍需交互验收。未引入无限历史通知队列,也未改变扫码轮询为自动重复生成二维码。 diff --git a/reimburse/AdminAttachmentRow.swift b/reimburse/AdminAttachmentRow.swift new file mode 100644 index 0000000..bcbed2c --- /dev/null +++ b/reimburse/AdminAttachmentRow.swift @@ -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 + } + } +} + diff --git a/reimburse/AdminAuditTab.swift b/reimburse/AdminAuditTab.swift new file mode 100644 index 0000000..64fb051 --- /dev/null +++ b/reimburse/AdminAuditTab.swift @@ -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) + } +} diff --git a/reimburse/AdminComponents.swift b/reimburse/AdminComponents.swift new file mode 100644 index 0000000..fc79919 --- /dev/null +++ b/reimburse/AdminComponents.swift @@ -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) -> 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(_ 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) -> 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 } + } + } +} diff --git a/reimburse/AdminExpensesTab.swift b/reimburse/AdminExpensesTab.swift new file mode 100644 index 0000000..6232be8 --- /dev/null +++ b/reimburse/AdminExpensesTab.swift @@ -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 = [] + @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 } + } + } +} diff --git a/reimburse/AdminGroupsTab.swift b/reimburse/AdminGroupsTab.swift new file mode 100644 index 0000000..967479f --- /dev/null +++ b/reimburse/AdminGroupsTab.swift @@ -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) } + } +} diff --git a/reimburse/AdminLoginView.swift b/reimburse/AdminLoginView.swift new file mode 100644 index 0000000..72e3c02 --- /dev/null +++ b/reimburse/AdminLoginView.swift @@ -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) } + } +} diff --git a/reimburse/AdminMembersTab.swift b/reimburse/AdminMembersTab.swift new file mode 100644 index 0000000..4b14a48 --- /dev/null +++ b/reimburse/AdminMembersTab.swift @@ -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) } + } +} diff --git a/reimburse/AdminPermissionsTab.swift b/reimburse/AdminPermissionsTab.swift new file mode 100644 index 0000000..1d0f0d4 --- /dev/null +++ b/reimburse/AdminPermissionsTab.swift @@ -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 = [] + @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) } + } +} diff --git a/reimburse/AdminPoliciesTab.swift b/reimburse/AdminPoliciesTab.swift new file mode 100644 index 0000000..73dacd0 --- /dev/null +++ b/reimburse/AdminPoliciesTab.swift @@ -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) -> 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) -> 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) -> 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, 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) -> 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, 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, 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) } + } +} diff --git a/reimburse/AdminStore.swift b/reimburse/AdminStore.swift index a155de9..6d67776 100644 --- a/reimburse/AdminStore.swift +++ b/reimburse/AdminStore.swift @@ -16,6 +16,8 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche @Published var session: AdminSession? @Published var access: AdminAccess? @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 qr: NSImage? @Published var invitationImage: NSImage? @@ -115,6 +117,8 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche do { try keychainSave(try JSONEncoder().encode(value)) try await reload() + serverHistory = Array(([origin] + serverHistory.filter { $0 != origin }).prefix(5)) + UserDefaults.standard.set(serverHistory, forKey: "admin.serverHistory") } catch { clearSession() throw error @@ -200,6 +204,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche 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]) try await refreshExpenses() + notice = state == "approved" ? "报销申请已批准" : "报销申请已退回" } 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]) @@ -398,6 +403,7 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。") } mergeSavedPolicy(saved) + notice = "“\(saved.type)”规则已保存" } func mergeSavedPolicy(_ saved: AdminExpensePolicy) { 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 ?? "导出失败") } try data.write(to: destination, options: .atomic) + notice = "凭证已保存至 \(destination.path)" } func download(_ file: AdminFile) async throws { 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 = "" generation += 1; loginTask?.cancel(); session = nil; access = nil; qr = nil; selected = nil busy = false + notice = nil expenses = []; members = []; grants = []; candidates = []; managedGroups = []; invites = []; audit = []; policies = []; uploadKinds = []; invitationImage = nil; invitationPoster = nil inviteScene = nil; invitationExpiresAt = nil; invitationMaxUses = 0 SecItemDelete(keychainQuery() as CFDictionary) diff --git a/reimburse/AdminView.swift b/reimburse/AdminView.swift index 7f65b12..c44d50d 100644 --- a/reimburse/AdminView.swift +++ b/reimburse/AdminView.swift @@ -1,130 +1,53 @@ import SwiftUI -struct AdminView: View { - @StateObject private var model = AdminStore() +struct AdminView: AdminPage { + @StateObject var model = AdminStore() @State private var tab = "报销审批" - @State private var expenseSearch = "" - @State private var expenseState = "all" - @State private var memberSearch = "" - @State private var groupSearch = "" @State private var memberSection = "成员列表" - @State private var auditSearch = "" @State private var permissionSection = "授权管理" - @State private var name = "" - @State private var groupName = "" - @State private var renameTarget: AdminManagedGroup? - @State private var renameValue = "" - @State private var selectedCandidate = "" @State private var role = "group" - @State private var selectedGroups: Set = [] - @State private var selectedExpenses: Set = [] - @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 hours = 168 - @State private var uses = 50 - @State private var reason = "" - @State private var confirmation: AdminAction? - @State private var approvalConfirmation: AdminAction? - @State private var loginMode = "password" - @State private var loginUsername = "" - @State private var loginPassword = "" - @State private var materialKind = "" - @State private var materialName = "" - @State private var materialHint = "" - @State private var materialAllowsImage = true - @State private var materialAllowsPDF = false - @State private var materialActive = true - @State private var editingMaterialKind = "" - @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 = "" - var body: some View { VStack(spacing: 0) { adminHeader if model.access != nil { adminNavigation } if let error = model.error { - HStack(spacing: 10) { - Label(error, systemImage: "exclamationmark.triangle.fill").font(.callout).textSelection(.enabled) - Spacer() - Button { model.error = nil } label: { Image(systemName: "xmark") } - .buttonStyle(.plain).help("关闭提示") - }.foregroundStyle(.red).padding(12).background(Color.red.opacity(0.06)) + AppNotice(text: error, tone: .danger, dismiss: { model.error = nil }) } - if model.access == nil { login } + if let notice = model.notice { + AppNotice(text: notice, tone: .success, dismiss: { model.notice = nil }) + } + if model.access == nil { AdminLoginView(model: model) } else { if tab == "报销审批" || tab == "成员与邀请" || tab == "项目与授权" { scopeFilters } Group { switch tab { - case "成员与邀请": members - case "组别管理": groupManagement - case "项目与授权": permissions - case "报销类型": policies - case "操作记录": audit - default: expenses + case "成员与邀请": AdminMembersTab(model: model, tab: $tab, memberSection: $memberSection, role: $role) + case "组别管理": AdminGroupsTab(model: model, tab: $tab, memberSection: $memberSection, permissionSection: $permissionSection) + case "项目与授权": AdminPermissionsTab(model: model, role: $role, permissionSection: $permissionSection) + case "报销类型": AdminPoliciesTab(model: model) + case "操作记录": AdminAuditTab(model: model) + default: AdminExpensesTab(model: model) } - }.frame(maxWidth: .infinity, maxHeight: .infinity).disabled(model.busy) + }.id(model.access?.userId).frame(maxWidth: .infinity, maxHeight: .infinity).disabled(model.busy) } }.background(Color(nsColor: .windowBackgroundColor)) - .frame(minWidth: 1050, minHeight: 700) - .task { await model.restore() } - .onChange(of: loginMode) { _, _ in model.cancelLogin(); loginPassword = "" } - .onChange(of: model.access?.userId) { _, _ in - role = model.access?.platform == true ? "leader" : "group" - permissionSection = "授权管理" - selectedExpenses = [] - } - .onChange(of: model.projectID) { _, _ in model.groupID = 0; selectedGroups = []; run { try await model.refreshExpenses(); try await model.refreshGroup() } } - .onChange(of: model.groupID) { _, _ in run { try await model.refreshExpenses(); try await model.refreshGroup() } } - .onChange(of: tab) { _, new in if new == "操作记录" { run { try await model.loadAudit() } } } - .onChange(of: model.policies.map(\.type)) { _, types in - if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" } - } - .onChange(of: selectedPolicyType) { _, _ in resetPolicyMaterialEditor() } - .onChange(of: filteredExpenses.map(\.id)) { _, ids in selectedExpenses.formIntersection(Set(ids)) } - .sheet(item: $model.selected) { detail($0) } - .sheet(isPresented: $showTypeEditor) { expenseTypeEditor } - .sheet(isPresented: $showMaterialEditor) { - if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) { - policyMaterialEditor(policy: $model.policies[index]) + .frame(minWidth: 900, minHeight: 560) + .task { await model.restore() } + .task(id: model.notice) { + guard let notice = model.notice else { return } + do { try await Task.sleep(for: .seconds(8)) } catch { return } + if model.notice == notice { model.notice = nil } } - } - .sheet(isPresented: $showDateExport) { - dateExport - } - .sheet(isPresented: $showPPTExport) { - AdminPPTExportSheet(model: model, initialSelection: selectedExpenses) - } - .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) - } - .confirmationDialog(confirmation?.title ?? "", isPresented: Binding(get: { confirmation != nil }, set: { if !$0 { confirmation = nil } }), titleVisibility: .visible) { - Button("确认") { let action = confirmation; confirmation = nil; action?.perform() } - Button("取消", role: .cancel) { confirmation = nil } - } + .onChange(of: model.access?.userId) { _, _ in + role = model.access?.platform == true ? "leader" : "group" + permissionSection = "授权管理" + } + .onChange(of: model.projectID) { _, _ in + model.groupID = 0 + run { try await model.refreshExpenses(); try await model.refreshGroup() } + } + .onChange(of: model.groupID) { _, _ in run { try await model.refreshExpenses(); try await model.refreshGroup() } } + .onChange(of: tab) { _, value in if value == "操作记录" { run { try await model.loadAudit() } } } } private var adminHeader: some View { HStack(spacing: 12) { @@ -149,6 +72,7 @@ struct AdminView: View { }.padding(.horizontal, 20).padding(.vertical, 14) } private var adminNavigation: some View { + ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 6) { navigationItem("报销审批", icon: "doc.text.magnifyingglass") navigationItem("成员与邀请", icon: "person.2") @@ -156,12 +80,13 @@ struct AdminView: View { navigationItem("项目与授权", icon: "person.badge.key") navigationItem("报销类型", icon: "slider.horizontal.3") navigationItem("操作记录", icon: "clock.arrow.circlepath") - }.padding(.horizontal, 16).padding(.bottom, 12).disabled(model.busy) + }.padding(.horizontal, 16).padding(.bottom, 12) + }.fixedSize(horizontal: false, vertical: true).disabled(model.busy) } private func navigationItem(_ title: String, icon: String) -> some View { Button { tab = title } label: { Label(title, systemImage: icon).font(.system(size: 13, weight: tab == title ? .semibold : .regular)) - .frame(maxWidth: .infinity).padding(.vertical, 11) + .fixedSize().padding(.horizontal, 16).padding(.vertical, 11) .foregroundStyle(tab == title ? Color.accentColor : Color.secondary) .background(tab == title ? Color.accentColor.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: 8)).contentShape(Rectangle()) @@ -186,1179 +111,4 @@ struct AdminView: View { }.font(.callout).padding(.horizontal, 20).padding(.vertical, 12) .background(Color(nsColor: .controlBackgroundColor)).disabled(model.busy) } - private func searchField(_ placeholder: String, text: Binding) -> some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass").foregroundStyle(.secondary) - TextField(placeholder, text: text).textFieldStyle(.plain) - if !text.wrappedValue.isEmpty { - Button { text.wrappedValue = "" } label: { Image(systemName: "xmark.circle.fill") } - .buttonStyle(.plain).foregroundStyle(.secondary).help("清空搜索") - } - }.padding(9).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 8)) - } - private func emptyState(_ title: String, icon: String, message: String) -> some View { - VStack(spacing: 10) { - Image(systemName: icon).font(.system(size: 30)).foregroundStyle(.secondary) - Text(title).font(.headline) - Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) - }.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity) - } - private func statusBadge(_ text: String, positive: Bool = false, pending: Bool = false) -> some View { - Text(text).font(.caption.weight(.medium)).padding(.horizontal, 8).padding(.vertical, 4) - .foregroundStyle(positive ? Color.green : pending ? Color.orange : Color.secondary) - .background((positive ? Color.green : pending ? Color.orange : Color.secondary).opacity(0.09)) - .clipShape(Capsule()) - } - private func sectionCard(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 16) { - Label(title, systemImage: icon).font(.headline) - content() - }.padding(20).frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 12)) - } - private var login: some View { - 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) - TextField("HTTPS 服务地址", text: $model.server).textFieldStyle(.roundedBorder).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.qr == nil ? "生成微信登录码" : "等待微信确认") { model.startLogin() } - .buttonStyle(.borderedProminent).disabled(model.busy) - if model.busy { Button("取消扫码", action: model.cancelLogin) } - } - } - }.frame(height: 320) - }.padding(32).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.vertical, 28).frame(maxWidth: .infinity) - }.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) } - } - private var filteredExpenses: [AdminExpense] { - AdminDisplayFilter.expenses(model.displayedExpenses, query: expenseSearch, state: expenseState) - } - private func metric(_ title: String, value: String, icon: String) -> some View { - HStack(spacing: 12) { - Image(systemName: icon).font(.title3).foregroundStyle(Color.accentColor) - VStack(alignment: .leading, spacing: 4) { - Text(title).font(.caption).foregroundStyle(.secondary) - Text(value).font(.title3.weight(.semibold)).monospacedDigit() - } - Spacer() - }.padding(16).frame(maxWidth: .infinity).background(Color(nsColor: .controlBackgroundColor)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - } - 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) { - TableColumn("申请人", value: \.name) - TableColumn("项目", value: \.projectName) - TableColumn("组别", value: \.team) - TableColumn("类型", value: \.type) - TableColumn("金额") { 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("显示 \(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: 740, height: 660).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 } - } - } - 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 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 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 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) - } - 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) -> 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) -> 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) -> 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, 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) -> 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 resetMaterialEditor() { - editingMaterialKind = "" - materialKind = "" - materialName = "" - materialHint = "" - materialAllowsImage = true - materialAllowsPDF = false - materialActive = true - } - private func materialModeButtons(policy: Binding, 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, 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 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 run(_ work: @escaping () async throws -> Void) { Task { await model.perform(work) } } - private func confirm(_ title: String, _ work: @escaping () async throws -> Void) { confirmation = AdminAction(title: title) { run(work) } } - private func memberState(_ s: String) -> String { ["pending": "待审核", "active": "已加入", "rejected": "已拒绝", "disabled": "已停用"][s] ?? s } - private func groupOption(_ group: AdminGroup) -> String { - group.canManage ? group.label : "\(group.label)(未授权)" - } - private 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 } - } - } -} - -private 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 - } - } -} - -private struct AdminAction: Identifiable { - let id = UUID() - let title: String - let perform: () -> Void } diff --git a/reimburse/AppTheme.swift b/reimburse/AppTheme.swift new file mode 100644 index 0000000..0ed8bb0 --- /dev/null +++ b/reimburse/AppTheme.swift @@ -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: 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") +} diff --git a/reimburse/ContentView.swift b/reimburse/ContentView.swift index 3639a41..0455405 100644 --- a/reimburse/ContentView.swift +++ b/reimburse/ContentView.swift @@ -3,7 +3,6 @@ import SwiftUI struct ContentView: View { @EnvironmentObject var store: WorkspaceStore @State private var confirmClear = false - @State private var showPayeeProfile = false var body: some View { NavigationSplitView { @@ -23,7 +22,13 @@ struct ContentView: View { Text(page.rawValue) Spacer() 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) } .padding(.vertical, 7).tag(page) @@ -50,11 +55,13 @@ struct ContentView: View { }.padding().background(.teal.opacity(0.06)) } if let notice = store.notice { - HStack { - Label(notice, systemImage: "checkmark.circle.fill").foregroundStyle(.teal) - Spacer() - Button { store.notice = nil } label: { Image(systemName: "xmark") }.buttonStyle(.plain) - }.font(.callout).padding(12).background(.teal.opacity(0.06)) + AppNotice(text: notice, tone: notice.hasPrefix("已取消") ? .information : .success, + actionTitle: store.lastExportURL == nil ? nil : "在访达中显示", + action: { if let url = store.lastExportURL { NSWorkspace.shared.activateFileViewerSelecting([url]) } }, + dismiss: { store.notice = nil }) + } + if let error = store.errorMessage { + AppNotice(text: error, tone: .danger, dismiss: { store.errorMessage = nil }) } Group { switch store.page ?? .scan { @@ -67,7 +74,7 @@ struct ContentView: View { } } .tint(.teal) - .frame(minWidth: 1050, minHeight: 700) + .frame(minWidth: 900, minHeight: 560) .toolbar { ToolbarItem(placement: .primaryAction) { Button { store.templateMessage = nil; store.showExpenseTemplate = true } label: { @@ -75,7 +82,7 @@ struct ContentView: View { }.disabled(store.busy) } ToolbarItem(placement: .primaryAction) { - Button { showPayeeProfile = true } label: { + Button { store.showPayeeProfile = true } label: { Label("收款信息", systemImage: "person.crop.rectangle") }.disabled(store.busy) } @@ -84,9 +91,6 @@ struct ContentView: View { .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) { Button("仅清空本次报销数据(保留模板)", role: .destructive) { store.clear() } if store.expenseTemplate != nil || store.expenseTemplateLoadError != nil { @@ -96,26 +100,36 @@ struct ContentView: View { } message: { Text("可选择保留模板用于下次报销,或一起移除本机模板副本。原始材料、原始 Excel 和已保存的收款资料均不会删除。") } .sheet(item: $store.preview) { item in MaterialPreview(item: item) } .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() } + .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 { HStack(alignment: .center) { VStack(alignment: .leading, spacing: 5) { - Text("RECEIPT DESK").font(.caption2.weight(.semibold)).tracking(2).foregroundStyle(.secondary) - Text((store.page ?? .scan).rawValue).font(.title.bold()) + Text("本地贴票").font(.caption.weight(.semibold)).foregroundStyle(.secondary) + Text((store.page ?? .scan).rawValue).font(.title2.bold()) } Spacer() VStack(alignment: .trailing, spacing: 5) { Text("\(store.state.invoices.count) 张发票 · \(store.state.payments.count) 张付款 · \(store.state.matches.count) 组已核对") .font(.caption).foregroundStyle(.secondary) HStack(spacing: 18) { - Text("发票 \(currency(store.state.invoiceTotal))") - Text("付款 \(currency(store.state.paymentTotal))").foregroundStyle(.teal) + Label(currency(store.state.invoiceTotal), systemImage: "doc.text") + Label(currency(store.state.paymentTotal), systemImage: "creditcard") }.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 { @@ -128,8 +142,8 @@ struct Panel: View { @ViewBuilder var content: Content var body: some View { content.padding(20).frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 16)) - .overlay(RoundedRectangle(cornerRadius: 16).stroke(.primary.opacity(0.06))) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius)) + .overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(AppTheme.border)) } } @@ -137,11 +151,14 @@ struct EmptyPanel: View { var symbol: String var title: String var message: String + var actionTitle: String? = nil + var action: (() -> Void)? = nil var body: some View { 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(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) } } diff --git a/reimburse/DockProgress.swift b/reimburse/DockProgress.swift new file mode 100644 index 0000000..b3cbf21 --- /dev/null +++ b/reimburse/DockProgress.swift @@ -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() + } +} diff --git a/reimburse/ExpenseTemplateSheet.swift b/reimburse/ExpenseTemplateSheet.swift index 27afefb..79112f8 100644 --- a/reimburse/ExpenseTemplateSheet.swift +++ b/reimburse/ExpenseTemplateSheet.swift @@ -16,20 +16,21 @@ struct ExpenseTemplateSummary: View { .font(.caption).foregroundStyle(.secondary) } Spacer() + AppBadge(title: "模板", symbol: "doc.text", tone: .information) Button(store.expenseTemplate == nil ? "选择模板…" : "查看 / 更换…", action: manage) .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 { - RoundedRectangle(cornerRadius: 10) - .strokeBorder(.teal.opacity(targeted && !store.busy ? 1 : 0.2), style: StrokeStyle(lineWidth: 1, dash: [5, 4])) + RoundedRectangle(cornerRadius: AppTheme.radius) + .strokeBorder(targeted && !store.busy ? AppTheme.accent : AppTheme.border, lineWidth: 1) .allowsHitTesting(false) } .dropDestination(for: URL.self) { sources, _ in guard !store.busy else { return false } manage() 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 store.importExpenseTemplates(sources) - } isTargeted: { targeted = $0 } + } isTargeted: { value in withAnimation(.easeInOut(duration: 0.15)) { targeted = value } } if store.busy { HStack { ProgressView().controlSize(.small); Text(store.status) }.font(.callout) } @@ -92,12 +93,12 @@ struct ExpenseTemplateManager: View { description: Text("没有本组模板也可以直接导出。\n如需更换,将 Excel 文件拖到上方。")) } }.frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.white, in: RoundedRectangle(cornerRadius: 10)) + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: 8)) .clipShape(RoundedRectangle(cornerRadius: 10)) VStack(alignment: .leading, spacing: 18) { ScrollView { 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) if let template = store.expenseTemplate { 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) .confirmationDialog("恢复内置报销单模板?", isPresented: $confirmRemove) { - Button("恢复内置模板", role: .destructive, action: store.removeExpenseTemplate) + Button("移除本机模板副本", role: .destructive, action: store.removeExpenseTemplate) Button("取消", role: .cancel) {} } message: { Text("仅移除本机模板副本,不影响本次报销数据、收款资料或原始 Excel。") } .task(id: store.expenseTemplate?.fileName) { savedFile = store.expenseTemplateFile } @@ -163,7 +165,7 @@ private struct ExpenseTemplateEditor: View { VStack(alignment: .leading, spacing: 8) { Text("原始模板预览").font(.caption).foregroundStyle(.secondary) SpreadsheetFilePreview(url: draft.source, preferredSheet: mapping.sheetName).id(draft.source) - .background(.white).clipShape(RoundedRectangle(cornerRadius: 10)) + .background(AppTheme.surface).clipShape(RoundedRectangle(cornerRadius: 8)) Text("这里显示导入文件原文;本次费用会在“预览报销单”中填入。") .font(.caption).foregroundStyle(.secondary) }.frame(maxWidth: .infinity, maxHeight: .infinity) @@ -184,9 +186,9 @@ private struct ExpenseTemplateEditor: View { Divider() Text("请核对上面的填写位置。使用后,明细范围内的对应列及收款字段会被替换;未指定位置的内容会保留。") .font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) - if mapping.columns["purpose"] == nil || mapping.columns["amount"] == nil { - Label("尚未识别完整,请先调整填写位置。", systemImage: "exclamationmark.triangle") - .font(.caption).foregroundStyle(.orange) + if !TemplateMappingValidation.issues(mapping).isEmpty { + AppNotice(text: TemplateMappingValidation.issues(mapping).joined(separator: ";"), tone: .warning, + actionTitle: "调整", action: { showFields = true }) } } .frame(maxWidth: .infinity, alignment: .leading) @@ -194,12 +196,12 @@ private struct ExpenseTemplateEditor: View { Divider() Button("确认位置,使用此模板") { store.saveExpenseTemplate(mapping) } .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 } }.frame(width: 285, alignment: .leading) } .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 { @Binding var mapping: ExpenseTemplateMapping let inspection: ExpenseTemplateInspection + let source: URL @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", "单据数量"), ("invoiceType", "票据类型"), ("sequence", "序号"), ("category", "费用类别"), ("remarks", "备注(清空旧值)")] private let cells = [("recipient", "收款人"), ("bankName", "开户行"), ("accountNumber", "账号"), @@ -218,47 +225,67 @@ private struct ExpenseTemplateFields: View { HStack { Text("调整填写位置").font(.title2.bold()) Spacer() + Button("关闭") { dismiss() }.keyboardShortcut(.cancelAction) Button("完成") { dismiss() }.keyboardShortcut(.defaultAction) + .disabled(!TemplateMappingValidation.issues(mapping).isEmpty) } Text("仅格式识别不正确时需要调整。列填字母(如 C),单元格填地址(如 C23),合并区域填左上角。") .font(.callout).foregroundStyle(.secondary) - ScrollView { + 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 { VStack(alignment: .leading, spacing: 18) { HStack { 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("结束行") - 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("包括全部旧明细,不包含表头及合计;容量不足时自动复制报销工作表分页。") .font(.caption).foregroundStyle(.secondary) 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 HStack { Text(title).frame(width: 120, alignment: .leading) + .foregroundStyle(activeField == "column:" + key ? AppTheme.accent : .primary) TextField("不填充", text: Binding(get: { mapping.columns[key] ?? "" }, set: { mapping.columns[key] = $0.uppercased() })) .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("请输入 A–IV 之间的列字母") + } } } } 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 HStack { Text(title).frame(width: 120, alignment: .leading) + .foregroundStyle(activeField == "cell:" + key ? AppTheme.accent : .primary) TextField("不填充", text: Binding(get: { mapping.cells[key] ?? "" }, set: { mapping.cells[key] = $0.uppercased() })) .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("签字岗位") { TextField("例如 A30,D30,A31;最多 6 项", text: $mapping.signatureCells) + .focused($focusedField, equals: "signature") } LabeledContent("额外清空") { TextField("例如 C27,I27;多个地址用逗号分隔", text: $mapping.clearCells) + .focused($focusedField, equals: "clear") } Text("收款位置用本机资料替换,未保存资料则清空;签字岗位按导出弹窗中的顺序填入。留空的配置不改动模板原文。") .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 Text(row).font(.caption.monospaced()).textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 3) + .onHover { inside in + hoveredRow = inside ? Int(row.prefix(while: \.isNumber)) : nil + } } } }.padding(.trailing, 8) + }.frame(width: 360) } - }.padding(24).frame(width: 750, height: 600).textFieldStyle(.roundedBorder) + 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: ",") } + } } } diff --git a/reimburse/MatchedCategoryBoard.swift b/reimburse/MatchedCategoryBoard.swift new file mode 100644 index 0000000..4fc5ccd --- /dev/null +++ b/reimburse/MatchedCategoryBoard.swift @@ -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.. 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) + } +} diff --git a/reimburse/MatchedPage.swift b/reimburse/MatchedPage.swift index 55542ba..ff83f27 100644 --- a/reimburse/MatchedPage.swift +++ b/reimburse/MatchedPage.swift @@ -2,165 +2,112 @@ import SwiftUI struct MatchedPage: View { @EnvironmentObject var store: WorkspaceStore - @State private var selectedGroup = "全部" @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] { store.state.matches.filter { pptSelection.ids.contains($0.id) } } - private var groups: [String] { - ["全部"] + Array(Set(store.state.matches.map(\.category))).sorted() + private var visibleMatches: [MatchGroup] { + WorkspaceFilter.matches(store.presentation.sortedMatches, query: query, category: "全部", + attentionOnly: attentionOnly, order: order, descending: descending) } var body: some View { - VStack(spacing: 18) { - HStack { - VStack(alignment: .leading, spacing: 4) { - Text("已核对材料").font(.title2.bold()) - Text("按费用分组查看,每个分组独立一页").font(.caption).foregroundStyle(.secondary) - } - Spacer() - Button { store.export(.travel) } label: { Label("行程 Excel", systemImage: "tram") }.disabled(store.state.travelCount == 0) - Button { store.openExpense(selectedMatchIDs: pptSelection.ids) } label: { - Label("所选报销单", systemImage: "tablecells") - }.disabled(selectedMatches.isEmpty) - Button { store.export(.ppt, selectedMatchIDs: pptSelection.ids) } label: { - Label("导出所选 \(selectedMatches.count) 组 PPT", systemImage: "square.and.arrow.up") - } - .buttonStyle(.borderedProminent).disabled(selectedMatches.isEmpty) + let matches = visibleMatches + let selected = selectedMatches + let columns = store.presentation.categoryColumns(for: matches) + VStack(spacing: 12) { + HStack(spacing: 12) { + AppSearchField(placeholder: "金额、商户、日期或文件名", text: $query).focused($searchFocused) + Toggle("需关注 \(store.presentation.attentionCount)", isOn: $attentionOnly) + .toggleStyle(.checkbox).fixedSize() + Picker("排序", selection: $order) { + ForEach(MatchOrder.allCases) { Text($0.rawValue).tag($0) } + }.labelsHidden().frame(width: 100).help("列内排序") + Button { descending.toggle() } label: { + Image(systemName: descending ? "arrow.down" : "arrow.up") + }.help(descending ? "降序,点击改为升序" : "升序,点击改为降序") } - if store.state.matches.isEmpty { - EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "扫描目录或完成人工配对后,结果会出现在这里。") - } else { - Picker("分组", selection: $selectedGroup) { - ForEach(groups, id: \.self) { group in - Text(group == "全部" ? "全部(\(store.state.matches.count))" : "\(group)(\(count(for: group)))") - .tag(group) - } + Group { + if store.state.matches.isEmpty { + EmptyPanel(symbol: "checkmark.seal", title: "还没有已核对材料", message: "", + actionTitle: "前往人工配对") { store.page = .pair } + } else if matches.isEmpty { + EmptyPanel(symbol: "magnifyingglass", title: "没有符合筛选的材料", message: "", + actionTitle: "清除筛选") { query = ""; attentionOnly = false } + } else { + MatchedCategoryBoard(columns: columns, selection: $pptSelection) } - .pickerStyle(.segmented) - .onChange(of: groups) { _, available in - if !available.contains(selectedGroup) { selectedGroup = "全部" } + }.frame(maxWidth: .infinity, minHeight: 220, maxHeight: .infinity) + 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 } } - 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) + }.padding(AppTheme.page) .onChange(of: store.state.matches.map(\.id)) { _, identifiers in pptSelection.retain(identifiers) } + .onReceive(NotificationCenter.default.publisher(for: WorkspaceCommand.find)) { _ in searchFocused = true } } - private func matches(for group: String) -> [MatchGroup] { - let matches = group == "全部" ? store.state.sortedMatches : store.state.sortedMatches.filter { $0.category == group } - return matches - } - - private func count(for group: String) -> Int { - matches(for: group).count - } - - private func baseIndex(for group: String) -> Int { - guard group != "全部", let first = store.state.sortedMatches.firstIndex(where: { $0.category == group }) else { return 1 } - return first + 1 - } -} - -struct 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)") + private func selectionSummary(matches: [MatchGroup], selected: [MatchGroup]) -> some View { + HStack(spacing: 12) { + Toggle("全选当前结果", isOn: Binding( + get: { pptSelection.containsAll(matches) }, + set: { pptSelection.setVisible(matches, selected: $0) } + )).toggleStyle(.checkbox).labelsHidden().disabled(matches.isEmpty).help("全选当前结果") + VStack(alignment: .leading, spacing: 3) { + Text(selected.isEmpty ? "共 \(matches.count) 组" : "已选 \(selected.count) 组") + .font(.callout.weight(.medium)) + Text("\(selected.isEmpty ? "当前发票" : "所选发票") \(currency((selected.isEmpty ? matches : selected).reduce(0) { $0 + $1.invoiceTotal }))") .font(.caption).foregroundStyle(.secondary) - Spacer() - Text("按发票日期排列").font(.caption).foregroundStyle(.secondary) + }.monospacedDigit().fixedSize() + 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 { - EmptyPanel(symbol: "tray", title: "此分组暂无材料", message: "完成核对后,材料会自动归入对应分组。") - } else { - Table(matches) { - TableColumn("选择") { match in - Toggle("选择第 \(index(of: match)) 组", isOn: Binding( - get: { selection.ids.contains(match.id) }, - set: { selected in - if selected { selection.ids.insert(match.id) } - else { selection.ids.remove(match.id) } - } - )) - .toggleStyle(.checkbox).labelsHidden() - }.width(48) - TableColumn("序号") { match in - Text(String(format: "%02d", index(of: match))).monospacedDigit().foregroundStyle(.secondary) - }.width(min: 48, ideal: 56, max: 64) - TableColumn("发票") { match in - MaterialCell(items: match.invoices, store: store) - }.width(min: 170, ideal: 235) - TableColumn("付款截图") { match in - MaterialCell(items: match.payments, store: store) - }.width(min: 170, ideal: 235) - TableColumn("金额") { match in - VStack(alignment: .trailing, spacing: 3) { - Text(currency(match.expenseAmount > 0 ? match.expenseAmount : match.invoiceTotal)).monospacedDigit() - Text("付 \(currency(match.paymentTotal))").font(.caption).foregroundStyle(.secondary).monospacedDigit() - }.frame(maxWidth: .infinity, alignment: .trailing) - }.width(min: 115, ideal: 135) - TableColumn("日期") { match in - Text(match.date == "9999-12-31" ? "未识别" : match.date).monospacedDigit() - }.width(min: 92, ideal: 105) - TableColumn("匹配") { match in - 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) + if !query.isEmpty || attentionOnly { + Button("清除筛选") { query = ""; attentionOnly = false }.buttonStyle(.borderless) } } } - private func index(of match: MatchGroup) -> Int { - guard let index = matches.firstIndex(where: { $0.id == match.id }) else { return baseIndex } - return baseIndex + index + private var exportActions: some View { + HStack(spacing: 10) { + 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" { Button { showExplanation = true } label: { VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 4) { - Text("自动 · \(match.evidenceLabel)").monospacedDigit() - Image(systemName: "info.circle").font(.caption) - }.foregroundStyle((match.evidenceScore ?? 0) >= 90 ? .teal : .orange) + AppBadge(title: "自动 · \(match.evidenceLabel)", + symbol: match.needsAttention ? "exclamationmark.circle" : "checkmark.seal", + tone: match.needsAttention ? .warning : .success) Text(match.explanation?.summary ?? "查看匹配依据") .font(.caption).foregroundStyle(.secondary).lineLimit(1) }.padding(.vertical, 4) } .buttonStyle(.plain) .help((match.explanation?.summary ?? "查看匹配依据") + ";点击查看详细得分和核对建议") - .accessibilityLabel("自动匹配证据评分 \(match.evidenceLabel),查看原因") + .accessibilityLabel("自动匹配证据评分 \(match.evidenceLabel),\(match.needsAttention ? "需关注" : "评分高"),查看原因") .popover(isPresented: $showExplanation) { MatchExplanationPopover(match: match) } } 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) { HStack { 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) Spacer() Text("\(item.points) / \(item.maximum) 分").monospacedDigit() @@ -263,18 +210,36 @@ struct MatchExplanationPopover: View { struct MaterialCell: View { let items: [Material] @ObservedObject var store: WorkspaceStore + @State private var expanded = false var body: some View { 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 Button(item.name) { store.preview = item } .buttonStyle(.link).font(.caption).lineLimit(1).help("点击预览 \(item.name)") } 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) + .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 @Environment(\.dismiss) private var dismiss @State private var showTemplate = false + @State private var removedSignature: (index: Int, value: String)? var body: some View { - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 16) { HStack { VStack(alignment: .leading, spacing: 6) { Text("个人报销单").font(.title2.bold()) @@ -292,9 +258,10 @@ struct ExpenseSheet: View { } Spacer() } - PayeeProfileSummary(profileStore: store.payeeProfile) - ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true } ScrollView { + VStack(alignment: .leading, spacing: 16) { + PayeeProfileSummary(profileStore: store.payeeProfile) + ExpenseTemplateSummary { store.templateMessage = nil; showTemplate = true } VStack(spacing: 12) { ForEach(Array(store.expenseRows.enumerated()), id: \.element.id) { index, summary in HStack(spacing: 16) { @@ -304,34 +271,60 @@ struct ExpenseSheet: View { Text("\(summary.groupCount) 组 · \(summary.invoiceCount) 张单据").font(.caption).foregroundStyle(.secondary) }.frame(width: 120, alignment: .leading) Text(currency(summary.amountValue)).fontWeight(.medium).monospacedDigit().frame(width: 120, alignment: .trailing) - TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 })) + VStack(alignment: .leading, spacing: 4) { + TextField("支出项目 / 用途", text: Binding(get: { store.purposes[summary.category] ?? summary.category }, set: { store.purposes[summary.category] = $0 })) + 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 { Text("合计 \(currency(NSDecimalNumber(decimal: store.expenseRows.reduce(.zero) { $0 + $1.amount }).doubleValue))").fontWeight(.semibold).monospacedDigit() Spacer() Text("金额沿用报销单的发票计算口径,不使用付款截图金额。").font(.caption).foregroundStyle(.secondary) } if !store.expenseSelectionValid { - Text("勾选材料已变化,请关闭弹窗后重新选择。").foregroundStyle(.red) + AppNotice(text: "勾选材料已变化", tone: .danger, + actionTitle: "返回重新选择", action: { dismiss(); store.page = .matched }) + } + if duplicatePurposes { + AppNotice(text: "多个费用类型使用了相同用途;金额仍按费用类型分别汇总。", tone: .warning) } Divider() HStack { Text("签字岗位").font(.headline) Text("最多 6 项;全部删除时沿用原模板默认岗位").font(.caption).foregroundStyle(.secondary) 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) } LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 12) { ForEach(store.signatures.indices, id: \.self) { index in HStack { 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 }) + } + } + } Divider() HStack { Text("按当前模板填充;收款信息使用本机已保存资料。").font(.caption).foregroundStyle(.secondary) @@ -342,10 +335,16 @@ struct ExpenseSheet: View { }.buttonStyle(.borderedProminent) .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) .interactiveDismissDisabled(store.busy) .sheet(isPresented: $showTemplate) { ExpenseTemplateManager() } .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 + } } diff --git a/reimburse/MaterialViews.swift b/reimburse/MaterialViews.swift index bc9cec2..238b840 100644 --- a/reimburse/MaterialViews.swift +++ b/reimburse/MaterialViews.swift @@ -1,31 +1,66 @@ import SwiftUI 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 { let item: Material var height: CGFloat = 155 + var pixels: Int = 700 @State private var image: NSImage? var body: some View { ZStack { - Color.white + AppTheme.surface if let image { Image(nsImage: image).resizable().scaledToFit().padding(6) } else { 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) { - let url = item.previewURL - image = await Task.detached(priority: .utility) { - guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), - let bitmap = CGImageSourceCreateThumbnailAtIndex(source, 0, [ - kCGImageSourceCreateThumbnailFromImageAlways: true, - kCGImageSourceThumbnailMaxPixelSize: 700, - kCGImageSourceCreateThumbnailWithTransform: true - ] as CFDictionary) else { return NSImage(contentsOf: url) } - return NSImage(cgImage: bitmap, size: .zero) - }.value + image = nil + let loaded = await MaterialImageLoader.load(item.previewURL, pixels: pixels) + guard !Task.isCancelled else { return } + image = loaded } } } @@ -35,10 +70,12 @@ struct MaterialCard: View { let item: Material var selected = false var selectable = true + var thumbnailHeight: CGFloat = 155 + var counterpartCount = 0 var body: some View { VStack(alignment: .leading, spacing: 9) { - MaterialThumbnail(item: item) + MaterialThumbnail(item: item, height: thumbnailHeight) .overlay(alignment: .topTrailing) { Button { store.preview = item } label: { Image(systemName: "arrow.up.left.and.arrow.down.right").padding(7) @@ -49,34 +86,44 @@ struct MaterialCard: View { Spacer(minLength: 2) 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) HStack { Text(item.dateLabel) Spacer() if item.ocr.status == "failed" { Label("识别失败", systemImage: "exclamationmark.triangle").foregroundStyle(.orange) } }.font(.caption2).foregroundStyle(.secondary) - }.padding(11) - .background(selected ? Color.teal.opacity(0.08) : Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 13)) - .overlay(RoundedRectangle(cornerRadius: 13).stroke(selected ? .teal : .primary.opacity(0.08), lineWidth: selected ? 2 : 1)) + }.padding(12) + .background(selected ? AppTheme.accent.opacity(0.08) : AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.radius)) + .overlay(RoundedRectangle(cornerRadius: AppTheme.radius).stroke(selected ? AppTheme.accent : AppTheme.border, lineWidth: selected ? 2 : 1)) .contentShape(Rectangle()) .focusable(selectable) .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 } return .handled } .onTapGesture { if selectable { store.toggle(item) } else { store.preview = item } } .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 } } } } struct MaterialPreview: View { let item: Material + @EnvironmentObject var store: WorkspaceStore @Environment(\.dismiss) private var dismiss @State private var zoom: CGFloat = 1 @State private var showText = false + @State private var image: NSImage? + @State private var loading = true + @State private var attempt = 0 var body: some View { VStack(spacing: 0) { HStack { @@ -85,25 +132,33 @@ struct MaterialPreview: View { Text(item.amountLabel + " · " + item.dateLabel).font(.caption).foregroundStyle(.secondary) } 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) - 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) - 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) }.padding(18) Divider() HStack(spacing: 0) { GeometryReader { geometry in ScrollView([.horizontal, .vertical]) { - if let image = NSImage(contentsOf: item.previewURL) { + if let image { Image(nsImage: image).resizable().scaledToFit() .frame(width: max(1, geometry.size.width - 30) * zoom, height: max(1, geometry.size.height - 30) * zoom) .padding(15) + } else if loading { + ProgressView().frame(width: geometry.size.width, height: geometry.size.height) } 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 { Divider() ScrollView { @@ -115,6 +170,21 @@ struct MaterialPreview: View { }.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) } } diff --git a/reimburse/PairConnectionLayout.swift b/reimburse/PairConnectionLayout.swift new file mode 100644 index 0000000..6e0179b --- /dev/null +++ b/reimburse/PairConnectionLayout.swift @@ -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, selectedPayments: Set, + 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, 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 + } +} diff --git a/reimburse/PairPage.swift b/reimburse/PairPage.swift index 60d5329..0c56a20 100644 --- a/reimburse/PairPage.swift +++ b/reimburse/PairPage.swift @@ -1,79 +1,178 @@ import SwiftUI +enum PairAnchor: Hashable { + case material(String), invoiceViewport, paymentViewport +} + struct MaterialAnchorKey: PreferenceKey { - static var defaultValue: [String: Anchor] = [:] - static func reduce(value: inout [String: Anchor], nextValue: () -> [String: Anchor]) { + static var defaultValue: [PairAnchor: Anchor] = [:] + static func reduce(value: inout [PairAnchor: Anchor], nextValue: () -> [PairAnchor: Anchor]) { value.merge(nextValue(), uniquingKeysWith: { _, next in next }) } } struct PairPage: View { @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 { - 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 { - VStack(alignment: .leading, spacing: 6) { - Text("人工连连对").font(.title3.bold()) - Text("左右均可多选,确认后作为一个核对组处理。人工确认不限制金额相等。") - .font(.caption).foregroundStyle(.secondary) - } + Text("人工配对").font(.title2.bold()) Spacer() + Image(systemName: "photo").foregroundStyle(.secondary) + Slider(value: $thumbnailSize, in: 0...2, step: 1).frame(width: 100) + .help("缩略图大小").accessibilityLabel("缩略图大小") Picker("分类", selection: $store.category) { ForEach(expenseCategories, id: \.self) { Text($0) } - }.frame(width: 160) - Button("确认配对", action: store.confirmPair).buttonStyle(.borderedProminent).disabled(!store.canPair) + }.frame(width: 160).disabled(store.busy) } - HStack { - Label("已选 \(store.selectedInvoices.count) 张发票", systemImage: "doc.text") - Image(systemName: "link").foregroundStyle(.teal) - 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) + HStack(alignment: .top, spacing: 32) { + column(invoice: true, items: invoices) + column(invoice: false, items: payments) } .overlayPreferenceValue(MaterialAnchorKey.self) { anchors in GeometryReader { geometry in - Canvas { context, _ in - for invoice in store.selectedInvoices { - for payment in store.selectedPayments { - if let left = anchors[invoice], let right = anchors[payment] { - let start = CGPoint(x: geometry[left].maxX, y: geometry[left].midY) - let end = CGPoint(x: geometry[right].minX, y: geometry[right].midY) - var line = Path() - line.move(to: start) - let bend = max(30, (end.x - start.x) * 0.42) - line.addCurve(to: end, control1: CGPoint(x: start.x + bend, y: start.y), control2: CGPoint(x: end.x - bend, y: end.y)) - context.stroke(line, with: .color(.teal.opacity(0.6)), style: StrokeStyle(lineWidth: 2, dash: [6, 4])) - } - } - } + // Resolve anchors in the view update, not in Canvas's retained drawing closure. + let _ = scrollRevision + if let left = anchors[.invoiceViewport], let right = anchors[.paymentViewport] { + let frames = Dictionary(uniqueKeysWithValues: anchors.compactMap { key, anchor -> (String, CGRect)? in + guard case .material(let id) = key else { return nil } + return (id, geometry[anchor]) + }) + let layout = PairConnectionLayout( + invoiceIDs: invoices.map(\.id), paymentIDs: payments.map(\.id), + selectedInvoices: store.selectedInvoices, selectedPayments: store.selectedPayments, + frames: frames, invoiceViewport: geometry[left], paymentViewport: geometry[right]) + PairConnectionOverlay(layout: layout) } - }.allowsHitTesting(false).clipped() + }.allowsHitTesting(false).clipped().accessibilityHidden(true) } - }.padding(28).disabled(store.busy) + Divider() + HStack(spacing: 16) { + Label("\(store.selectedInvoices.count) 张发票", systemImage: "doc.text") + Image(systemName: "link").foregroundStyle(AppTheme.accent) + 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) -> some View { - VStack(alignment: .leading, spacing: 14) { - HStack { Text(title).font(.headline); Spacer(); Text("\(items.count)").foregroundStyle(.secondary) } + private func column(invoice: Bool, items: [Material]) -> some View { + let source = invoice ? store.state.unmatchedInvoices : store.state.unmatchedPayments + 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 { - 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 { - ScrollView { - LazyVGrid(columns: [GridItem(.adaptive(minimum: 180), spacing: 12)], spacing: 12) { - ForEach(items) { item in - MaterialCard(item: item, selected: selected.contains(item.id)) - .anchorPreference(key: MaterialAnchorKey.self, value: .bounds) { selected.contains(item.id) ? [item.id: $0] : [:] } + ScrollViewReader { proxy in + ScrollView { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 155 + thumbnailSize * 30), spacing: 12)], spacing: 12) { + ForEach(items) { item in + MaterialCard(item: item, selected: selected.contains(item.id), + 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] + } + .id(item.id) + } } - }.padding(3) + .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) } + + 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) + } + } } diff --git a/reimburse/PayeeProfileSheet.swift b/reimburse/PayeeProfileSheet.swift index d831fb9..ba0c9ac 100644 --- a/reimburse/PayeeProfileSheet.swift +++ b/reimburse/PayeeProfileSheet.swift @@ -7,6 +7,7 @@ struct PayeeProfileSheet: View { @State private var showAccount = false @State private var confirmDelete = false @State private var errorMessage: String? + var onSave: (() -> Void)? var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -54,6 +55,7 @@ struct PayeeProfileSheet: View { Button("保存") { do { try profileStore.save(draft) + onSave?() dismiss() } catch { errorMessage = error.localizedDescription } } @@ -61,7 +63,7 @@ struct PayeeProfileSheet: View { .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() } .confirmationDialog("删除本机保存的收款信息?", isPresented: $confirmDelete) { Button("删除收款信息", role: .destructive) { @@ -81,6 +83,7 @@ struct PayeeProfileSheet: View { struct PayeeProfileSummary: View { @ObservedObject var profileStore: PayeeProfileStore @State private var editing = false + @State private var saved = false var body: some View { HStack { @@ -89,14 +92,24 @@ struct PayeeProfileSummary: View { if let profile = profileStore.profile { Text("\(profile.recipient) · \(profile.bankName) · 尾号 \(profile.accountNumber.suffix(4))") .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 { Text(profileStore.loadError ?? "尚未填写,导出的收款信息将留空,不使用模板示例账号。") .font(.caption).foregroundStyle(.secondary) } } Spacer() + if saved { AppBadge(title: "已保存", symbol: "checkmark", tone: .success) } 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 + } } } diff --git a/reimburse/ScanPage.swift b/reimburse/ScanPage.swift index dfe0201..ca2e36d 100644 --- a/reimburse/ScanPage.swift +++ b/reimburse/ScanPage.swift @@ -4,13 +4,20 @@ struct ScanPage: View { @EnvironmentObject var store: WorkspaceStore @Binding var confirmClear: Bool @State private var folderTargeted = false + @State private var dismissedWarnings: Set = [] + @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 { ScrollView { - VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 24) { Panel { 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) { Text(store.state.rootPath.isEmpty ? "从一个材料文件夹开始" : store.state.rootPath).font(.title3.bold()) Text("文件夹内请分别放置:发票 / 付款截图 / 实物照片") @@ -18,99 +25,109 @@ struct ScanPage: View { Text(store.busy ? "正在处理材料,请完成后再导入" : folderTargeted ? "松开即可导入文件夹" : "将材料文件夹拖到此处,或点击右侧选择文件夹") .font(.callout.weight(.medium)).foregroundStyle(.teal) Text("支持图片和 PDF · PDF 处理首页 · 再次导入将替换当前工作区") - .font(.caption).foregroundStyle(.tertiary) + .font(.caption).foregroundStyle(.secondary) } Spacer() Button("选择文件夹", action: store.chooseFolder).buttonStyle(.borderedProminent).controlSize(.large).disabled(store.busy) } } .overlay { - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: AppTheme.radius) .strokeBorder(.teal.opacity(folderTargeted && !store.busy ? 1 : 0), style: StrokeStyle(lineWidth: 2, dash: [8, 5])) .allowsHitTesting(false) } .dropDestination(for: URL.self) { sources, _ in store.importFolders(sources) } isTargeted: { targeted in - folderTargeted = targeted + withAnimation(.easeInOut(duration: 0.15)) { folderTargeted = targeted } + } + if hasMaterials && pendingCount > 0 { + AppNotice(text: "\(pendingCount) 份材料待配对 · 已核对 \(store.state.matches.count) 组", + actionTitle: "前往人工配对", action: { store.page = .pair }) } ExpenseTemplateSummary { store.templateMessage = nil store.showExpenseTemplate = true } - if !store.state.warnings.isEmpty { - Panel { - VStack(alignment: .leading, spacing: 7) { - Label("需要留意", systemImage: "exclamationmark.triangle").foregroundStyle(.orange).font(.headline) - ForEach(store.state.warnings, id: \.self) { Text($0).font(.caption).foregroundStyle(.secondary) } + if !warningIndices.isEmpty { + VStack(alignment: .leading, spacing: 8) { + HStack { + Label("\(warningIndices.count) 条提醒", systemImage: "exclamationmark.triangle") + .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) { - 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) { - amountPanel("本次导入材料", subtitle: "包含已核对和待核对材料", invoice: store.state.directoryInvoiceTotal, payment: store.state.directoryPaymentTotal) - amountPanel("已核对材料", subtitle: "自动匹配与人工确认均计入", invoice: store.state.invoiceTotal, payment: store.state.paymentTotal) - } - if !store.state.matches.isEmpty { - Panel { + if hasMaterials { + HStack(alignment: .top, spacing: 16) { + StatNumber(title: "发票", value: "\(store.state.invoices.count)", symbol: "doc.text", detail: "\(store.state.unmatchedInvoices.count) 份待配对") + 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 { + Divider() VStack(alignment: .leading, spacing: 16) { Text("已核对付款 · 分类汇总").font(.headline) LazyVGrid(columns: [GridItem(.adaptive(minimum: 130))], alignment: .leading, spacing: 18) { 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 { VStack(alignment: .leading, spacing: 7) { Text(category).font(.caption).foregroundStyle(.secondary) Text(currency(total)).font(.headline).monospacedDigit() + ProgressView(value: total, total: max(total, store.state.paymentTotal)) + .tint(AppTheme.accent).accessibilityLabel("\(category)付款金额占比") } } } } } } + Divider() + HStack { + Label("识别完成 \(store.state.recognizedCount) 份", systemImage: "text.viewfinder") + .font(.caption).foregroundStyle(.secondary) + Spacer() + Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy) + } } - 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() - HStack { - Text("工作区自动保存在本机,不修改原始材料。").font(.caption).foregroundStyle(.secondary) - Spacer() - Button("清空重置", role: .destructive) { confirmClear = true }.disabled(store.busy) - } - }.padding(28) + }.padding(AppTheme.page) } - } - - private func summary(_ title: String, symbol: String, count: Int, subtitle: String) -> some View { - 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) - } + .onChange(of: store.state.invoices.map(\.id) + store.state.payments.map(\.id) + store.state.photos.map(\.id)) { _, _ in + dismissedWarnings = []; expandedWarnings = false } } private func amountPanel(_ title: String, subtitle: String, invoice: Double, payment: Double) -> some View { - Panel { VStack(alignment: .leading, spacing: 17) { VStack(alignment: .leading, spacing: 5) { Text(title).font(.headline) Text(subtitle).font(.caption).foregroundStyle(.secondary) } HStack { Text("发票总额").foregroundStyle(.secondary); Spacer(); Text(currency(invoice)).fontWeight(.semibold) } - HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold).foregroundStyle(.teal) } - }.monospacedDigit() - } + HStack { Text("付款总额").foregroundStyle(.secondary); Spacer(); Text(currency(payment)).fontWeight(.semibold) } + }.monospacedDigit().frame(maxWidth: .infinity) } } diff --git a/reimburse/SpreadsheetLayout.swift b/reimburse/SpreadsheetLayout.swift index d9b41d3..407b34f 100644 --- a/reimburse/SpreadsheetLayout.swift +++ b/reimburse/SpreadsheetLayout.swift @@ -101,6 +101,9 @@ enum SpreadsheetZoom: String, CaseIterable { struct ExpenseSpreadsheetPreview: View { let layout: SpreadsheetLayout var preferredSheet: String? + var selectedReference: String? + var highlightedRow: Int? + var onSelectCell: ((String) -> Void)? var onExpand: (() -> Void)? @State private var sheetIndex = 0 @State private var zoom: SpreadsheetZoom = .width @@ -115,13 +118,18 @@ struct ExpenseSpreadsheetPreview: View { HStack(spacing: 10) { Picker("工作表", selection: $sheetIndex) { 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) - 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)) } - }.pickerStyle(.segmented).labelsHidden().frame(width: 190) + if geometry.size.width >= 540 { + zoomPicker.pickerStyle(.segmented).frame(width: 190) + } else { + Menu { + 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: { Image(systemName: "minus.magnifyingglass") }.help("缩小").disabled(scale <= 0.1) @@ -135,14 +143,15 @@ struct ExpenseSpreadsheetPreview: View { Image(systemName: "arrow.up.left.and.arrow.down.right") }.help("在可调整大小的窗口中预览").accessibilityLabel("放大预览窗口") } - }.padding(.horizontal, 16).frame(height: 44) + }.padding(.horizontal, geometry.size.width < 540 ? 8 : 16).frame(height: 44) .background(Color(nsColor: .windowBackgroundColor)) 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) .background(.white).padding(24) .frame(minWidth: viewport.width, minHeight: viewport.height, alignment: .top) - }.background(Color(nsColor: NSColor(calibratedWhite: 0.92, alpha: 1))) + }.background(Color(nsColor: .underPageBackgroundColor)) .id(sheetIndex) }.onChange(of: sheetIndex) { _, _ in customScale = nil } .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 { let sheet: SpreadsheetSheet let scale: CGFloat + var selectedReference: String? + var highlightedRow: Int? + var onSelectCell: ((String) -> Void)? func makeNSView(context: Context) -> SpreadsheetCanvasView { SpreadsheetCanvasView(sheet: sheet, scale: scale) } func updateNSView(_ view: SpreadsheetCanvasView, context: Context) { view.sheet = sheet view.scale = scale + view.selectedReference = selectedReference + view.highlightedRow = highlightedRow + view.onSelectCell = onSelectCell view.needsDisplay = true } } @@ -175,6 +198,9 @@ struct SpreadsheetCanvas: NSViewRepresentable { var sheet: SpreadsheetSheet var scale: CGFloat + var selectedReference: String? + var highlightedRow: Int? + var onSelectCell: ((String) -> Void)? override var isFlipped: Bool { true } override var isOpaque: Bool { true } init(sheet: SpreadsheetSheet, scale: CGFloat) { @@ -186,6 +212,15 @@ struct SpreadsheetCanvas: NSViewRepresentable { } 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 { let value = UInt32(hex, radix: 16) ?? 0 return NSColor(srgbRed: CGFloat((value >> 16) & 255) / 255, @@ -305,6 +340,16 @@ struct SpreadsheetCanvas: NSViewRepresentable { } 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, diff --git a/reimburse/SpreadsheetPreview.swift b/reimburse/SpreadsheetPreview.swift index f105041..2e65a20 100644 --- a/reimburse/SpreadsheetPreview.swift +++ b/reimburse/SpreadsheetPreview.swift @@ -5,6 +5,9 @@ import SwiftUI struct SpreadsheetFilePreview: View { let url: URL var preferredSheet: String? + var selectedReference: String? + var highlightedRow: Int? + var onSelectCell: ((String) -> Void)? @State private var layout: SpreadsheetLayout? @State private var error: String? @State private var attempt = 0 @@ -17,7 +20,8 @@ struct SpreadsheetFilePreview: View { var body: some View { Group { 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 { VStack(spacing: 12) { ContentUnavailableView("模板预览暂不可用", systemImage: "doc.badge.exclamationmark", @@ -59,6 +63,9 @@ struct ExpenseDocumentPreview: View { let layout: SpreadsheetLayout var title = "报销单预览" var preferredSheet: String? + var selectedReference: String? + var highlightedRow: Int? + var onSelectCell: ((String) -> Void)? @StateObject private var expanded = SpreadsheetPreviewWindow() private var warnings: [String] { @@ -73,11 +80,13 @@ struct ExpenseDocumentPreview: View { var body: some View { 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) - } + } : nil) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.white) + .background(Color(nsColor: .textBackgroundColor)) if !warnings.isEmpty { Text(warnings.joined(separator: ";")) .font(.caption).foregroundStyle(.secondary) diff --git a/reimburse/TemplateMappingValidation.swift b/reimburse/TemplateMappingValidation.swift new file mode 100644 index 0000000..77e7308 --- /dev/null +++ b/reimburse/TemplateMappingValidation.swift @@ -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.. [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("列地址须在 A–IV 之间") } + 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 + } +} diff --git a/reimburse/WorkspacePresentation.swift b/reimburse/WorkspacePresentation.swift new file mode 100644 index 0000000..2c20e6e --- /dev/null +++ b/reimburse/WorkspacePresentation.swift @@ -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 } } +} diff --git a/reimburse/WorkspaceStore.swift b/reimburse/WorkspaceStore.swift index ea4e0fb..cf67091 100644 --- a/reimburse/WorkspaceStore.swift +++ b/reimburse/WorkspaceStore.swift @@ -4,7 +4,10 @@ import UniformTypeIdentifiers @MainActor 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 selectedInvoices: Set = [] @Published var selectedPayments: Set = [] @@ -21,6 +24,8 @@ final class WorkspaceStore: ObservableObject { @Published var purposes: [String: String] = [:] @Published var signatures = defaultSignatures @Published var showExpenseTemplate = false + @Published var showPayeeProfile = false + @Published var lastExportURL: URL? @Published private(set) var expenseTemplate: SavedExpenseTemplate? @Published private(set) var expenseTemplateLoadError: String? @Published var templateDraft: ExpenseTemplateDraft? @@ -28,14 +33,15 @@ final class WorkspaceStore: ObservableObject { @Published var showExpensePreview = false @Published private(set) var expensePreview: PreparedExpensePreview? @Published private(set) var expensePreviewError: String? - let payeeProfile = PayeeProfileStore() + let payeeProfile: PayeeProfileStore private let engine = EngineBridge() private let storage: URL private var cancelRequested = false private var templateStorage: ExpenseTemplateStorage { ExpenseTemplateStorage(root: storage) } - init() { - storage = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true) + init(storageURL: URL? = nil, payeeStorage: any PayeeStorage = KeychainPayeeStorage()) { + payeeProfile = PayeeProfileStore(storage: payeeStorage) + storage = storageURL ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("ReceiptDesk", isDirectory: true) do { try FileManager.default.createDirectory(at: storage, withIntermediateDirectories: true) let session = storage.appendingPathComponent("workspace.json") @@ -51,6 +57,7 @@ final class WorkspaceStore: ObservableObject { } catch { expenseTemplateLoadError = error.localizedDescription } + presentation = WorkspacePresentation(state) restoreMatchExplanations() } @@ -481,6 +488,7 @@ final class WorkspaceStore: ObservableObject { let data = try prepared.verifiedData(request: expenseExportRequest().request) try data.write(to: destination, options: .atomic) notice = "个人报销单已导出到 \(destination.lastPathComponent)" + lastExportURL = destination showExpensePreview = false NSWorkspace.shared.activateFileViewerSelecting([destination]) } catch { expensePreviewError = error.localizedDescription } @@ -523,6 +531,7 @@ final class WorkspaceStore: ObservableObject { _ = try await run(request) try Data(contentsOf: temporary).write(to: destination, options: .atomic) notice = "\(kind.title)已导出到 \(destination.lastPathComponent)" + lastExportURL = destination NSWorkspace.shared.activateFileViewerSelecting([destination]) } catch is CancellationError { notice = "已取消导出" diff --git a/reimburse/reimburseApp.swift b/reimburse/reimburseApp.swift index 36ebb26..475fa92 100644 --- a/reimburse/reimburseApp.swift +++ b/reimburse/reimburseApp.swift @@ -10,14 +10,17 @@ import SwiftUI @main struct reimburseApp: App { @StateObject private var store = WorkspaceStore() + @State private var area = "local" var body: some Scene { Window("贴票台", id: "workspace") { - TabView { - AdminView().tabItem { Label("项目管理", systemImage: "person.3") } - ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") } + TabView(selection: $area) { + ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") }.tag("local") + AdminView().tabItem { Label("项目管理", systemImage: "person.3") }.tag("admin") } + .tint(AppTheme.accent) .environmentObject(store) + .modifier(WorkspaceDockProgress(busy: store.busy, progress: store.progress)) .task { #if DEBUG if ProcessInfo.processInfo.arguments.contains("--verify-local-engine") { @@ -32,6 +35,23 @@ struct reimburseApp: App { Button("导入报账材料…", action: store.chooseFolder) .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) + } } } } diff --git a/tests/InterfaceSmokeTests.swift b/tests/InterfaceSmokeTests.swift new file mode 100644 index 0000000..198ab30 --- /dev/null +++ b/tests/InterfaceSmokeTests.swift @@ -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(_ 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() + 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 { + 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() + for x in (center - 8)...(center + 8) { + for y in top.. 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)") + } +} diff --git a/tests/PairConnectionLayoutTests.swift b/tests/PairConnectionLayoutTests.swift new file mode 100644 index 0000000..182e091 --- /dev/null +++ b/tests/PairConnectionLayoutTests.swift @@ -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, _ selectedPayments: Set, + _ 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") + } +} diff --git a/tests/TemplateMappingValidationTests.swift b/tests/TemplateMappingValidationTests.swift new file mode 100644 index 0000000..885d2a4 --- /dev/null +++ b/tests/TemplateMappingValidationTests.swift @@ -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("A1,B2; C3\nD4") == ["A1", "B2", "C3", "D4"]) + print("Template column/address bounds, duplicate targets and row validation passed") + } +} diff --git a/tests/WorkspacePresentationTests.swift b/tests/WorkspacePresentationTests.swift new file mode 100644 index 0000000..ab42c26 --- /dev/null +++ b/tests/WorkspacePresentationTests.swift @@ -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") + } +} diff --git a/tests/run-ui-checks.mjs b/tests/run-ui-checks.mjs new file mode 100644 index 0000000..f650104 --- /dev/null +++ b/tests/run-ui-checks.mjs @@ -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}`); diff --git a/界面优化方案.md b/界面优化方案.md new file mode 100644 index 0000000..8c723d9 --- /dev/null +++ b/界面优化方案.md @@ -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 全量 filter(7 × 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 重组不要改变「再次导入替换工作区」的确认流程。