1
This commit is contained in:
@@ -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
|
||||
|
||||
+421
-189
@@ -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<String>) -> 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<Content: View>(_ 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) {
|
||||
|
||||
Reference in New Issue
Block a user