From 467e374bf6652bcd9b61aea4e4c2d9f0ce8106e0 Mon Sep 17 00:00:00 2001 From: baokaibo <19271189822@163.com> Date: Sat, 12 Sep 2026 16:25:42 +0800 Subject: [PATCH] Add native admin workspace --- .gitignore | 1 + PROJECT-ADMIN.md | 24 + reimburse.xcodeproj/project.pbxproj | 2 + reimburse/AdminModels.swift | 110 +++ reimburse/AdminStore.swift | 484 +++++++++ reimburse/AdminView.swift | 928 ++++++++++++++++++ .../AppIcon.appiconset/Contents.json | 88 +- reimburse/reimburseApp.swift | 5 +- 8 files changed, 1597 insertions(+), 45 deletions(-) create mode 100644 PROJECT-ADMIN.md create mode 100644 reimburse/AdminModels.swift create mode 100644 reimburse/AdminStore.swift create mode 100644 reimburse/AdminView.swift diff --git a/.gitignore b/.gitignore index 3624348..e82680c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ native-engine/*.spec native-engine/__pycache__/ native-engine/tests/__pycache__/ *.xcuserstate +xcuserdata/ .DS_Store diff --git a/PROJECT-ADMIN.md b/PROJECT-ADMIN.md new file mode 100644 index 0000000..e2f2c49 --- /dev/null +++ b/PROJECT-ADMIN.md @@ -0,0 +1,24 @@ +# 管理端接入 + +应用新增“项目管理”入口,原“本地贴票”入口及 OCR、核对、配对、Office 导出保持原样。 + +## 使用 + +1. 先启动 `scoopex-expense-api`,执行其数据库迁移。 +2. 微信小程序登录后查看“我的”账户 ID,由服务器操作者初始化首位平台管理员。 +3. 打开管理端,设置 API 地址,生成微信登录码,在手机小程序中确认。 +4. 平台管理员创建项目,按账户 ID 任命项目负责人。 +5. 项目负责人选择项目、创建组别,按账户 ID 勾选多个组授权管理员。 +6. 管理员选择具体组别,生成邀请二维码,审核扫码入组申请。 +7. 成员提交报销后,在报销审批列表打开详情,查看/下载凭证并批准或退回。 + +“全部授权组”汇总数据;单个组筛选用于成员和邀请管理。二维码和登录票据不是管理员授权渠道。 +登录凭证保存于 macOS 钥匙串;网络不使用代理。HTTP 仅允许本机地址,部署必须使用 HTTPS。 +平台管理员默认没有项目发票读取权限,需另行指定为项目负责人才能进入该项目业务。 + +## 构建 + +Xcode 工程已开启出站网络沙盒权限。 +该仓库当前没有 `native-engine/dist/receipt-engine-helper.app`,完整应用构建仍需按 README 重建原有 OCR 引擎。新增 Swift 管理界面可独立进行类型检查;不能把类型检查当作完整 OCR 安装包验收。 + +完整接口、初始化命令、部署注意事项见 Go 项目的 `PROJECT-ACCESS.md`。 diff --git a/reimburse.xcodeproj/project.pbxproj b/reimburse.xcodeproj/project.pbxproj index 2395362..530469e 100644 --- a/reimburse.xcodeproj/project.pbxproj +++ b/reimburse.xcodeproj/project.pbxproj @@ -273,6 +273,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readwrite; @@ -306,6 +307,7 @@ COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; ENABLE_APP_SANDBOX = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = NO; ENABLE_USER_SELECTED_FILES = readwrite; diff --git a/reimburse/AdminModels.swift b/reimburse/AdminModels.swift new file mode 100644 index 0000000..6db3122 --- /dev/null +++ b/reimburse/AdminModels.swift @@ -0,0 +1,110 @@ +import Foundation + +struct AdminUser: Codable { let id: String; let name: String } +struct AdminCandidate: Codable, Identifiable { + let id: String + let name: String + let team: String +} +struct AdminSession: Codable { let token: String; let expiresAt: String; let user: AdminUser } +struct AdminProject: Codable, Identifiable { let id: Int64; let name: String; let canLead: Bool; let active: Bool } +struct AdminGroup: Codable, Identifiable { + let id: Int64; let projectId: Int64; let name: String; let projectName: String + let canManage: Bool; let memberStatus: String; let memberName: String + var label: String { "\(projectName) / \(name)" } +} +struct AdminManagedGroup: Codable, Identifiable { + let id: Int64 + let projectId: Int64 + let name: String + let projectName: String + let active: Bool + let canEdit: Bool + var label: String { "\(projectName) / \(name)" } +} +struct AdminAccess: Codable { + let userId: String; let platform: Bool; let admin: Bool + let projects: [AdminProject]; let groups: [AdminGroup] +} +struct AdminGrant: Codable, Identifiable { + let id: Int64; let userId: String; let name: String; let role: String; let scopeId: Int64 +} +struct AdminMember: Codable, Identifiable { + let id: Int64; let groupId: Int64; let userId: String; let name: String; let status: String +} +struct AdminFile: Codable, Identifiable { + let id: String; let name: String; let kind: String; let mime: String; let size: Int64 +} +struct AdminEvent: Codable { let action: String; let actorId: String; let detail: String; let date: String } +struct AdminExpense: Codable, Identifiable { + let id: String; let groupId: Int64; let projectId: Int64; let userId: String + let name: String; let team: String; let projectName: String; let type: String + let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String + let date: String; let version: Int; let files: [String: AdminFile]; let events: [AdminEvent] +} +struct AdminExpenseMaterial: Codable, Identifiable { + let kind: String + let name: String + let hint: String + var required: Bool + let allowImage: Bool + let allowPDF: Bool + let active: Bool + let sortOrder: Int + var id: String { kind } +} +struct AdminUploadKind: Codable, Identifiable { + let kind: String + var name: String + var hint: String + var allowImage: Bool + var allowPDF: Bool + var active: Bool + var sortOrder: Int + var id: String { kind } +} +struct AdminExpensePolicy: Codable, Identifiable { + let type: String + var description: String + var tips: String + var requiredKinds: [String] + var materials: [AdminExpenseMaterial] + var active: Bool + var id: String { type } +} +struct AdminInvite: Codable, Identifiable { + let id: Int64; let groupId: Int64; let expiresAt: String; let maxUses: Int; let uses: Int; let active: Bool + let scene: String? +} +struct AdminTicket: Codable { let scene: String; let pollToken: String; let expiresAt: String; let image: String } +struct AdminPoll: Codable { let confirmed: Bool; let session: AdminSession? } +struct AdminImage: Codable { let image: String } +struct AdminGrantScope: Codable, Identifiable { + let id: Int64; let projectId: Int64; let projectName: String; let name: String +} +struct AdminGrantTicket: Codable { + let scene: String; let role: String; let scopes: [AdminGrantScope] + let expiresAt: String; let state: String; let userId: String + var expiryDate: Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: expiresAt) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: expiresAt) + } +} +struct AdminCreated: Codable { let id: Int64 } +struct AdminAudit: Codable, Identifiable { + let id: Int64; let actorId: String; let projectId: Int64; let groupId: Int64 + let action: String; let resourceId: String; let detail: String; let date: String +} +struct AdminEnvelope: Decodable { let code: Int; let msg: String; let data: T? } +struct AdminFailure: LocalizedError { + let message: String + let status: Int? + init(message: String, status: Int? = nil) { + self.message = message + self.status = status + } + var errorDescription: String? { message } +} diff --git a/reimburse/AdminStore.swift b/reimburse/AdminStore.swift new file mode 100644 index 0000000..e113b81 --- /dev/null +++ b/reimburse/AdminStore.swift @@ -0,0 +1,484 @@ +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 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? + private var grantTask: Task? + 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(_ 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.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() + } 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() + } + 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 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: .urlPathAllowed) ?? 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] }, + "active": policy.active + ]) + policies = try await request("/api/expense-policies") + } + 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 = try await request("/api/upload-kinds") + policies = try await request("/api/expense-policies") + } + func export(_ ids: Set) 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) + } + 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 + 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: "无法更新钥匙串") } + } +} diff --git a/reimburse/AdminView.swift b/reimburse/AdminView.swift new file mode 100644 index 0000000..afdf183 --- /dev/null +++ b/reimburse/AdminView.swift @@ -0,0 +1,928 @@ +import SwiftUI + +struct AdminView: View { + @StateObject private var model = AdminStore() + @State private var tab = "报销审批" + @State private var name = "" + @State private var groupName = "" + @State private var renameTarget: AdminManagedGroup? + @State private var renameValue = "" + @State private var selectedCandidate = "" + @State private var role = "group" + @State private var selectedGroups: Set = [] + @State private var selectedExpenses: Set = [] + @State private var showDateExport = false + @State private var exportStartDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() + @State private var exportEndDate = Date() + @State private var hours = 168 + @State private var uses = 50 + @State private var reason = "" + @State private var confirmation: AdminAction? + @State private var approvalConfirmation: AdminAction? + @State private var loginMode = "password" + @State private var loginUsername = "" + @State private var loginPassword = "" + @State private var materialKind = "" + @State private var materialName = "" + @State private var materialHint = "" + @State private var materialAllowsImage = true + @State private var materialAllowsPDF = false + @State private var materialActive = true + @State private var editingMaterialKind = "" + @State private var selectedPolicyType = "" + @State private var newPolicyMaterialName = "" + @State private var newPolicyMaterialHint = "" + @State private var newPolicyMaterialAllowsImage = true + @State private var newPolicyMaterialAllowsPDF = true + @State private var editingPolicyMaterialKind = "" + + var body: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "building.2.crop.circle").font(.title).foregroundStyle(.teal) + Text("项目报销管理").font(.title2.bold()) + Spacer() + if let a = model.access { + Text(a.platform ? "平台管理员" : "项目 / 组管理").foregroundStyle(.secondary) + Text("账户 \(a.userId)").monospacedDigit() + Button { run { try await model.reload() } } label: { Image(systemName: "arrow.clockwise") }.help("刷新权限和记录") + Button("退出登录") { Task { await model.logout() } } + } + }.padding(20) + Divider() + if model.busy { ProgressView().controlSize(.small).padding(8) } + if let error = model.error { + HStack { + Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.red) + Spacer() + Button { model.error = nil } label: { Image(systemName: "xmark") }.help("关闭提示") + }.padding(12) + } + if model.access == nil { login } + else { + HStack { + Picker("项目", selection: $model.projectID) { + Text("全部授权项目").tag(Int64(0)) + ForEach(model.access?.projects ?? []) { Text($0.name).tag($0.id) } + }.frame(maxWidth: 300) + Picker("组别", selection: $model.groupID) { + Text("全部可见组").tag(Int64(0)) + ForEach(model.availableGroups) { Text(groupOption($0)).tag($0.id) } + }.frame(maxWidth: 350) + Spacer() + }.padding(16).disabled(model.busy) + Picker("管理视图", selection: $tab) { + ForEach(["报销审批", "成员与邀请", "组别管理", "项目与授权", "报销类型", "操作记录"], id: \.self) { Text($0) } + }.pickerStyle(.segmented).padding(.horizontal, 16) + Group { + switch tab { + case "成员与邀请": members + case "组别管理": groupManagement + case "项目与授权": permissions + case "报销类型": policies + case "操作记录": audit + default: expenses + } + }.frame(maxWidth: .infinity, maxHeight: .infinity).disabled(model.busy) + } + } + .frame(minWidth: 1050, minHeight: 700) + .task { await model.restore() } + .onChange(of: loginMode) { _, _ in model.cancelLogin(); loginPassword = "" } + .onChange(of: model.access?.userId) { _, _ in role = model.access?.platform == true ? "leader" : "group" } + .onChange(of: model.projectID) { _, _ in model.groupID = 0; selectedGroups = []; run { try await model.refreshExpenses(); try await model.refreshGroup() } } + .onChange(of: model.groupID) { _, _ in run { try await model.refreshExpenses(); try await model.refreshGroup() } } + .onChange(of: tab) { _, new in if new == "操作记录" { run { try await model.loadAudit() } } } + .onChange(of: model.policies.map(\.type)) { _, types in + if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" } + } + .sheet(item: $model.selected) { detail($0) } + .sheet(isPresented: $showDateExport) { + dateExport + } + .sheet(item: $renameTarget) { group in + VStack(alignment: .leading, spacing: 16) { + Text("重命名组别").font(.title3.bold()) + TextField("组别名称", text: $renameValue).textFieldStyle(.roundedBorder) + HStack { + Spacer() + Button("取消") { renameTarget = nil } + Button("保存") { + let value = renameValue + renameTarget = nil + run { try await model.renameGroup(group, name: value) } + }.buttonStyle(.borderedProminent).disabled(renameValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding(24).frame(width: 360) + } + .confirmationDialog(confirmation?.title ?? "", isPresented: Binding(get: { confirmation != nil }, set: { if !$0 { confirmation = nil } }), titleVisibility: .visible) { + Button("确认") { let action = confirmation; confirmation = nil; action?.perform() } + Button("取消", role: .cancel) { confirmation = nil } + } + } + private var login: some View { + VStack(spacing: 20) { + Image(systemName: "lock.shield").font(.system(size: 40)).foregroundStyle(.teal) + Text("管理端登录").font(.title2.bold()) + TextField("HTTPS 服务地址", text: $model.server).textFieldStyle(.roundedBorder).frame(width: 400).disabled(model.busy) + Picker("登录方式", selection: $loginMode) { + Text("账号密码").tag("password") + Text("微信扫码").tag("wechat") + }.pickerStyle(.segmented).frame(width: 400) + Group { + if loginMode == "password" { + VStack(spacing: 16) { + TextField("用户名", text: $loginUsername) + .textContentType(.username).textFieldStyle(.roundedBorder) + .accessibilityLabel("用户名") + SecureField("密码", text: $loginPassword) + .textContentType(.password).textFieldStyle(.roundedBorder) + .accessibilityLabel("密码").onSubmit { submitPassword() } + Button("登录管理端", action: submitPassword) + .buttonStyle(.borderedProminent) + .disabled(loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || loginPassword.isEmpty) + }.frame(width: 400).disabled(model.busy) + } else { + VStack(spacing: 16) { + if let image = model.qr { + Image(nsImage: image).resizable().interpolation(.none).scaledToFit() + .frame(width: 240, height: 240).background(.white) + } else { + Image(systemName: "qrcode").font(.system(size: 64)).foregroundStyle(.secondary) + .frame(width: 240, height: 240) + } + Button(model.qr == nil ? "生成微信登录码" : "等待微信确认") { model.startLogin() } + .buttonStyle(.borderedProminent).disabled(model.busy) + if model.busy { Button("取消扫码", action: model.cancelLogin) } + } + } + }.frame(height: 320) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func submitPassword() { + guard !model.busy, !loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !loginPassword.isEmpty else { return } + let password = loginPassword + let username = loginUsername + loginPassword = "" + Task { await model.passwordLogin(username: username, password: password) } + } + private var expenses: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("已加载 \(model.displayedExpenses.count) 笔").foregroundStyle(.secondary) + Button { showDateExport = true } label: { + Label("按时间下载数据", systemImage: "calendar.badge.arrow.down") + } + .buttonStyle(.borderedProminent) + Button { run { try await model.export(selectedExpenses) } } label: { Label("下载所选凭证", systemImage: "arrow.down.to.line") }.disabled(selectedExpenses.isEmpty || selectedExpenses.count > 20) + Spacer() + Text("¥" + String(format: "%.2f", Double(model.displayedExpenses.reduce(Int64(0)) { $0 + $1.amountCents }) / 100)).monospacedDigit() + } + Table(model.displayedExpenses, selection: $selectedExpenses) { + TableColumn("申请人", value: \.name) + TableColumn("项目", value: \.projectName) + TableColumn("组别", value: \.team) + TableColumn("类型", value: \.type) + TableColumn("金额") { Text("¥\($0.amountText)").monospacedDigit() } + TableColumn("状态", value: \.status) + TableColumn("提交日期", value: \.date) + TableColumn("操作") { record in + Button { reason = ""; run { try await model.detail(record) } } label: { + Label("查看详情", systemImage: "doc.text.magnifyingglass") + } + .buttonStyle(.bordered) + .help("查看申请、备注和凭证") + }.width(min: 120, ideal: 130) + } + if model.more { Button("加载更多") { run { try await model.refreshExpenses(loadMore: true) } } } + }.padding(16) + } + private var dateExport: some View { + VStack(alignment: .leading, spacing: 18) { + HStack { + Image(systemName: "calendar.badge.arrow.down") + .font(.title2) + .foregroundStyle(.teal) + Text("按时间下载报销数据").font(.title3.bold()) + } + Text("导出范围:\(exportScopeText)") + .foregroundStyle(.secondary) + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 14) { + GridRow { + Text("开始日期") + DatePicker("开始日期", selection: $exportStartDate, displayedComponents: .date) + .labelsHidden() + } + GridRow { + Text("结束日期") + DatePicker("结束日期", selection: $exportEndDate, displayedComponents: .date) + .labelsHidden() + } + } + Label("压缩包目录:日期-xxx组 / 姓名-报销类型", systemImage: "folder") + .font(.callout) + .foregroundStyle(.secondary) + HStack { + Spacer() + Button("取消") { showDateExport = false } + Button { + let start = exportStartDate + let end = exportEndDate + let project = model.projectID + let group = model.groupID + showDateExport = false + run { try await model.exportRange(startDate: start, endDate: end, projectID: project, groupID: group) } + } label: { + Label("选择位置并下载", systemImage: "arrow.down.to.line") + } + .buttonStyle(.borderedProminent) + .disabled(Calendar.current.startOfDay(for: exportStartDate) > Calendar.current.startOfDay(for: exportEndDate)) + } + } + .padding(24) + .frame(width: 470) + } + private var exportScopeText: String { + if let group = model.selectedGroup { return group.label } + if let project = model.access?.projects.first(where: { $0.id == model.projectID }) { return "\(project.name) / 全部可见组" } + return "全部授权项目 / 全部可见组" + } + private func detail(_ record: AdminExpense) -> some View { + VStack(alignment: .leading, spacing: 16) { + HStack { Text("申请 #\(record.id)").font(.title2.bold()); Spacer(); Button { model.selected = nil } label: { Image(systemName: "xmark") }.help("关闭详情") } + Text("\(record.projectName) / \(record.team)").foregroundStyle(.secondary) + HStack { Text("\(record.name) · \(record.type)"); Spacer(); Text("¥\(record.amountText)").font(.title2).monospacedDigit(); Text(record.status) } + if record.state == "pending", record.userId != model.access?.userId || model.access?.platform == true { + VStack(alignment: .leading, spacing: 10) { + Text("审批操作").font(.headline) + TextField("审批原因(退回 / 拒绝时必填)", text: $reason, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(2...4) + HStack { + Button { + approvalConfirmation = AdminAction(title: "退回 / 拒绝申请 #\(record.id)?") { + run { try await model.approve("returned", reason: reason) } + } + } label: { + Label("退回 / 拒绝", systemImage: "arrow.uturn.backward.circle") + } + .buttonStyle(.bordered) + .tint(.red) + .disabled(reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + Spacer() + Button { + approvalConfirmation = AdminAction(title: "批准申请 #\(record.id),金额 ¥\(record.amountText)?") { + run { try await model.approve("approved", reason: reason) } + } + } label: { + Label("批准报销", systemImage: "checkmark.circle") + } + .buttonStyle(.borderedProminent) + } + } + .padding(14) + .background(.quaternary.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else if record.state == "pending" { + Label("不能审批本人提交的报销申请", systemImage: "person.crop.circle.badge.exclamationmark") + .foregroundStyle(.secondary) + } + if !record.note.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("申请人备注").font(.caption).foregroundStyle(.secondary) + Text(record.note).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.quaternary.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + Divider() + ForEach(record.files.values.sorted(by: { $0.kind < $1.kind })) { file in + AdminAttachmentRow(model: model, file: file) + } + ScrollView { + ForEach(Array(record.events.enumerated()), id: \.offset) { _, event in + HStack(alignment: .top) { Text(event.date).foregroundStyle(.secondary); Text(event.detail.isEmpty ? event.action : event.detail); Spacer(); Text("账户 \(event.actorId)") }.font(.caption).padding(.vertical, 4) + } + }.frame(maxHeight: 160) + if let error = model.error { Text(error).foregroundStyle(.red) } + } + .padding(24) + .frame(width: 680) + .disabled(model.busy) + .confirmationDialog(approvalConfirmation?.title ?? "", isPresented: Binding( + get: { approvalConfirmation != nil }, + set: { if !$0 { approvalConfirmation = nil } } + ), titleVisibility: .visible) { + Button("确认") { + let action = approvalConfirmation + approvalConfirmation = nil + action?.perform() + } + Button("取消", role: .cancel) { approvalConfirmation = nil } + } + } + private var members: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Label("管理组", systemImage: "person.3") + Picker("管理组", selection: $model.groupID) { + Text("请选择管理组").tag(Int64(0)) + ForEach(model.availableGroups) { group in + Text(groupOption(group)).tag(group.id) + } + }.labelsHidden().frame(maxWidth: 360) + Spacer() + } + if model.groupID == 0 { + VStack(spacing: 12) { + ContentUnavailableView(model.availableGroups.isEmpty ? "当前项目暂无可见组" : "请选择一个管理组", systemImage: "person.3") + if model.projectID != 0 { + Button("查看全部项目的组") { model.projectID = 0 } + } + if !model.editableProjects.isEmpty { + Button("组别管理") { tab = "组别管理" } + } + if model.access?.platform == true { + Button("项目与授权") { tab = "项目与授权" } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let group = model.selectedGroup, !group.canManage { + VStack(spacing: 12) { + ContentUnavailableView("\(group.label) · 未授权", systemImage: "lock", + description: Text("当前账户没有本组成员管理权限")) + if model.access?.platform == true { + Button("项目负责人授权") { + model.projectID = group.projectId + role = "leader" + tab = "项目与授权" + } + } + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + HStack { + Stepper("失效时间 \(hours) 小时", value: $hours, in: 1...720).frame(width: 230) + Text("生成后约 \(hours) 小时失效").foregroundStyle(.secondary).font(.caption) + Stepper("最多 \(uses) 人", value: $uses, in: 1...1000).frame(width: 190) + Button { run { try await model.createInvite(hours: hours, uses: uses) } } label: { Label("生成邀请", systemImage: "qrcode") } + Button { run { try await model.retryInviteQR() } } label: { Image(systemName: "arrow.clockwise") }.help("重新加载刚生成的二维码") + } + if let poster = model.invitationPoster { + HStack { + Image(nsImage: poster).resizable().interpolation(.high).scaledToFit().frame(width: 300, height: 400) + VStack(alignment: .leading, spacing: 10) { + Text("邀请海报已生成").font(.headline) + Text("二维码和有效期已经排版到海报中,可直接保存后发送给成员。").foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true) + Button { saveQR(poster, filename: "报销组入组邀请海报.png") } label: { Label("保存邀请海报", systemImage: "square.and.arrow.down") }.buttonStyle(.borderedProminent) + Button { run { try await model.retryInviteQR() } } label: { Label("重新生成海报", systemImage: "arrow.clockwise") } + }.frame(maxWidth: 380, alignment: .leading) + } + } + Table(model.members) { + TableColumn("姓名", value: \.name) + TableColumn("账户 ID", value: \.userId) + TableColumn("状态") { Text(memberState($0.status)) } + TableColumn("操作") { m in + HStack { + if m.status == "pending" { + Button("批准入组") { run { try await model.reviewMember(m, status: "active") } } + Button("拒绝") { confirm("拒绝 \(m.name) 的入组申请?") { try await model.reviewMember(m, status: "rejected") } } + } else if m.status == "active" { + Button("停用") { confirm("停用 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "disabled") } } + } else { + Button("恢复") { confirm("恢复 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "active") } } + } + } + }.width(min: 200) + }.frame(minHeight: 180) + Text("邀请记录").font(.headline) + List(model.invites) { i in + HStack { + Text("#\(i.id)"); Text("已使用 \(i.uses)/\(i.maxUses)"); Text(i.expiresAt).foregroundStyle(.secondary); Spacer() + if i.active { + Button("撤销") { confirm("撤销邀请 #\(i.id)?") { try await model.revoke(i) } } + } else { + Text("已撤销").foregroundStyle(.secondary) + } + } + }.frame(minHeight: 120) + } + }.padding(16) + } + private var groupManagement: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("组别管理").font(.headline) + Spacer() + Picker("所属项目", selection: $model.projectID) { + Text("全部项目").tag(Int64(0)) + ForEach((model.access?.projects ?? []).filter(\.active)) { project in + Text(project.name).tag(project.id) + } + }.frame(maxWidth: 340) + } + if model.editableProjects.contains(where: { $0.id == model.projectID }) { + HStack { + TextField("新组别名称", text: $groupName).textFieldStyle(.roundedBorder) + Button { + let value = groupName + run { try await model.createGroup(value); groupName = "" } + } label: { Label("创建组别", systemImage: "plus") } + .disabled(groupName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || groupName.count > 80) + } + } else if model.projectID == 0, !model.editableProjects.isEmpty { + Menu { + ForEach(model.editableProjects) { project in + Button(project.name) { model.projectID = project.id } + } + } label: { Label("选择项目并新建组", systemImage: "plus") } + } + if model.visibleManagedGroups.isEmpty { + ContentUnavailableView("暂无组别", systemImage: "rectangle.3.group") + if model.access?.projects.isEmpty == true, model.access?.platform == true { + Button("创建项目") { tab = "项目与授权" } + } + } else { + Table(model.visibleManagedGroups) { + TableColumn("组别", value: \.name) + TableColumn("所属项目", value: \.projectName) + TableColumn("组 ID") { Text("#\($0.id)").monospacedDigit() }.width(80) + TableColumn("状态") { Text($0.active ? "已启用" : "已停用").foregroundStyle($0.active ? .primary : .secondary) }.width(80) + TableColumn("操作") { group in + HStack { + if group.canEdit { + Button { + renameValue = group.name + renameTarget = group + } label: { Image(systemName: "pencil") }.help("重命名组别") + Button { + confirm("\(group.active ? "停用" : "恢复") \(group.name)?") { try await model.toggleGroup(group) } + } label: { Image(systemName: group.active ? "pause.circle" : "play.circle") } + .help(group.active ? "停用组别" : "恢复组别") + } + if model.availableGroups.contains(where: { $0.id == group.id }) { + Button { + model.groupID = group.id + tab = "成员与邀请" + } label: { Image(systemName: "person.3") }.help("管理本组成员") + } + } + }.width(130) + } + } + }.padding(16) + } + private var permissions: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if model.access?.platform == true { + Text("项目管理").font(.headline) + HStack { + TextField("新项目名称", text: $name).textFieldStyle(.roundedBorder) + Button { run { try await model.createProject(name); name = "" } } label: { Label("创建项目", systemImage: "plus") }.disabled(name.isEmpty) + } + ForEach(model.access?.projects ?? []) { p in + HStack { Text(p.name); Text("#\(p.id)").foregroundStyle(.secondary); Spacer(); Button(p.active ? "停用项目" : "恢复项目") { confirm("确认\(p.active ? "停用" : "恢复")项目 \(p.name)?") { try await model.toggleProject(p) } } } + } + } + Divider() + Text("管理员授权").font(.headline) + HStack { + Picker("授权用户", selection: $selectedCandidate) { + Text("请选择已登录微信用户").tag("") + ForEach(model.candidates) { user in + Text("\(user.name) · 账户 \(user.id)").tag(user.id) + } + } + .frame(maxWidth: 360) + Button { + let userID = selectedCandidate + let selectedRole = role + let scope = selectedRole == "leader" ? model.projectID : selectedGroups.sorted().first ?? 0 + confirm("直接授权给账户 \(userID)?") { + if selectedRole == "group" { + for groupID in selectedGroups.sorted() { + try await model.grant(userID: userID, role: selectedRole, scope: groupID) + } + selectedGroups = [] + } else { + try await model.grant(userID: userID, role: selectedRole, scope: scope) + } + selectedCandidate = "" + } + } label: { Label("直接授权", systemImage: "person.badge.key") } + .disabled(selectedCandidate.isEmpty || + (role == "leader" ? model.projectID == 0 : selectedGroups.isEmpty)) + } + Text("也可以生成二维码,让用户扫码确认授权。").font(.caption).foregroundStyle(.secondary) + Picker("角色", selection: $role) { + if model.access?.platform == true { Text("项目负责人").tag("leader") } + Text("组管理员").tag("group") + }.pickerStyle(.segmented).frame(maxWidth: 400) + if role == "leader" { + Button { + let projectID = model.projectID + confirm("生成当前项目的负责人授权码?首位扫码并确认的账户将获得权限,有效期 5 分钟。") { + try await model.createGrantCode(role: "leader", scopes: [projectID]) + } + } label: { Label("生成负责人授权码", systemImage: "qrcode") } + .disabled(model.access?.platform != true || !(model.access?.projects.contains(where: { $0.id == model.projectID && $0.active }) ?? false)) + } else { + ForEach(model.availableGroups.filter { group in + group.memberStatus != "disabled" && + (model.access?.platform == true || model.access?.projects.contains(where: { $0.id == group.projectId && $0.canLead }) == true) + }) { g in + Toggle(g.label, isOn: Binding(get: { selectedGroups.contains(g.id) }, set: { if $0 { selectedGroups.insert(g.id) } else { selectedGroups.remove(g.id) } })) + } + Button { + let scopes = selectedGroups.sorted() + confirm("生成 \(scopes.count) 个组的管理员授权码?首位扫码并确认的账户将获得权限,有效期 5 分钟。") { + try await model.createGrantCode(role: "group", scopes: scopes) + selectedGroups = [] + } + } label: { Label("生成组管理员授权码", systemImage: "qrcode") } + .disabled(selectedGroups.isEmpty || selectedGroups.count > 50) + } + if let ticket = model.grantTicket { grantCode(ticket) } + Divider() + ForEach(model.grants) { g in + HStack { + Text("账户 \(g.userId)"); Text(g.role == "leader" ? "项目负责人" : g.role == "group" ? "组管理员" : "平台管理员") + Text("范围 #\(g.scopeId)").foregroundStyle(.secondary); Spacer() + if g.role != "platform" { + Button("撤销授权") { confirm("撤销账户 \(g.userId) 的这项授权?") { try await model.grant(userID: g.userId, role: g.role, scope: g.scopeId, revoke: true) } } + } + } + } + }.padding(20) + } + } + private var audit: some View { + Table(model.audit) { + TableColumn("时间", value: \.date) + TableColumn("账户", value: \.actorId) + TableColumn("操作", value: \.action) + TableColumn("资源", value: \.resourceId) + TableColumn("详情", value: \.detail) + }.padding(16) + } + private var policies: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("报销类型管理").font(.title3.bold()) + Spacer() + Text("配置不同类型所需材料和提交提示").foregroundStyle(.secondary) + } + if model.access?.platform != true { + Label("仅平台管理员可以修改报销类型配置,当前为只读。", systemImage: "lock").foregroundStyle(.secondary) + } + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("报销类型规则", systemImage: "list.bullet.rectangle") + .font(.headline) + Spacer() + Picker("选择报销类型", selection: $selectedPolicyType) { + if model.policies.isEmpty { + Text("暂无报销类型").tag("") + } else { + ForEach(model.policies) { policy in + Text(policy.active ? policy.type : "\(policy.type)(已停用)") + .tag(policy.type) + } + } + } + .frame(width: 280) + } + Text("选择不同报销类型后,下方说明、提示和材料要求会同步切换。") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + if let policyIndex = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) { + let policy = $model.policies[policyIndex] + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(policy.wrappedValue.type).font(.headline) + Spacer() + Toggle("启用", isOn: policy.active).disabled(model.access?.platform != true) + } + TextField("报销内容说明", text: policy.description, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(2...4).disabled(model.access?.platform != true) + TextField("提交提示", text: policy.tips, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(2...4).disabled(model.access?.platform != true) + Text("上传材料规则").font(.caption).foregroundStyle(.secondary) + ForEach(model.uploadKinds.filter(\.active)) { kind in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(kind.name) + Text(kind.hint).font(.caption).foregroundStyle(.secondary) + } + Spacer() + materialModeButtons(policy: policy, kind: kind) + if policy.wrappedValue.materials.contains(where: { $0.kind == kind.kind }) { + Button { + editingPolicyMaterialKind = kind.kind + newPolicyMaterialName = kind.name + newPolicyMaterialHint = kind.hint + newPolicyMaterialAllowsImage = kind.allowImage + newPolicyMaterialAllowsPDF = kind.allowPDF + } label: { Image(systemName: "pencil") } + .buttonStyle(.borderless) + .help("编辑当前类型材料") + Button { + removeMaterial(policy: policy, kind: kind.kind) + if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() } + } label: { Image(systemName: "trash") } + .buttonStyle(.borderless) + .tint(.red) + .help("从当前报销类型移除") + } + } + } + Divider() + Text(editingPolicyMaterialKind.isEmpty ? "新增本类型材料" : "编辑当前类型材料") + .font(.subheadline.weight(.semibold)) + HStack { + TextField("材料名称,如租赁合同", text: $newPolicyMaterialName) + TextField("材料提示", text: $newPolicyMaterialHint) + } + HStack { + Toggle("允许图片", isOn: $newPolicyMaterialAllowsImage) + Toggle("允许 PDF", isOn: $newPolicyMaterialAllowsPDF) + Spacer() + if !editingPolicyMaterialKind.isEmpty { + Button("取消编辑") { resetPolicyMaterialEditor() } + } + Button { + let name = newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines) + let hint = newPolicyMaterialHint.trimmingCharacters(in: .whitespacesAndNewlines) + let kind = editingPolicyMaterialKind.isEmpty + ? "custom-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased())" + : editingPolicyMaterialKind + let nextKind = AdminUploadKind(kind: kind, name: name, hint: hint, + allowImage: newPolicyMaterialAllowsImage, allowPDF: newPolicyMaterialAllowsPDF, + active: true, sortOrder: (model.uploadKinds.map(\.sortOrder).max() ?? 0) + 10) + var nextPolicy = policy.wrappedValue + if let index = nextPolicy.materials.firstIndex(where: { $0.kind == kind }) { + let current = nextPolicy.materials[index] + nextPolicy.materials[index] = AdminExpenseMaterial(kind: kind, name: name, hint: hint, + required: current.required, allowImage: nextKind.allowImage, allowPDF: nextKind.allowPDF, + active: true, sortOrder: current.sortOrder) + } else { + nextPolicy.materials.append(AdminExpenseMaterial(kind: kind, name: name, hint: hint, + required: false, allowImage: nextKind.allowImage, allowPDF: nextKind.allowPDF, + active: true, sortOrder: nextKind.sortOrder)) + } + nextPolicy.requiredKinds = nextPolicy.materials.filter(\.required).map(\.kind) + run { + try await model.updateUploadKind(nextKind) + try await model.updatePolicy(nextPolicy) + resetPolicyMaterialEditor() + } + } label: { + Label(editingPolicyMaterialKind.isEmpty ? "添加到当前类型" : "更新当前类型材料", + systemImage: editingPolicyMaterialKind.isEmpty ? "plus" : "checkmark") + } + .buttonStyle(.bordered) + .disabled(newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || + (!newPolicyMaterialAllowsImage && !newPolicyMaterialAllowsPDF)) + } + .disabled(model.access?.platform != true) + HStack { + Spacer() + if model.access?.platform == true { + Button { let value = policy.wrappedValue; run { try await model.updatePolicy(value) } } label: { + Label("保存规则", systemImage: "checkmark") + }.buttonStyle(.borderedProminent) + } + } + } + .padding(14) + .background(.quaternary.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + ContentUnavailableView("暂无可配置的报销类型", systemImage: "list.bullet.rectangle") + .frame(maxWidth: .infinity, minHeight: 180) + } + }.padding(20) + } + } + private func materialFormats(_ kind: AdminUploadKind) -> String { + [kind.allowImage ? "图片" : nil, kind.allowPDF ? "PDF" : nil].compactMap { $0 }.joined(separator: " + ") + } + private func resetMaterialEditor() { + editingMaterialKind = "" + materialKind = "" + materialName = "" + materialHint = "" + materialAllowsImage = true + materialAllowsPDF = false + materialActive = true + } + private func materialModeButtons(policy: Binding, kind: AdminUploadKind) -> some View { + let current = policy.wrappedValue.materials.first(where: { $0.kind == kind.kind }) + let mode = current == nil ? "none" : (current?.required == true ? "required" : "optional") + return HStack(spacing: 0) { + materialModeButton("不使用", mode: "none", selected: mode == "none") { + setMaterialMode(policy: policy, kind: kind, mode: "none") + } + materialModeButton("选填", mode: "optional", selected: mode == "optional") { + setMaterialMode(policy: policy, kind: kind, mode: "optional") + } + materialModeButton("必填", mode: "required", selected: mode == "required") { + setMaterialMode(policy: policy, kind: kind, mode: "required") + } + } + .frame(width: 250, height: 28) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(.separator)) + } + private func materialModeButton(_ title: String, mode: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(selected ? Color.accentColor : Color.clear) + .foregroundStyle(selected ? .white : .primary) + } + .buttonStyle(.plain) + .contentShape(Rectangle()) + .help(mode == "none" ? "当前类型不需要此材料" : mode == "optional" ? "当前类型可以不上传此材料" : "当前类型必须上传此材料") + } + private func setMaterialMode(policy: Binding, kind: AdminUploadKind, mode: String) { + policy.wrappedValue.materials.removeAll { $0.kind == kind.kind } + if mode != "none" { + policy.wrappedValue.materials.append(AdminExpenseMaterial( + kind: kind.kind, name: kind.name, hint: kind.hint, required: mode == "required", + allowImage: kind.allowImage, allowPDF: kind.allowPDF, active: kind.active, sortOrder: kind.sortOrder + )) + } + policy.wrappedValue.requiredKinds = policy.wrappedValue.materials.filter(\.required).map(\.kind) + } + private func removeMaterial(policy: Binding, kind: String) { + policy.wrappedValue.materials.removeAll { $0.kind == kind } + policy.wrappedValue.requiredKinds = policy.wrappedValue.materials.filter(\.required).map(\.kind) + } + private func resetPolicyMaterialEditor() { + editingPolicyMaterialKind = "" + newPolicyMaterialName = "" + newPolicyMaterialHint = "" + newPolicyMaterialAllowsImage = true + newPolicyMaterialAllowsPDF = true + } + private func grantCode(_ ticket: AdminGrantTicket) -> some View { + HStack(alignment: .top, spacing: 24) { + Group { + if let image = model.grantImage, model.grantState == "pending" { + Image(nsImage: image).resizable().interpolation(.none).scaledToFit().background(.white) + } else { + Image(systemName: model.grantState == "confirmed" ? "checkmark.shield" : "qrcode") + .font(.system(size: 48)).foregroundStyle(.secondary) + } + }.frame(width: 210, height: 210) + VStack(alignment: .leading, spacing: 12) { + Text(ticket.role == "leader" ? "项目负责人授权" : "组管理员授权").font(.headline) + ForEach(ticket.scopes) { scope in + Text(ticket.role == "leader" ? scope.projectName : "\(scope.projectName) / \(scope.name)") + .fixedSize(horizontal: false, vertical: true) + } + Text(["pending": "等待微信扫码确认", "confirmed": "授权成功 · 账户 \(model.grantRecipient)", "expired": "授权码已过期", "revoked": "授权码已撤销"][model.grantState] ?? "") + if let expiry = ticket.expiryDate { + Text("到期:\(expiry.formatted(date: .omitted, time: .standard))").font(.caption).foregroundStyle(.secondary) + } + HStack { + if model.grantState == "pending" { + Button { run { try await model.retryGrantImage() } } label: { Image(systemName: "qrcode") }.help("重新加载授权码") + if let image = model.grantImage { + Button { saveQR(image, filename: "管理员授权码.png") } label: { Image(systemName: "square.and.arrow.down") }.help("保存授权二维码") + } + Button { + confirm("撤销此授权码?已保存的二维码也将失效。") { try await model.revokeGrantCode() } + } label: { Image(systemName: "xmark.circle") }.help("撤销授权码") + } + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.padding(.vertical, 12) + } + private func run(_ work: @escaping () async throws -> Void) { Task { await model.perform(work) } } + private func confirm(_ title: String, _ work: @escaping () async throws -> Void) { confirmation = AdminAction(title: title) { run(work) } } + private func memberState(_ s: String) -> String { ["pending": "待审核", "active": "已加入", "rejected": "已拒绝", "disabled": "已停用"][s] ?? s } + private func groupOption(_ group: AdminGroup) -> String { + group.canManage ? group.label : "\(group.label)(未授权)" + } + private func saveQR(_ image: NSImage, filename: String = "入组二维码.png") { + let panel = NSSavePanel(); panel.nameFieldStringValue = filename + if panel.runModal() == .OK, let url = panel.url, let data = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: data), let png = bitmap.representation(using: .png, properties: [:]) { + do { try png.write(to: url, options: .atomic) } catch { model.error = error.localizedDescription } + } + } +} + +private struct AdminAttachmentRow: View { + @ObservedObject var model: AdminStore + let file: AdminFile + @State private var image: NSImage? + @State private var loading = false + @State private var showPreview = false + + private var isImage: Bool { file.mime.hasPrefix("image/") } + + var body: some View { + HStack(spacing: 12) { + if isImage { + Button { + if image != nil { showPreview = true } + else { Task { await loadImage(openWhenReady: true) } } + } label: { + ZStack { + RoundedRectangle(cornerRadius: 6).fill(.quaternary.opacity(0.35)) + if let image { + Image(nsImage: image) + .resizable() + .scaledToFill() + } else if loading { + ProgressView().controlSize(.small) + } else { + Image(systemName: "photo").foregroundStyle(.secondary) + } + } + .frame(width: 112, height: 76) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + .help("点击预览大图") + } else { + Image(systemName: file.mime == "application/pdf" ? "doc.richtext" : "doc") + .font(.title2) + .foregroundStyle(.teal) + .frame(width: 44) + } + VStack(alignment: .leading, spacing: 4) { + Text(file.name).lineLimit(2) + Text(isImage ? "点击图片直接预览" : "文件附件") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if isImage { + Button { + if image != nil { showPreview = true } + else { Task { await loadImage(openWhenReady: true) } } + } label: { Image(systemName: "eye") } + .help("预览图片") + } + Button { Task { await model.perform { try await model.download(file) } } } label: { + Image(systemName: "arrow.down.to.line") + }.help("下载凭证") + } + .padding(.vertical, 4) + .task(id: file.id) { await loadImage(openWhenReady: false) } + .sheet(isPresented: $showPreview) { + VStack(spacing: 0) { + HStack { + Text(file.name).font(.headline).lineLimit(1) + Spacer() + Button { showPreview = false } label: { Image(systemName: "xmark") }.help("关闭预览") + } + .padding(16) + Divider() + if let image { + Image(nsImage: image) + .resizable() + .scaledToFit() + .padding(20) + .frame(minWidth: 720, minHeight: 520) + } + } + } + } + + @MainActor + private func loadImage(openWhenReady: Bool) async { + guard isImage, image == nil, !loading else { + if openWhenReady && image != nil { showPreview = true } + return + } + loading = true + defer { loading = false } + do { + let data = try await model.fileData(file) + guard let loaded = NSImage(data: data) else { + throw AdminFailure(message: "图片格式无法预览,请下载后查看") + } + image = loaded + if openWhenReady { showPreview = true } + } catch is CancellationError { + } catch { + model.error = error.localizedDescription + } + } +} + +private struct AdminAction: Identifiable { + let id = UUID() + let title: String + let perform: () -> Void +} diff --git a/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json b/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json index e6368b5..6b098ee 100644 --- a/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/reimburse/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images": [ + "images" : [ { - "idiom": "mac", - "size": "16x16", - "scale": "1x", - "filename": "icon-16@1x.png" + "filename" : "icon-16@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" }, { - "idiom": "mac", - "size": "16x16", - "scale": "2x", - "filename": "icon-16@2x.png" + "filename" : "icon-16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" }, { - "idiom": "mac", - "size": "32x32", - "scale": "1x", - "filename": "icon-32@1x.png" + "filename" : "icon-32@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" }, { - "idiom": "mac", - "size": "32x32", - "scale": "2x", - "filename": "icon-32@2x.png" + "filename" : "icon-32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" }, { - "idiom": "mac", - "size": "128x128", - "scale": "1x", - "filename": "icon-128@1x.png" + "filename" : "icon-128@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" }, { - "idiom": "mac", - "size": "128x128", - "scale": "2x", - "filename": "icon-128@2x.png" + "filename" : "icon-128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" }, { - "idiom": "mac", - "size": "256x256", - "scale": "1x", - "filename": "icon-256@1x.png" + "filename" : "icon-256@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" }, { - "idiom": "mac", - "size": "256x256", - "scale": "2x", - "filename": "icon-256@2x.png" + "filename" : "icon-256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" }, { - "idiom": "mac", - "size": "512x512", - "scale": "1x", - "filename": "icon-512@1x.png" + "filename" : "icon-512@1x.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" }, { - "idiom": "mac", - "size": "512x512", - "scale": "2x", - "filename": "icon-512@2x.png" + "filename" : "icon-512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" } ], - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/reimburse/reimburseApp.swift b/reimburse/reimburseApp.swift index 23399b0..36ebb26 100644 --- a/reimburse/reimburseApp.swift +++ b/reimburse/reimburseApp.swift @@ -13,7 +13,10 @@ struct reimburseApp: App { var body: some Scene { Window("贴票台", id: "workspace") { - ContentView() + TabView { + AdminView().tabItem { Label("项目管理", systemImage: "person.3") } + ContentView().tabItem { Label("本地贴票", systemImage: "doc.text.viewfinder") } + } .environmentObject(store) .task { #if DEBUG