From 11524e58c35a6ae7150feba18201aad8f34f76f5 Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Sun, 13 Sep 2026 11:18:31 +0800 Subject: [PATCH] 1 --- reimburse/AdminModels.swift | 12 + reimburse/AdminView.swift | 610 +++++++++++++++++++++++++----------- 2 files changed, 433 insertions(+), 189 deletions(-) diff --git a/reimburse/AdminModels.swift b/reimburse/AdminModels.swift index 01a45fc..adb8043 100644 --- a/reimburse/AdminModels.swift +++ b/reimburse/AdminModels.swift @@ -1,5 +1,17 @@ import Foundation +enum AdminDisplayFilter { + static func matches(_ query: String, fields: [String]) -> Bool { + let words = query.split(whereSeparator: { $0.isWhitespace }) + return words.allSatisfy { word in fields.contains { $0.localizedCaseInsensitiveContains(String(word)) } } + } + static func expenses(_ records: [AdminExpense], query: String, state: String) -> [AdminExpense] { + records.filter { + (state == "all" || $0.state == state) && matches(query, fields: [$0.id, $0.name, $0.projectName, $0.team, $0.type, $0.note]) + } + } +} + struct AdminUser: Codable { let id: String; let name: String } struct AdminCandidate: Codable, Identifiable { let id: String diff --git a/reimburse/AdminView.swift b/reimburse/AdminView.swift index 8db76d5..41996fe 100644 --- a/reimburse/AdminView.swift +++ b/reimburse/AdminView.swift @@ -3,6 +3,13 @@ import SwiftUI struct AdminView: View { @StateObject private var model = AdminStore() @State private var tab = "报销审批" + @State private var expenseSearch = "" + @State private var expenseState = "all" + @State private var memberSearch = "" + @State private var groupSearch = "" + @State private var memberSection = "成员列表" + @State private var auditSearch = "" + @State private var permissionSection = "授权管理" @State private var name = "" @State private var groupName = "" @State private var renameTarget: AdminManagedGroup? @@ -44,44 +51,19 @@ struct AdminView: View { 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) } + adminHeader + if model.access != nil { adminNavigation } if let error = model.error { - HStack { - Label(error, systemImage: "exclamationmark.triangle").foregroundStyle(.red) + HStack(spacing: 10) { + Label(error, systemImage: "exclamationmark.triangle.fill").font(.callout).textSelection(.enabled) Spacer() - Button { model.error = nil } label: { Image(systemName: "xmark") }.help("关闭提示") - }.padding(12) + Button { model.error = nil } label: { Image(systemName: "xmark") } + .buttonStyle(.plain).help("关闭提示") + }.foregroundStyle(.red).padding(12).background(Color.red.opacity(0.06)) } if model.access == nil { login } else { - if tab != "报销类型" { - 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).padding(.top, tab == "报销类型" ? 16 : 0) + if tab == "报销审批" || tab == "成员与邀请" || tab == "项目与授权" { scopeFilters } Group { switch tab { case "成员与邀请": members @@ -93,11 +75,15 @@ struct AdminView: View { } }.frame(maxWidth: .infinity, maxHeight: .infinity).disabled(model.busy) } - } + }.background(Color(nsColor: .windowBackgroundColor)) .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.access?.userId) { _, _ in + role = model.access?.platform == true ? "leader" : "group" + permissionSection = "授权管理" + selectedExpenses = [] + } .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() } } } @@ -105,6 +91,7 @@ struct AdminView: View { if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" } } .onChange(of: selectedPolicyType) { _, _ in resetPolicyMaterialEditor() } + .onChange(of: filteredExpenses.map(\.id)) { _, ids in selectedExpenses.formIntersection(Set(ids)) } .sheet(item: $model.selected) { detail($0) } .sheet(isPresented: $showTypeEditor) { expenseTypeEditor } .sheet(isPresented: $showMaterialEditor) { @@ -135,10 +122,104 @@ struct AdminView: View { Button("取消", role: .cancel) { confirmation = nil } } } + private var adminHeader: some View { + HStack(spacing: 12) { + Image(systemName: "building.2.crop.circle.fill").font(.system(size: 28)).foregroundStyle(.teal) + VStack(alignment: .leading, spacing: 2) { + Text("项目报销管理").font(.headline) + Text("申请 · 成员 · 项目协作").font(.caption).foregroundStyle(.secondary) + } + Spacer() + if model.busy { ProgressView().controlSize(.small); Text("正在处理…").font(.caption).foregroundStyle(.secondary) } + if let access = model.access { + Text(access.platform ? "平台管理员" : "项目 / 组管理").font(.caption).foregroundStyle(.secondary) + Button { run { try await model.reload() } } label: { Label("刷新", systemImage: "arrow.clockwise") } + .disabled(model.busy).help("重新加载数据;请先保存正在编辑的规则") + Menu { + Text("账户 \(access.userId)") + Divider() + Button("退出登录") { Task { await model.logout() } } + } label: { Label("账户 \(access.userId)", systemImage: "person.crop.circle") } + .fixedSize().disabled(model.busy) + } + }.padding(.horizontal, 20).padding(.vertical, 14) + } + private var adminNavigation: some View { + HStack(spacing: 6) { + navigationItem("报销审批", icon: "doc.text.magnifyingglass") + navigationItem("成员与邀请", icon: "person.2") + navigationItem("组别管理", icon: "rectangle.3.group") + navigationItem("项目与授权", icon: "person.badge.key") + navigationItem("报销类型", icon: "slider.horizontal.3") + navigationItem("操作记录", icon: "clock.arrow.circlepath") + }.padding(.horizontal, 16).padding(.bottom, 12).disabled(model.busy) + } + private func navigationItem(_ title: String, icon: String) -> some View { + Button { tab = title } label: { + Label(title, systemImage: icon).font(.system(size: 13, weight: tab == title ? .semibold : .regular)) + .frame(maxWidth: .infinity).padding(.vertical, 11) + .foregroundStyle(tab == title ? Color.accentColor : Color.secondary) + .background(tab == title ? Color.accentColor.opacity(0.1) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 8)).contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityAddTraits(tab == title ? [.isSelected] : []) + } + private var scopeFilters: some View { + HStack(spacing: 16) { + Picker("项目", selection: $model.projectID) { + Text("全部授权项目").tag(Int64(0)) + ForEach(model.access?.projects ?? []) { Text($0.name).tag($0.id) } + }.frame(maxWidth: 320) + if tab != "项目与授权" { + Picker("组别", selection: $model.groupID) { + Text(tab == "成员与邀请" ? "请选择管理组" : "全部可见组").tag(Int64(0)) + ForEach(model.availableGroups) { Text(groupOption($0)).tag($0.id) } + }.frame(maxWidth: 360) + } + Spacer() + if model.projectID != 0 || model.groupID != 0 { + Button("重置范围") { model.projectID = 0; model.groupID = 0 }.buttonStyle(.borderless) + } + }.font(.callout).padding(.horizontal, 20).padding(.vertical, 12) + .background(Color(nsColor: .controlBackgroundColor)).disabled(model.busy) + } + private func searchField(_ placeholder: String, text: Binding) -> some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField(placeholder, text: text).textFieldStyle(.plain) + if !text.wrappedValue.isEmpty { + Button { text.wrappedValue = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary).help("清空搜索") + } + }.padding(9).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 8)) + } + private func emptyState(_ title: String, icon: String, message: String) -> some View { + VStack(spacing: 10) { + Image(systemName: icon).font(.system(size: 30)).foregroundStyle(.secondary) + Text(title).font(.headline) + Text(message).font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) + }.padding(24).frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func statusBadge(_ text: String, positive: Bool = false, pending: Bool = false) -> some View { + Text(text).font(.caption.weight(.medium)).padding(.horizontal, 8).padding(.vertical, 4) + .foregroundStyle(positive ? Color.green : pending ? Color.orange : Color.secondary) + .background((positive ? Color.green : pending ? Color.orange : Color.secondary).opacity(0.09)) + .clipShape(Capsule()) + } + private func sectionCard(_ title: String, icon: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 16) { + Label(title, systemImage: icon).font(.headline) + content() + }.padding(20).frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 12)) + } private var login: some View { + ScrollView { VStack(spacing: 20) { Image(systemName: "lock.shield").font(.system(size: 40)).foregroundStyle(.teal) - Text("管理端登录").font(.title2.bold()) + Text("登录工作台").font(.title2.bold()) + Text("使用管理员账号或微信扫码,继续处理项目报销。") + .font(.callout).foregroundStyle(.secondary) + Text("服务地址").font(.caption).foregroundStyle(.secondary).frame(width: 400, alignment: .leading) TextField("HTTPS 服务地址", text: $model.server).textFieldStyle(.roundedBorder).frame(width: 400).disabled(model.busy) Picker("登录方式", selection: $loginMode) { Text("账号密码").tag("password") @@ -147,13 +228,15 @@ struct AdminView: View { Group { if loginMode == "password" { VStack(spacing: 16) { + Text("用户名").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) TextField("用户名", text: $loginUsername) .textContentType(.username).textFieldStyle(.roundedBorder) .accessibilityLabel("用户名") + Text("密码").font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading) SecureField("密码", text: $loginPassword) .textContentType(.password).textFieldStyle(.roundedBorder) .accessibilityLabel("密码").onSubmit { submitPassword() } - Button("登录管理端", action: submitPassword) + Button(action: submitPassword) { Text("登录管理端").frame(maxWidth: .infinity).padding(.vertical, 5) } .buttonStyle(.borderedProminent) .disabled(loginUsername.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || loginPassword.isEmpty) }.frame(width: 400).disabled(model.busy) @@ -172,6 +255,8 @@ struct AdminView: View { } } }.frame(height: 320) + }.padding(32).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.vertical, 28).frame(maxWidth: .infinity) }.frame(maxWidth: .infinity, maxHeight: .infinity) } private func submitPassword() { @@ -181,25 +266,46 @@ struct AdminView: View { 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() + private var filteredExpenses: [AdminExpense] { + AdminDisplayFilter.expenses(model.displayedExpenses, query: expenseSearch, state: expenseState) + } + private func metric(_ title: String, value: String, icon: String) -> some View { + HStack(spacing: 12) { + Image(systemName: icon).font(.title3).foregroundStyle(Color.accentColor) + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.caption).foregroundStyle(.secondary) + Text(value).font(.title3.weight(.semibold)).monospacedDigit() } - Table(model.displayedExpenses, selection: $selectedExpenses) { + Spacer() + }.padding(16).frame(maxWidth: .infinity).background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + private var expenses: some View { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + metric("已加载申请", value: "\(model.displayedExpenses.count) 笔", icon: "doc.on.doc") + metric("已加载待审核", value: "\(model.displayedExpenses.filter { $0.state == "pending" }.count) 笔", icon: "clock") + metric("已加载申请金额", value: "¥" + String(format: "%.2f", Double(model.displayedExpenses.reduce(Int64(0)) { $0 + $1.amountCents }) / 100), icon: "yensign.circle") + } + HStack(spacing: 12) { + searchField("搜索姓名、项目、类型或申请编号", text: $expenseSearch).frame(maxWidth: 340) + Picker("状态", selection: $expenseState) { + Text("全部状态").tag("all") + Text("待审核").tag("pending") + Text("已批准").tag("approved") + Text("已退回").tag("returned") + }.frame(width: 180) + Spacer() + Button { showDateExport = true } label: { Label("按日期导出", systemImage: "calendar.badge.arrow.down") } + } + ZStack { + Table(filteredExpenses, selection: $selectedExpenses) { TableColumn("申请人", value: \.name) TableColumn("项目", value: \.projectName) TableColumn("组别", value: \.team) TableColumn("类型", value: \.type) TableColumn("金额") { Text("¥\($0.amountText)").monospacedDigit() } - TableColumn("状态", value: \.status) + TableColumn("状态") { statusBadge($0.status, positive: $0.state == "approved", pending: $0.state == "pending") } TableColumn("提交日期", value: \.date) TableColumn("操作") { record in Button { reason = ""; run { try await model.detail(record) } } label: { @@ -209,8 +315,23 @@ struct AdminView: View { .help("查看申请、备注和凭证") }.width(min: 120, ideal: 130) } - if model.more { Button("加载更多") { run { try await model.refreshExpenses(loadMore: true) } } } - }.padding(16) + if filteredExpenses.isEmpty { + emptyState(model.displayedExpenses.isEmpty ? "暂无报销申请" : "没有匹配的申请", icon: "tray", message: "可调整范围、搜索或状态;搜索仅针对已加载记录。") + .allowsHitTesting(false) + } + }.clipShape(RoundedRectangle(cornerRadius: 10)) + HStack { + Text("显示 \(filteredExpenses.count) 笔 · 已选 \(selectedExpenses.count) 笔").foregroundStyle(.secondary) + if !selectedExpenses.isEmpty { Button("清空选择") { selectedExpenses = [] }.buttonStyle(.borderless) } + Spacer() + if model.more { Button("加载更多申请") { run { try await model.refreshExpenses(loadMore: true) } } } + Button { run { try await model.export(selectedExpenses) } } label: { Label("下载所选凭证", systemImage: "arrow.down.to.line") } + .buttonStyle(.borderedProminent).disabled(selectedExpenses.isEmpty || selectedExpenses.count > 20) + .help("每次最多选择 20 笔申请") + }.font(.callout) + Text("统计与搜索仅包含已加载数据,并非项目全量总账;凭证下载每次最多 20 笔。") + .font(.caption).foregroundStyle(.secondary) + }.padding(20) } private var dateExport: some View { VStack(alignment: .leading, spacing: 18) { @@ -263,10 +384,58 @@ struct AdminView: View { 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) } + VStack(spacing: 0) { + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 6) { + Text("\(record.name) · \(record.type)").font(.title3.weight(.semibold)) + Text("申请 #\(record.id) · \(record.date)").font(.caption).foregroundStyle(.secondary) + } + Spacer() + statusBadge(record.status, positive: record.state == "approved", pending: record.state == "pending") + Button { model.selected = nil } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary).help("关闭详情") + }.padding(20) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack { + VStack(alignment: .leading, spacing: 6) { + Text("所属项目 / 组别").font(.caption).foregroundStyle(.secondary) + Text("\(record.projectName) / \(record.team)").textSelection(.enabled) + } + Spacer() + Text("¥\(record.amountText)").font(.title.weight(.semibold)).monospacedDigit() + } + if !record.note.isEmpty { + sectionCard("申请备注", icon: "text.alignleft") { + Text(record.note).textSelection(.enabled).fixedSize(horizontal: false, vertical: true) + } + } + VStack(alignment: .leading, spacing: 12) { + Label("凭证附件(\(record.files.values.flatMap { $0.files }.count))", systemImage: "paperclip").font(.headline) + Text("图片可预览;PDF 等文件可下载后查看。").font(.caption).foregroundStyle(.secondary) + LazyVStack(spacing: 8) { + ForEach(record.files.values.flatMap { $0.files }.sorted(by: { ($0.kind, $0.id) < ($1.kind, $1.id) })) { file in + AdminAttachmentRow(model: model, file: file) + Divider() + } + } + if record.files.isEmpty { Text("此申请没有附件").foregroundStyle(.secondary) } + } + DisclosureGroup("处理记录(\(record.events.count))") { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(record.events.enumerated()), id: \.offset) { _, event in + VStack(alignment: .leading, spacing: 4) { + Text(event.detail.isEmpty ? event.action : event.detail).textSelection(.enabled) + Text("\(event.date) · 账户 \(event.actorId)").font(.caption).foregroundStyle(.secondary) + } + } + }.padding(.top, 12).frame(maxWidth: .infinity, alignment: .leading) + } + }.padding(20) + } + Divider() + VStack(alignment: .leading, spacing: 10) { if record.state == "pending", record.userId != model.access?.userId || model.access?.platform == true { VStack(alignment: .leading, spacing: 10) { Text("审批操作").font(.headline) @@ -302,35 +471,10 @@ struct AdminView: View { 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() - ScrollView { - VStack(spacing: 12) { - ForEach(record.files.values.flatMap { $0.files }.sorted(by: { ($0.kind, $0.id) < ($1.kind, $1.id) })) { file in - AdminAttachmentRow(model: model, file: file) - } - } - } - .frame(maxHeight: 300) - 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) + if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) } + }.padding(16).frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .windowBackgroundColor)) + }.frame(width: 740, height: 660).disabled(model.busy) .confirmationDialog(approvalConfirmation?.title ?? "", isPresented: Binding( get: { approvalConfirmation != nil }, set: { if !$0 { approvalConfirmation = nil } } @@ -343,21 +487,22 @@ struct AdminView: View { Button("取消", role: .cancel) { approvalConfirmation = nil } } } + private var filteredMembers: [AdminMember] { + model.members.filter { AdminDisplayFilter.matches(memberSearch, fields: [$0.name, $0.userId, memberState($0.status)]) } + } 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) + Picker("成员与邀请", selection: $memberSection) { + Text("成员列表").tag("成员列表") + Text("入组邀请").tag("入组邀请") + }.pickerStyle(.segmented).frame(width: 260) Spacer() + if let group = model.selectedGroup { Text(group.label).font(.callout).foregroundStyle(.secondary) } } if model.groupID == 0 { VStack(spacing: 12) { - ContentUnavailableView(model.availableGroups.isEmpty ? "当前项目暂无可见组" : "请选择一个管理组", systemImage: "person.3") + emptyState(model.availableGroups.isEmpty ? "当前项目暂无可见组" : "请选择一个管理组", icon: "person.3", message: "在上方选择组别后,即可审核成员或发送入组邀请。") if model.projectID != 0 { Button("查看全部项目的组") { model.projectID = 0 } } @@ -370,8 +515,7 @@ struct AdminView: View { }.frame(maxWidth: .infinity, maxHeight: .infinity) } else if let group = model.selectedGroup, !group.canManage { VStack(spacing: 12) { - ContentUnavailableView("\(group.label) · 未授权", systemImage: "lock", - description: Text("当前账户没有本组成员管理权限")) + emptyState("\(group.label) · 未授权", icon: "lock", message: "当前账户没有本组成员管理权限") if model.access?.platform == true { Button("项目负责人授权") { model.projectID = group.projectId @@ -381,28 +525,24 @@ struct AdminView: View { } }.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) { + if memberSection == "成员列表" { memberList } else { invitationPanel } + } + }.padding(20) + } + private var memberList: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + searchField("搜索姓名、账户或状态", text: $memberSearch).frame(maxWidth: 340) + Spacer() + Text("已加载 \(model.members.count) 人 · 待审核 \(model.members.filter { $0.status == "pending" }.count) 人") + .font(.callout).foregroundStyle(.secondary) + Button { memberSection = "入组邀请" } label: { Label("邀请成员", systemImage: "person.badge.plus") }.buttonStyle(.borderedProminent) + } + ZStack { + Table(filteredMembers) { TableColumn("姓名", value: \.name) TableColumn("账户 ID", value: \.userId) - TableColumn("状态") { Text(memberState($0.status)) } + TableColumn("状态") { statusBadge(memberState($0.status), positive: $0.status == "active", pending: $0.status == "pending") } TableColumn("操作") { m in HStack { if m.status == "pending" { @@ -415,25 +555,69 @@ struct AdminView: View { } } }.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) + }.frame(minHeight: 180).clipShape(RoundedRectangle(cornerRadius: 10)) + if filteredMembers.isEmpty { + emptyState("暂无匹配成员", icon: "person.2", message: "可以清空搜索,或通过邀请二维码让成员申请入组。") + .allowsHitTesting(false) + } } - }.padding(16) + Text("成员资格与管理员权限独立;停用、恢复等操作仅作用于当前组。") + .font(.caption).foregroundStyle(.secondary) + } + } + private var invitationPanel: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + sectionCard("创建入组邀请", icon: "qrcode") { + Text("设置有效期和人数上限,生成海报后发给成员。扫码申请仍需管理员审核。") + .font(.callout).foregroundStyle(.secondary) + HStack(spacing: 24) { + Stepper("有效期 \(hours) 小时", value: $hours, in: 1...720).frame(width: 240) + Stepper("最多 \(uses) 人", value: $uses, in: 1...1000).frame(width: 200) + Spacer() + } + HStack { + Button { run { try await model.createInvite(hours: hours, uses: uses) } } label: { Label("生成邀请海报", systemImage: "qrcode") }.buttonStyle(.borderedProminent) + Button { run { try await model.retryInviteQR() } } label: { Label("重试加载二维码", systemImage: "arrow.clockwise") } + } + if let poster = model.invitationPoster { + HStack { + Image(nsImage: poster).resizable().interpolation(.high).scaledToFit().frame(width: 210, height: 280) + 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) + } + } + } + sectionCard("邀请记录", icon: "clock") { + if model.invites.isEmpty { Text("还没有邀请记录").foregroundStyle(.secondary) } + ForEach(model.invites) { invite in + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text("邀请 #\(invite.id) · 已使用 \(invite.uses) / \(invite.maxUses) 人").font(.callout.weight(.medium)) + Text("有效期至 \(invite.expiresAt)").font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + Spacer() + if invite.active { + Button("撤销邀请", role: .destructive) { confirm("撤销邀请 #\(invite.id)?") { try await model.revoke(invite) } } + } else { statusBadge("已撤销") } + }.padding(.vertical, 6) + Divider() + } + } + }.frame(maxWidth: 1060).frame(maxWidth: .infinity) + } + } + private var filteredGroups: [AdminManagedGroup] { + model.visibleManagedGroups.filter { AdminDisplayFilter.matches(groupSearch, fields: [$0.name, $0.projectName, String($0.id)]) } } private var groupManagement: some View { VStack(alignment: .leading, spacing: 16) { HStack { - Text("组别管理").font(.headline) + searchField("搜索组别、项目或组 ID", text: $groupSearch).frame(maxWidth: 340) Spacer() Picker("所属项目", selection: $model.projectID) { Text("全部项目").tag(Int64(0)) @@ -444,12 +628,15 @@ struct AdminView: View { } if model.editableProjects.contains(where: { $0.id == model.projectID }) { HStack { - TextField("新组别名称", text: $groupName).textFieldStyle(.roundedBorder) + TextField("新组别名称", text: $groupName).textFieldStyle(.roundedBorder).frame(maxWidth: 360) Button { let value = groupName run { try await model.createGroup(value); groupName = "" } } label: { Label("创建组别", systemImage: "plus") } + .buttonStyle(.borderedProminent) .disabled(groupName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || groupName.count > 80) + Spacer() + Text("在当前项目下新增组别").font(.caption).foregroundStyle(.secondary) } } else if model.projectID == 0, !model.editableProjects.isEmpty { Menu { @@ -458,46 +645,57 @@ struct AdminView: View { } } label: { Label("选择项目并新建组", systemImage: "plus") } } - if model.visibleManagedGroups.isEmpty { - ContentUnavailableView("暂无组别", systemImage: "rectangle.3.group") + if filteredGroups.isEmpty { + emptyState("暂无匹配组别", icon: "rectangle.3.group", message: "选择项目后可创建组别,或清空搜索查看已有组别。") if model.access?.projects.isEmpty == true, model.access?.platform == true { - Button("创建项目") { tab = "项目与授权" } + Button("创建项目") { tab = "项目与授权"; permissionSection = "项目管理" } } } else { - Table(model.visibleManagedGroups) { + Table(filteredGroups) { 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("状态") { statusBadge($0.active ? "已启用" : "已停用", positive: $0.active) }.width(80) TableColumn("操作") { group in HStack { if group.canEdit { Button { renameValue = group.name renameTarget = group - } label: { Image(systemName: "pencil") }.help("重命名组别") + } label: { Label("重命名", systemImage: "pencil") }.help("重命名组别") Button { confirm("\(group.active ? "停用" : "恢复") \(group.name)?") { try await model.toggleGroup(group) } - } label: { Image(systemName: group.active ? "pause.circle" : "play.circle") } + } label: { Text(group.active ? "停用" : "恢复") } .help(group.active ? "停用组别" : "恢复组别") } if model.availableGroups.contains(where: { $0.id == group.id }) { Button { model.groupID = group.id + memberSection = "成员列表" tab = "成员与邀请" - } label: { Image(systemName: "person.3") }.help("管理本组成员") + } label: { Label("成员", systemImage: "person.3") }.help("管理本组成员") } } - }.width(130) + }.width(240) } } - }.padding(16) + Text("显示 \(filteredGroups.count) 个组别 · 修改和成员管理操作仅在已有权限范围内提供。") + .font(.caption).foregroundStyle(.secondary) + }.padding(20) } private var permissions: some View { - ScrollView { - VStack(alignment: .leading, spacing: 18) { - if model.access?.platform == true { - Text("项目管理").font(.headline) + VStack(alignment: .leading, spacing: 16) { + Picker("项目与授权", selection: $permissionSection) { + Text("授权管理").tag("授权管理") + Text("现有授权").tag("现有授权") + if model.access?.platform == true { Text("项目管理").tag("项目管理") } + }.pickerStyle(.segmented).frame(maxWidth: 420) + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if permissionSection == "项目管理", model.access?.platform == true { + sectionCard("项目管理", icon: "building.2") { + Text("创建项目,或调整项目启用状态。停用前请确认对成员业务的影响。") + .font(.callout).foregroundStyle(.secondary) HStack { TextField("新项目名称", text: $name).textFieldStyle(.roundedBorder) Button { run { try await model.createProject(name); name = "" } } label: { Label("创建项目", systemImage: "plus") }.disabled(name.isEmpty) @@ -505,37 +703,26 @@ struct AdminView: View { 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) + } + } else if permissionSection == "现有授权" { + sectionCard("现有授权", icon: "person.crop.rectangle.badge.checkmark") { + Text("显示当前账号可查看的授权;撤销仅影响所选角色及范围。") + .font(.callout).foregroundStyle(.secondary) + if model.grants.isEmpty { Text("暂无可显示的授权记录").foregroundStyle(.secondary) } + 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) } } } } - .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) + } + } else { + sectionCard("1 · 选择角色和范围", icon: "person.badge.key") { + Text(model.projectID == 0 ? "请先在上方选择项目;组管理员可勾选多个授权组。" : "当前项目:" + (model.access?.projects.first { $0.id == model.projectID }?.name ?? "")) + .font(.callout).foregroundStyle(.secondary) Picker("角色", selection: $role) { if model.access?.platform == true { Text("项目负责人").tag("leader") } Text("组管理员").tag("group") @@ -564,28 +751,73 @@ struct AdminView: View { } 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) } } + } + sectionCard("2 · 或直接授权给已有账户", icon: "person.crop.circle.badge.checkmark") { + Text("使用上面选择的角色和范围;也可使用上面的二维码,由接收者扫码确认。") + .font(.callout).foregroundStyle(.secondary) + 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)) } - }.padding(20) - } + } + if let ticket = model.grantTicket { + sectionCard("二维码授权进度", icon: "qrcode") { grantCode(ticket) } + } + } + }.frame(maxWidth: 1060).frame(maxWidth: .infinity) + } + }.padding(20) + } + private var filteredAudit: [AdminAudit] { + model.audit.filter { AdminDisplayFilter.matches(auditSearch, fields: [$0.actorId, $0.action, $0.resourceId, $0.detail, $0.date]) } } 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) + VStack(alignment: .leading, spacing: 14) { + HStack { + searchField("搜索账户、操作、资源或详情", text: $auditSearch).frame(maxWidth: 400) + Spacer() + Text("已加载 \(model.audit.count) 条 · 匹配 \(filteredAudit.count) 条").font(.callout).foregroundStyle(.secondary) + Button { run { try await model.loadAudit() } } label: { Label("刷新记录", systemImage: "arrow.clockwise") } + } + ZStack { + Table(filteredAudit) { + TableColumn("时间", value: \.date) + TableColumn("账户", value: \.actorId) + TableColumn("操作", value: \.action) + TableColumn("资源", value: \.resourceId) + TableColumn("详情") { Text($0.detail).lineLimit(2).help($0.detail).textSelection(.enabled) } + } + if filteredAudit.isEmpty { + emptyState("暂无匹配的操作记录", icon: "clock.arrow.circlepath", message: "可清空搜索或刷新记录;搜索仅针对已加载的数据。") + .allowsHitTesting(false) + } + }.clipShape(RoundedRectangle(cornerRadius: 10)) + Text("操作记录用于追溯管理行为;此页面不改变任何权限或业务数据。") + .font(.caption).foregroundStyle(.secondary) + }.padding(20) } private var expenseTypeEditor: some View { VStack(alignment: .leading, spacing: 16) {