This commit is contained in:
csj
2026-09-13 15:20:40 +08:00
parent 8bc9ae8046
commit da6cb24249
8 changed files with 566 additions and 4 deletions
+289
View File
@@ -0,0 +1,289 @@
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))) }
}
}
}