527 lines
31 KiB
Swift
527 lines
31 KiB
Swift
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: "无法更新钥匙串") }
|
||
}
|
||
}
|