Add native admin workspace
This commit is contained in:
@@ -7,4 +7,5 @@ native-engine/*.spec
|
||||
native-engine/__pycache__/
|
||||
native-engine/tests/__pycache__/
|
||||
*.xcuserstate
|
||||
xcuserdata/
|
||||
.DS_Store
|
||||
|
||||
@@ -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`。
|
||||
@@ -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;
|
||||
|
||||
@@ -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<T: Decodable>: 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 }
|
||||
}
|
||||
@@ -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<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()
|
||||
} 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<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)
|
||||
}
|
||||
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: "无法更新钥匙串") }
|
||||
}
|
||||
}
|
||||
@@ -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<Int64> = []
|
||||
@State private var selectedExpenses: Set<String> = []
|
||||
@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<AdminExpensePolicy>, 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<AdminExpensePolicy>, 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<AdminExpensePolicy>, 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
|
||||
}
|
||||
@@ -1,64 +1,64 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-16@1x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "16x16",
|
||||
"scale" : "1x",
|
||||
"filename": "icon-16@1x.png"
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "16x16",
|
||||
"scale" : "2x",
|
||||
"filename": "icon-16@2x.png"
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32@1x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "32x32",
|
||||
"scale" : "1x",
|
||||
"filename": "icon-32@1x.png"
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "32x32",
|
||||
"scale" : "2x",
|
||||
"filename": "icon-32@2x.png"
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-128@1x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "128x128",
|
||||
"scale" : "1x",
|
||||
"filename": "icon-128@1x.png"
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "128x128",
|
||||
"scale" : "2x",
|
||||
"filename": "icon-128@2x.png"
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256@1x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "256x256",
|
||||
"scale" : "1x",
|
||||
"filename": "icon-256@1x.png"
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "256x256",
|
||||
"scale" : "2x",
|
||||
"filename": "icon-256@2x.png"
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "512x512",
|
||||
"scale" : "1x",
|
||||
"filename": "icon-512@1x.png"
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"size": "512x512",
|
||||
"scale" : "2x",
|
||||
"filename": "icon-512@2x.png"
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user