290 lines
17 KiB
Swift
290 lines
17 KiB
Swift
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))) }
|
||
}
|
||
}
|
||
}
|