This commit is contained in:
csj
2026-09-13 11:18:31 +08:00
parent b17e3246f0
commit 11524e58c3
2 changed files with 433 additions and 189 deletions
+12
View File
@@ -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
+403 -171
View File
@@ -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")
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()
}
.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()
}.padding(16).frame(maxWidth: .infinity).background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
Table(model.displayedExpenses, selection: $selectedExpenses) {
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 {
if memberSection == "成员列表" { memberList } else { invitationPanel }
}
}.padding(20)
}
private var memberList: some View {
VStack(alignment: .leading, spacing: 14) {
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("重新加载刚生成的二维码")
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)
}
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) {
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
}.frame(minHeight: 180).clipShape(RoundedRectangle(cornerRadius: 10))
if filteredMembers.isEmpty {
emptyState("暂无匹配成员", icon: "person.2", message: "可以清空搜索,或通过邀请二维码让成员申请入组。")
.allowsHitTesting(false)
}
}
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 {
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)
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)
}
}
}.frame(minHeight: 120)
}
}.padding(16)
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 {
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: 18) {
if model.access?.platform == true {
Text("项目管理").font(.headline)
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)
@@ -506,36 +704,25 @@ struct AdminView: View {
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)
} 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 {
Picker("授权用户", selection: $selectedCandidate) {
Text("请选择已登录微信用户").tag("")
ForEach(model.candidates) { user in
Text("\(user.name) · 账户 \(user.id)").tag(user.id)
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)
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
}
sectionCard("2 · 或直接授权给已有账户", icon: "person.crop.circle.badge.checkmark") {
Text("使用上面选择的角色和范围;也可使用上面的二维码,由接收者扫码确认。")
.font(.callout).foregroundStyle(.secondary)
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) } }
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))
}
}
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) {
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("详情", value: \.detail)
}.padding(16)
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) {