Files
reimburse/reimburse/AdminStore.swift
T
2026-09-20 16:31:24 +08:00

527 lines
31 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 AppKit
import Security
import UniformTypeIdentifiers
private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse,
newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
completionHandler(nil)
}
}
@MainActor final class AdminStore: ObservableObject {
@Published var server = UserDefaults.standard.string(forKey: "admin.server") ?? "http://127.0.0.1:8080"
@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?
@Published var invitationPoster: NSImage?
@Published var expenses: [AdminExpense] = []
@Published var members: [AdminMember] = []
@Published var invites: [AdminInvite] = []
@Published var grants: [AdminGrant] = []
@Published var candidates: [AdminCandidate] = []
@Published var managedGroups: [AdminManagedGroup] = []
@Published var grantTicket: AdminGrantTicket?
@Published var grantImage: NSImage?
@Published var grantState = ""
@Published var grantRecipient = ""
@Published var audit: [AdminAudit] = []
@Published var policies: [AdminExpensePolicy] = []
@Published var uploadKinds: [AdminUploadKind] = []
@Published var selected: AdminExpense?
@Published var groupID: Int64 = 0
@Published var projectID: Int64 = 0
@Published var more = false
private var loginTask: Task<Void, Never>?
private var grantTask: Task<Void, Never>?
private var generation = 0
private var inviteScene: String?
private var invitationExpiresAt: String?
private var invitationMaxUses = 0
private var signedOrigin: String?
private let network: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 120
configuration.connectionProxyDictionary = [:]
return URLSession(configuration: configuration, delegate: AdminRedirectGuard(), delegateQueue: nil)
}()
var availableGroups: [AdminGroup] {
(access?.groups ?? []).filter { projectID == 0 || $0.projectId == projectID }
}
var groups: [AdminGroup] { availableGroups.filter(\.canManage) }
var selectedGroup: AdminGroup? { availableGroups.first { $0.id == groupID } }
var editableProjects: [AdminProject] {
(access?.projects ?? []).filter { $0.active && (access?.platform == true || $0.canLead) }
}
var visibleManagedGroups: [AdminManagedGroup] {
managedGroups.filter { projectID == 0 || $0.projectId == projectID }
}
var displayedExpenses: [AdminExpense] { expenses.filter { projectID == 0 || $0.projectId == projectID } }
var origin: String { server.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: "/")) }
func request<T: Decodable>(_ path: String, method: String = "GET", body: [String: Any]? = nil, publicRequest: Bool = false) async throws -> T {
guard let base = URL(string: origin), base.user == nil, base.password == nil,
base.scheme == "https" || (base.scheme == "http" && ["127.0.0.1", "localhost"].contains(base.host ?? "")),
let url = URL(string: origin + path) else { throw AdminFailure(message: "服务地址必须为 HTTPS;本机调试可使用 http://127.0.0.1:8080") }
let version = generation
let requestOrigin = origin
if !publicRequest && signedOrigin != requestOrigin { throw AdminFailure(message: "服务地址已改变,请重新登录") }
var req = URLRequest(url: url)
req.httpMethod = method
if !publicRequest, let token = session?.token { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") }
if let body { req.httpBody = try JSONSerialization.data(withJSONObject: body); req.setValue("application/json", forHTTPHeaderField: "Content-Type") }
let (data, response) = try await network.data(for: req)
guard version == generation, requestOrigin == origin else { throw CancellationError() }
guard let http = response as? HTTPURLResponse else { throw AdminFailure(message: "无效的服务响应") }
if http.statusCode == 401 && !publicRequest { clearSession(); throw AdminFailure(message: "登录已失效,请重新登录") }
if http.statusCode != 200 {
let message = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
throw AdminFailure(message: message?["msg"] as? String ?? "请求失败(\(http.statusCode)", status: http.statusCode)
}
let result = try JSONDecoder().decode(AdminEnvelope<T>.self, from: data)
guard result.code == 200, let value = result.data else { throw AdminFailure(message: result.msg) }
return value
}
func restore() async {
signedOrigin = origin
if let data = keychainRead(), let saved = try? JSONDecoder().decode(AdminSession.self, from: data) { session = saved }
if session != nil { await perform { try await self.reload() } }
}
func perform(_ operation: @escaping () async throws -> Void) async {
guard !busy else { return }
busy = true; error = nil
defer { busy = false }
do { try await operation() } catch is CancellationError {} catch { self.error = error.localizedDescription }
}
func cancelLogin() {
// Invalidate in-flight login results before allowing another login method.
generation += 1
loginTask?.cancel()
loginTask = nil
qr = nil
busy = false
error = nil
}
private func acceptLogin(_ value: AdminSession) async throws {
session = value
signedOrigin = origin
qr = nil
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
}
}
func passwordLogin(username: String, password: String) async {
guard !busy else { return }
cancelLogin()
let attempt = generation
busy = true
defer { if generation == attempt { busy = false } }
do {
UserDefaults.standard.set(origin, forKey: "admin.server")
let value: AdminSession = try await request("/api/admin/login/password", method: "POST",
body: ["username": username.trimmingCharacters(in: .whitespacesAndNewlines), "password": password], publicRequest: true)
guard generation == attempt, !Task.isCancelled else { return }
try await acceptLogin(value)
} catch is CancellationError {
} catch {
self.error = error.localizedDescription
}
}
func startLogin() {
guard !busy else { return }
cancelLogin()
let attempt = generation
busy = true
qr = nil; error = nil
UserDefaults.standard.set(origin, forKey: "admin.server")
loginTask = Task {
defer { if self.generation == attempt { self.busy = false } }
do {
let ticket: AdminTicket = try await self.request("/api/admin/login/ticket", method: "POST", body: [:], publicRequest: true)
guard self.generation == attempt, !Task.isCancelled else { return }
guard let bytes = Data(base64Encoded: ticket.image), let image = NSImage(data: bytes) else { throw AdminFailure(message: "二维码响应异常") }
self.qr = image
for _ in 0..<100 {
try await Task.sleep(nanoseconds: 3_000_000_000)
let poll: AdminPoll = try await self.request("/api/admin/login/poll", method: "POST", body: ["pollToken": ticket.pollToken], publicRequest: true)
guard self.generation == attempt, !Task.isCancelled else { return }
if let session = poll.session, poll.confirmed {
try await self.acceptLogin(session)
return
}
}
self.qr = nil
throw AdminFailure(message: "登录二维码已过期,请重新生成")
} catch is CancellationError {
} catch {
if self.generation == attempt { self.error = error.localizedDescription }
}
}
}
func reload() async throws {
let current: AdminAccess = try await request("/api/access")
guard current.admin else { clearSession(); throw AdminFailure(message: "当前账户没有管理权限") }
access = current
if !current.projects.contains(where: { $0.id == projectID }) { projectID = 0 }
if !availableGroups.contains(where: { $0.id == groupID }) { groupID = 0 }
grants = try await request("/api/admin/grants")
candidates = try await request("/api/admin/candidates")
managedGroups = try await request("/api/admin/groups")
policies = try await request("/api/expense-policies")
uploadKinds = try await request("/api/upload-kinds")
try await refreshExpenses()
try await refreshGroup()
}
func refreshExpenses(loadMore: Bool = false) async throws {
if !loadMore { expenses = []; selected = nil; more = false }
let before = loadMore ? (expenses.last?.id ?? "0") : "0"
let rows: [AdminExpense] = try await request("/api/admin/expenses?groupId=\(groupID)&before=\(before)")
expenses = loadMore ? expenses + rows : rows; more = rows.count == 100; selected = nil
}
func refreshGroup() async throws {
members = []; invites = []; invitationImage = nil; invitationPoster = nil; inviteScene = nil
invitationExpiresAt = nil; invitationMaxUses = 0
guard selectedGroup?.canManage == true else { return }
members = try await request("/api/admin/members?groupId=\(groupID)")
invites = try await request("/api/admin/invites?groupId=\(groupID)")
}
func detail(_ record: AdminExpense) async throws { selected = try await request("/api/expenses/\(record.id)") }
func approve(_ state: String, reason: String) async throws {
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])
inviteScene = i.scene
invitationExpiresAt = i.expiresAt
invitationMaxUses = i.maxUses
invites.insert(i, at: 0)
try await retryInviteQR()
}
func retryInviteQR() async throws {
guard let scene = inviteScene else { return }
let qr: AdminImage = try await request("/api/admin/invites/qr", method: "POST", body: ["scene": scene])
guard let data = Data(base64Encoded: qr.image), let image = NSImage(data: data) else { throw AdminFailure(message: "二维码图片异常") }
invitationImage = image
invitationPoster = makeInvitationPoster(qr: image)
}
private func makeInvitationPoster(qr: NSImage) -> NSImage? {
guard let group = selectedGroup else { return nil }
let canvas = NSSize(width: 900, height: 1200)
let poster = NSImage(size: canvas)
poster.lockFocus()
defer { poster.unlockFocus() }
NSColor(calibratedWhite: 0.965, alpha: 1).setFill()
NSBezierPath(rect: NSRect(origin: .zero, size: canvas)).fill()
NSColor(calibratedRed: 0.035, green: 0.18, blue: 0.19, alpha: 1).setFill()
NSBezierPath(rect: NSRect(x: 0, y: 860, width: canvas.width, height: 340)).fill()
drawPosterText("SCOOPEX", in: NSRect(x: 72, y: 1080, width: 756, height: 38), font: .systemFont(ofSize: 25, weight: .bold), color: NSColor(calibratedRed: 0.52, green: 0.86, blue: 0.78, alpha: 1))
drawPosterText("报销组入组邀请", in: NSRect(x: 72, y: 932, width: 756, height: 110), font: .systemFont(ofSize: 58, weight: .bold), color: .white)
drawPosterText("扫码加入项目报销组", in: NSRect(x: 76, y: 886, width: 748, height: 34), font: .systemFont(ofSize: 24, weight: .medium), color: NSColor(calibratedWhite: 0.86, alpha: 1))
let card = NSRect(x: 70, y: 235, width: 760, height: 575)
NSColor.white.setFill()
NSBezierPath(roundedRect: card, xRadius: 18, yRadius: 18).fill()
drawPosterText(group.projectName, in: NSRect(x: 112, y: 740, width: 676, height: 34), font: .systemFont(ofSize: 22, weight: .semibold), color: NSColor(calibratedWhite: 0.32, alpha: 1), alignment: .center)
drawPosterText(group.name, in: NSRect(x: 112, y: 690, width: 676, height: 54), font: .systemFont(ofSize: 38, weight: .bold), color: NSColor(calibratedRed: 0.035, green: 0.18, blue: 0.19, alpha: 1), alignment: .center)
let qrRect = NSRect(x: 285, y: 335, width: 330, height: 330)
NSColor.white.setFill()
NSBezierPath(rect: NSRect(x: 264, y: 314, width: 372, height: 372)).fill()
qr.draw(in: qrRect, from: .zero, operation: .sourceOver, fraction: 1)
drawPosterText("使用微信扫一扫", in: NSRect(x: 112, y: 270, width: 676, height: 30), font: .systemFont(ofSize: 20, weight: .medium), color: NSColor(calibratedWhite: 0.42, alpha: 1), alignment: .center)
drawPosterText("有效期至 \(formatPosterDate(invitationExpiresAt))", in: NSRect(x: 80, y: 158, width: 740, height: 28), font: .systemFont(ofSize: 20, weight: .semibold), color: NSColor(calibratedRed: 0.035, green: 0.18, blue: 0.19, alpha: 1), alignment: .center)
let quota = invitationMaxUses > 0 ? "本二维码最多邀请 \(invitationMaxUses) 人" : "每位成员只能使用一次"
drawPosterText(quota, in: NSRect(x: 80, y: 122, width: 740, height: 26), font: .systemFont(ofSize: 17), color: NSColor(calibratedWhite: 0.42, alpha: 1), alignment: .center)
drawPosterText("扫码后填写真实姓名,等待组管理员审核", in: NSRect(x: 80, y: 72, width: 740, height: 26), font: .systemFont(ofSize: 16), color: NSColor(calibratedWhite: 0.5, alpha: 1), alignment: .center)
return poster
}
private func drawPosterText(_ value: String, in rect: NSRect, font: NSFont, color: NSColor, alignment: NSTextAlignment = .left) {
let paragraph = NSMutableParagraphStyle(); paragraph.alignment = alignment
(value as NSString).draw(in: rect, withAttributes: [.font: font, .foregroundColor: color, .paragraphStyle: paragraph])
}
private func formatPosterDate(_ raw: String?) -> String {
guard let raw else { return "生成后失效" }
let iso = ISO8601DateFormatter()
iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let date = iso.date(from: raw) ?? {
iso.formatOptions = [.withInternetDateTime]
return iso.date(from: raw)
}()
guard let date else { return raw }
let formatter = DateFormatter(); formatter.locale = Locale(identifier: "zh_CN"); formatter.timeZone = TimeZone(identifier: "Asia/Shanghai"); formatter.dateFormat = "yyyy年M月d日 HH:mm"
return formatter.string(from: date)
}
func reviewMember(_ m: AdminMember, status: String) async throws {
let _: Bool = try await request("/api/admin/members/\(m.id)/review", method: "POST", body: ["status": status])
try await refreshGroup()
}
func revoke(_ i: AdminInvite) async throws {
let _: Bool = try await request("/api/admin/invites/\(i.id)/revoke", method: "POST", body: [:])
try await refreshGroup()
}
func grant(userID: String, role: String, scope: Int64, revoke: Bool = false) async throws {
let _: Bool = try await request("/api/admin/grants", method: "POST", body: ["userId": userID, "role": role, "scopeId": scope, "revoke": revoke])
try await reload()
}
func createGrantCode(role: String, scopes: [Int64]) async throws {
if grantTicket != nil, grantState == "pending" { try await revokeGrantCode() }
grantTask?.cancel()
grantTicket = nil; grantImage = nil; grantState = ""; grantRecipient = ""
let ticket: AdminGrantTicket = try await request("/api/admin/grants/ticket", method: "POST",
body: ["role": role, "scopeIds": scopes])
grantTicket = ticket; grantState = ticket.state
watchGrant(ticket)
try await retryGrantImage()
}
func retryGrantImage() async throws {
guard let ticket = grantTicket, grantState == "pending" else { return }
let value: AdminImage = try await request("/api/admin/grants/qr", method: "POST", body: ["scene": ticket.scene])
guard grantTicket?.scene == ticket.scene, grantState == "pending" else { return }
guard let data = Data(base64Encoded: value.image), let image = NSImage(data: data) else {
throw AdminFailure(message: "授权二维码响应异常")
}
grantImage = image
}
func refreshGrantStatus() async throws {
guard let ticket = grantTicket else { return }
let state: AdminGrantTicket = try await request("/api/admin/grants/poll", method: "POST", body: ["scene": ticket.scene])
guard grantTicket?.scene == ticket.scene else { return }
grantState = state.state; grantRecipient = state.userId
if state.state != "pending" {
grantImage = nil
if state.state == "confirmed" { try await reload() }
}
}
private func watchGrant(_ ticket: AdminGrantTicket) {
grantTask = Task {
while !Task.isCancelled, self.grantTicket?.scene == ticket.scene, self.grantState == "pending" {
do {
try await Task.sleep(nanoseconds: 3_000_000_000)
if Task.isCancelled { return }
if ticket.expiryDate.map({ $0 <= Date() }) == true {
self.grantState = "expired"; self.grantImage = nil; return
}
try await self.refreshGrantStatus()
} catch is CancellationError {
return
} catch let failure as AdminFailure where failure.status == 403 || failure.status == 404 {
self.grantState = "revoked"
self.grantImage = nil
self.error = failure.localizedDescription
return
} catch {
// Temporary network failures stay silent; polling resumes automatically.
continue
}
}
}
}
func revokeGrantCode() async throws {
guard let ticket = grantTicket else { return }
let _: Bool = try await request("/api/admin/grants/revoke-ticket", method: "POST", body: ["scene": ticket.scene])
grantTask?.cancel(); grantState = "revoked"; grantImage = nil
}
func createProject(_ name: String) async throws {
let _: AdminCreated = try await request("/api/admin/projects", method: "POST", body: ["name": name])
try await reload()
}
func createGroup(_ name: String) async throws {
let created: AdminCreated = try await request("/api/admin/groups", method: "POST", body: ["name": name.trimmingCharacters(in: .whitespacesAndNewlines), "projectId": projectID])
try await reload()
if groups.contains(where: { $0.id == created.id }) {
groupID = created.id
try await refreshExpenses()
try await refreshGroup()
}
}
func renameGroup(_ group: AdminManagedGroup, name: String) async throws {
let _: Bool = try await request("/api/admin/groups/\(group.id)", method: "PUT", body: ["name": name])
try await reload()
}
func toggleProject(_ p: AdminProject) async throws {
let _: Bool = try await request("/api/admin/active", method: "PUT", body: ["kind": "project", "id": p.id, "active": !p.active])
try await reload()
}
func toggleGroup(_ group: AdminManagedGroup) async throws {
let _: Bool = try await request("/api/admin/active", method: "PUT", body: ["kind": "group", "id": group.id, "active": !group.active])
if groupID == group.id { groupID = 0 }
try await reload()
}
func loadAudit() async throws { audit = try await request("/api/admin/audit") }
func saveExpenseType(name: String, previous: String?) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以管理报销类型") }
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, name.unicodeScalars.count <= 40 else { throw AdminFailure(message: "类型名称须为 1 至 40 字") }
let suffix = previous.map { "/" + ($0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0) } ?? ""
do {
let _: Bool = try await request("/api/admin/expense-types" + suffix, method: previous == nil ? "POST" : "PUT", body: ["name": name])
} catch let failure as AdminFailure where failure.status == 409 {
throw AdminFailure(message: "该类型名称已存在,请换一个名称或刷新列表后重试。")
}
if let previous, let index = policies.firstIndex(where: { $0.type == previous }) {
policies[index].type = name
} else if previous == nil {
policies.append(AdminExpensePolicy(type: name, description: name + "报销", tips: "请说明费用用途并提供相关材料。", requiredKinds: [], materials: [], active: false))
}
}
func deleteExpenseType(_ type: String) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以管理报销类型") }
let encoded = type.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? type
let _: Bool = try await request("/api/admin/expense-types/\(encoded)", method: "DELETE")
policies.removeAll { $0.type == type }
}
func updatePolicy(_ policy: AdminExpensePolicy) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改报销类型") }
let _: Bool = try await request("/api/admin/expense-policies/\(policy.type.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? policy.type)", method: "PUT", body: [
"description": policy.description, "tips": policy.tips,
"requiredKinds": policy.materials.filter(\.required).map(\.kind),
"materials": policy.materials.map { ["kind": $0.kind, "required": $0.required] as [String: Any] },
"active": policy.active
])
let refreshed: [AdminExpensePolicy] = try await request("/api/expense-policies")
guard let saved = refreshed.first(where: { $0.type == policy.type }) else {
throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。")
}
mergeSavedPolicy(saved)
notice = "“\(saved.type)”规则已保存"
}
func mergeSavedPolicy(_ saved: AdminExpensePolicy) {
if let index = policies.firstIndex(where: { $0.type == saved.type }) {
policies[index] = saved
}
}
func updateUploadKind(_ kind: AdminUploadKind) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改上传材料类型") }
let encoded = kind.kind.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? kind.kind
let _: Bool = try await request("/api/admin/upload-kinds/\(encoded)", method: "PUT", body: [
"name": kind.name, "hint": kind.hint, "allowImage": kind.allowImage,
"allowPDF": kind.allowPDF, "active": kind.active, "sortOrder": kind.sortOrder
])
uploadKinds = try await request("/api/upload-kinds")
}
func deleteUploadKind(_ kind: AdminUploadKind) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以删除上传材料类型") }
let encoded = kind.kind.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? kind.kind
let _: Bool = try await request("/api/admin/upload-kinds/\(encoded)", method: "DELETE")
uploadKinds.removeAll { $0.kind == kind.kind }
for index in policies.indices {
policies[index].materials.removeAll { $0.kind == kind.kind }
policies[index].requiredKinds.removeAll { $0 == kind.kind }
}
}
func export(_ ids: Set<String>) async throws {
guard !ids.isEmpty, ids.count <= 20 else { throw AdminFailure(message: "每次请选择 1 至 20 笔申请") }
try await exportArchive(body: ["ids": Array(ids)], filename: "报销凭证.zip")
}
func exportRange(startDate: Date, endDate: Date, projectID: Int64, groupID: Int64) async throws {
let calendar = Calendar.current
guard calendar.startOfDay(for: startDate) <= calendar.startOfDay(for: endDate) else {
throw AdminFailure(message: "开始日期不能晚于结束日期")
}
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .gregorian)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(identifier: "Asia/Shanghai")
formatter.dateFormat = "yyyy-MM-dd"
let start = formatter.string(from: startDate)
let end = formatter.string(from: endDate)
try await exportArchive(body: [
"startDate": start,
"endDate": end,
"projectId": projectID,
"groupId": groupID
], filename: "\(start)-\(end)-报销数据.zip")
}
private func exportArchive(body: [String: Any], filename: String) async throws {
let panel = NSSavePanel(); panel.nameFieldStringValue = filename
panel.allowedContentTypes = [.zip]
guard await panel.begin() == .OK, let destination = panel.url else { return }
guard signedOrigin == origin, let token = session?.token, let url = URL(string: origin + "/api/admin/export") else { throw AdminFailure(message: "请重新登录") }
let version = generation
var req = URLRequest(url: url); req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try JSONSerialization.data(withJSONObject: body)
let (data, response) = try await network.data(for: req)
guard version == generation, signedOrigin == origin else { throw CancellationError() }
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
let error = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
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
guard await panel.begin() == .OK, let destination = panel.url else { return }
let data = try await fileData(file)
try data.write(to: destination, options: .atomic)
}
func fileData(_ file: AdminFile) async throws -> Data {
guard signedOrigin == origin, let token = session?.token, let url = URL(string: origin + "/api/files/\(file.id)") else { throw AdminFailure(message: "请重新登录") }
let version = generation
var req = URLRequest(url: url); req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await network.data(for: req)
guard version == generation, signedOrigin == origin else { throw CancellationError() }
guard let http = response as? HTTPURLResponse else { throw AdminFailure(message: "无效的附件响应") }
if http.statusCode == 401 {
clearSession()
throw AdminFailure(message: "登录已失效,请重新登录")
}
guard http.statusCode == 200 else { throw AdminFailure(message: "附件加载失败或权限已撤销") }
guard data.count <= 10 * 1024 * 1024 else { throw AdminFailure(message: "附件超过预览大小限制") }
return data
}
func logout() async {
await perform {
let _: [String: Bool] = try await self.request("/api/auth/logout", method: "POST", body: [:])
self.clearSession()
}
}
private func clearSession() {
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)
signedOrigin = nil
}
private func keychainQuery() -> [String: Any] {
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: "Scoopex.Reimburse.Admin", kSecAttrAccount as String: signedOrigin ?? origin]
}
private func keychainRead() -> Data? {
var q = keychainQuery(); q[kSecReturnData as String] = true; q[kSecMatchLimit as String] = kSecMatchLimitOne
var value: CFTypeRef?; guard SecItemCopyMatching(q as CFDictionary, &value) == errSecSuccess else { return nil }; return value as? Data
}
private func keychainSave(_ data: Data) throws {
let q = keychainQuery()
let status = SecItemUpdate(q as CFDictionary, [kSecValueData as String: data] as CFDictionary)
if status == errSecItemNotFound {
var insert = q; insert[kSecValueData as String] = data; insert[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
guard SecItemAdd(insert as CFDictionary, nil) == errSecSuccess else { throw AdminFailure(message: "无法将登录凭证保存至钥匙串") }
} else if status != errSecSuccess { throw AdminFailure(message: "无法更新钥匙串") }
}
}