Files
reimburse/reimburse/AdminPPTExport.swift
T
2026-09-13 15:20:40 +08:00

290 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Combine
import UniformTypeIdentifiers
@MainActor final class AdminPPTExport: ObservableObject {
@Published var records: [AdminExpense] = []
@Published var busy = false
@Published var status = ""
@Published var progress = 0.0
@Published var error: String?
@Published var destination: URL?
private var task: Task<Void, Never>?
private let engine = EngineBridge()
func cancel() {
task?.cancel()
engine.cancel()
}
func load(_ model: AdminStore) {
guard !busy else { return }
busy = true; error = nil; progress = 0; destination = nil
let projectID = model.projectID
let groupID = model.groupID
let origin = model.origin
let token = model.session?.token
task = Task {
defer { busy = false }
do {
var all: [AdminExpense] = []
var before = "0"
var seen: Set<String> = []
while true {
try Task.checkCancellation()
status = "正在加载已批准申请(已检查 \(seen.count) 笔)…"
let page: [AdminExpense] = try await model.request("/api/admin/expenses?groupId=\(groupID)&before=\(before)")
guard model.origin == origin, model.session?.token == token else { throw CancellationError() }
guard page.allSatisfy({ !seen.contains($0.id) }) else {
throw AdminFailure(message: "分页数据异常,请刷新后重试")
}
seen.formUnion(page.map(\.id))
all += page.filter { $0.state == "approved" && (projectID == 0 || $0.projectId == projectID) }
if page.count < 100 { break }
guard seen.count < 10000, let last = page.last else {
throw AdminFailure(message: "当前范围超过 10,000 笔申请,请关闭弹窗并选择具体组别后重试")
}
before = last.id
}
try Task.checkCancellation()
records = all
status = "已加载当前范围内全部已批准申请,共 \(all.count) 笔"
} catch is CancellationError {
status = "已取消加载,可重新加载"
} catch { records = []; self.error = error.localizedDescription }
}
}
func export(_ selected: [AdminExpense], model: AdminStore) {
guard !busy, !selected.isEmpty, selected.count <= 100 else { return }
busy = true; error = nil; destination = nil; progress = 0
let origin = model.origin
let token = model.session?.token
task = Task {
defer { busy = false }
var temporary: URL?
defer { if let temporary { try? FileManager.default.removeItem(at: temporary) } }
do {
let panel = NSSavePanel()
panel.nameFieldStringValue = "已批准报销贴票-\(selected.count)笔.pptx"
panel.allowedContentTypes = [UTType(filenameExtension: "pptx") ?? .data]
guard await panel.begin() == .OK, let output = panel.url else { return }
try Task.checkCancellation()
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("approved-ppt-" + UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
temporary = directory
var prepared: [[String: Any]] = []
var bytes = 0
for (index, record) in selected.enumerated() {
try Task.checkCancellation()
guard model.origin == origin, model.session?.token == token else { throw CancellationError() }
status = "正在核验并下载 \(index + 1)/\(selected.count) · \(record.name) · #\(record.id)"
let access: AdminAccess = try await model.request("/api/access")
guard access.groups.contains(where: { $0.id == record.groupId && $0.canManage }) else {
throw AdminFailure(message: "申请 #\(record.id) 所属组的管理权限已变化,请重新加载")
}
let detail: AdminExpense = try await model.request("/api/expenses/\(record.id)")
guard detail.state == "approved", detail.version == record.version,
detail.groupId == record.groupId, detail.userId == record.userId else {
throw AdminFailure(message: "申请 #\(record.id) 状态已变化,请重新加载后选择")
}
var materials: [[String: Any]] = []
let files = detail.files.keys.sorted().flatMap { detail.files[$0]?.files ?? [] }
for file in files {
try Task.checkCancellation()
guard file.size >= 0, file.size <= 10 * 1024 * 1024,
Int64(bytes) + file.size <= 200 * 1024 * 1024 else {
throw AdminFailure(message: "附件合计超过 200 MB 或单份超过 10 MB,请分批导出")
}
let data = try await model.fileData(file)
bytes += data.count
guard bytes <= 200 * 1024 * 1024 else { throw AdminFailure(message: "附件合计超过 200 MB,请分批导出") }
let pdf = file.mime == "application/pdf"
let path = directory.appendingPathComponent(UUID().uuidString + (pdf ? ".pdf" : ".png"))
if pdf {
try data.write(to: path, options: .atomic)
} else {
guard file.mime.hasPrefix("image/") else {
throw AdminFailure(message: "申请 #\(record.id) 的“\(file.name)”无法读取,请检查附件后重试")
}
let png = try await Task.detached(priority: .userInitiated) {
guard let bitmap = NSBitmapImageRep(data: data),
let converted = bitmap.representation(using: .png, properties: [:]) else {
throw AdminFailure(message: "图片“\(file.name)”无法读取,请检查附件后重试")
}
return converted
}.value
try Task.checkCancellation()
try png.write(to: path, options: .atomic)
}
materials.append(["path": path.path, "kind": file.kind, "name": file.name,
"kindName": model.uploadKinds.first { $0.kind == file.kind }?.name ?? "其他材料"])
}
prepared.append(["id": detail.id, "projectName": detail.projectName, "team": detail.team,
"name": detail.name, "date": detail.date, "type": detail.type,
"amountText": detail.amountText, "note": detail.note, "materials": materials])
progress = Double(index + 1) / Double(selected.count) * 0.75
}
try Task.checkCancellation()
status = "正在排版 PPT…"
let generated = directory.appendingPathComponent("approved.pptx")
let request = try JSONSerialization.data(withJSONObject: ["operation": "approved-ppt", "state": ["approvedRecords": prepared], "destination": generated.path])
_ = try await engine.run(request: request) { [weak self] progress, message in
Task { @MainActor in
self?.progress = 0.75 + progress * 0.24
self?.status = message
}
}
try Task.checkCancellation()
guard model.origin == origin, model.session?.token == token else { throw CancellationError() }
try Data(contentsOf: generated, options: .mappedIfSafe).write(to: output, options: .atomic)
destination = output; progress = 1; status = "已导出 \(selected.count) 笔已批准报销"
} catch is CancellationError {
status = "已取消导出,选择已保留"
} catch {
self.error = error.localizedDescription
status = "导出未完成,选择已保留;未写入不完整的 PPT"
}
}
}
}
struct AdminPPTExportSheet: View {
@ObservedObject var model: AdminStore
let initialSelection: Set<String>
@StateObject private var exporter = AdminPPTExport()
@Environment(\.dismiss) private var dismiss
@State private var selection: Set<String> = []
@State private var groupID: Int64 = 0
@State private var userID = ""
@State private var search = ""
@State private var order: AdminPPTOrder = .group
@State private var limitDates = false
@State private var startDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date()
@State private var endDate = Date()
private func dateKey(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.calendar = Calendar(identifier: .gregorian)
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: date)
}
private var invalidDates: Bool { limitDates && dateKey(startDate) > dateKey(endDate) }
private var filtered: [AdminExpense] {
guard !invalidDates else { return [] }
let records = AdminPPTSelection.filter(exporter.records, groupID: groupID, userID: userID,
start: limitDates ? dateKey(startDate) : nil, end: limitDates ? dateKey(endDate) : nil)
return order.sorted(records.filter { AdminDisplayFilter.matches(search, fields: [$0.name, $0.type, $0.id, $0.userId]) })
}
private var selected: [AdminExpense] { order.sorted(exporter.records.filter { selection.contains($0.id) }) }
private var groupChoices: [AdminExpense] {
var seen: Set<Int64> = []
return AdminPPTOrder.group.sorted(exporter.records).filter { seen.insert($0.groupId).inserted }
}
private var people: [AdminExpense] {
var seen: Set<String> = []
return AdminPPTOrder.person.sorted(exporter.records.filter { groupID == 0 || $0.groupId == groupID })
.filter { seen.insert($0.userId).inserted }
}
private var scopeText: String {
if let group = model.selectedGroup { return group.label }
return model.access?.projects.first { $0.id == model.projectID }?.name ?? "全部授权项目 / 组别"
}
var body: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
Label("导出已批准报销 PPT", systemImage: "doc.richtext").font(.title2.bold())
Spacer()
Button("关闭") { dismiss() }.disabled(exporter.busy)
}
Text("只导出已批准申请,逐笔保留归属信息。发票大图、付款凭证拼页;照片及其他材料自动贴入,多页 PDF 完整展开。")
.font(.callout).foregroundStyle(.secondary)
Text("导出范围:\(scopeText)").font(.callout.weight(.medium))
HStack {
Picker("组别", selection: $groupID) {
Text("当前范围全部组").tag(Int64(0))
ForEach(groupChoices) { Text("\($0.projectName) / \($0.team)").tag($0.groupId) }
}
Picker("人员", selection: $userID) {
Text("全部人员").tag("")
ForEach(people) { Text("\($0.name)(账户 \($0.userId)").tag($0.userId) }
}
}.disabled(exporter.busy)
HStack {
Toggle("按提交日期筛选", isOn: $limitDates)
if limitDates {
DatePicker("从", selection: $startDate, displayedComponents: .date)
DatePicker("至", selection: $endDate, displayedComponents: .date)
}
Spacer()
Picker("排序", selection: $order) {
ForEach(AdminPPTOrder.allCases) { Text($0.rawValue).tag($0) }
}.frame(width: 290)
}.disabled(exporter.busy)
if invalidDates { Text("开始日期不能晚于结束日期").foregroundStyle(.red) }
HStack {
TextField("搜索姓名、类型或申请编号", text: $search).textFieldStyle(.roundedBorder).frame(width: 235)
Button("全选筛选结果") { selection.formUnion(filtered.map(\.id)) }.disabled(filtered.isEmpty)
Button("取消当前筛选选择") { selection.subtract(filtered.map(\.id)) }.disabled(filtered.isEmpty)
Button("清空全部选择") { selection = [] }.disabled(selection.isEmpty)
Spacer()
Text("筛选结果 \(filtered.count) 笔 · 时间从早到晚").foregroundStyle(.secondary)
}.disabled(exporter.busy)
Table(filtered) {
TableColumn("选择") { record in
Toggle("选择申请 #\(record.id)", isOn: Binding(get: { selection.contains(record.id) }, set: {
if $0 { selection.insert(record.id) } else { selection.remove(record.id) }
})).labelsHidden()
}.width(45)
TableColumn("项目", value: \.projectName)
TableColumn("组别", value: \.team)
TableColumn("申请人", value: \.name)
TableColumn("类型", value: \.type)
TableColumn("金额") { Text(\($0.amountText)") }
TableColumn("提交日期", value: \.date)
TableColumn("编号", value: \.id).width(55)
}.frame(minHeight: 200).disabled(exporter.busy)
.overlay {
if filtered.isEmpty && !exporter.busy {
Text(exporter.records.isEmpty ? "当前范围暂无已批准申请" : "没有符合筛选条件的已批准申请")
.foregroundStyle(.secondary).allowsHitTesting(false)
}
}
Text("已选 \(selected.count) 笔 · 合计 ¥\(String(format: "%.2f", Double(selected.reduce(Int64(0)) { $0 + $1.amountCents }) / 100)) · 其中 \(selection.subtracting(Set(filtered.map(\.id))).count) 笔在当前筛选外")
.font(.callout.weight(.medium))
Text("切换筛选保留勾选;按所选排序合并为一个 PPT。每批最多 100 笔、原附件合计 200 MB。日期按提交日期,不是审批或发票日期。")
.font(.caption).foregroundStyle(.secondary)
if selected.count > 100 { Text("已超过 100 笔,请减少选择或分批导出").foregroundStyle(.red) }
if let error = exporter.error { Text(error).foregroundStyle(.red).textSelection(.enabled) }
HStack {
if exporter.busy {
ProgressView().controlSize(.small)
Text(exporter.status).lineLimit(1)
Spacer()
Button("取消") { exporter.cancel() }
} else {
Text(exporter.status).foregroundStyle(.secondary)
Spacer()
if let destination = exporter.destination {
Button("在 Finder 中显示") { NSWorkspace.shared.activateFileViewerSelecting([destination]) }
}
Button("重新加载") { exporter.load(model) }
Button("导出所选 \(selected.count) 笔") { exporter.export(selected, model: model) }
.buttonStyle(.borderedProminent).disabled(selected.isEmpty || selected.count > 100 || invalidDates)
}
}
if exporter.busy && exporter.progress > 0 { ProgressView(value: exporter.progress) }
}.padding(24).frame(width: 1040, height: 700)
.interactiveDismissDisabled(exporter.busy)
.task { selection = initialSelection; exporter.load(model) }
.onDisappear { exporter.cancel() }
.onChange(of: groupID) { _, _ in userID = "" }
.onChange(of: exporter.records.map(\.id)) { _, ids in selection.formIntersection(Set(ids)) }
.onChange(of: exporter.busy) { _, busy in
if !busy { selection.formIntersection(Set(exporter.records.map(\.id))) }
}
}
}