Files
reimburse/reimburse/AdminMembersTab.swift
T
2026-09-20 16:31:24 +08:00

143 lines
8.4 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct AdminMembersTab: AdminPage {
@ObservedObject var model: AdminStore
@Binding var tab: String
@Binding var memberSection: String
@Binding var role: String
@State private var memberSearch = ""
@State private var hours = 168
@State private var uses = 50
@State private var confirmation: AdminAction?
var body: some View {
members.adminConfirmation($confirmation)
}
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 {
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) {
emptyState(model.availableGroups.isEmpty ? "当前项目暂无可见组" : "请选择一个管理组", icon: "person.3", message: "在上方选择组别后,即可审核成员或发送入组邀请。")
if model.projectID != 0 {
Button("查看全部项目的组") { model.projectID = 0 }
}
if !model.editableProjects.isEmpty {
Button("组别管理") { tab = "组别管理" }
}
if model.access?.platform == true {
Button("项目与授权") { tab = "项目与授权" }
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let group = model.selectedGroup, !group.canManage {
VStack(spacing: 12) {
emptyState("\(group.label) · 未授权", icon: "lock", message: "当前账户没有本组成员管理权限")
if model.access?.platform == true {
Button("项目负责人授权") {
model.projectID = group.projectId
role = "leader"
tab = "项目与授权"
}
}
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
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("状态") { statusBadge(memberState($0.status), positive: $0.status == "active", pending: $0.status == "pending") }
TableColumn("操作") { m in
HStack {
if m.status == "pending" {
Button("批准入组") { run { try await model.reviewMember(m, status: "active") } }
Button("拒绝") { confirm("拒绝 \(m.name) 的入组申请?") { try await model.reviewMember(m, status: "rejected") } }
} else if m.status == "active" {
Button("停用") { confirm("停用 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "disabled") } }
} else {
Button("恢复") { confirm("恢复 \(m.name) 的本组成员资格?") { try await model.reviewMember(m, status: "active") } }
}
}
}.width(min: 200)
}.frame(minHeight: 180).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 {
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 func confirm(_ title: String, _ work: @escaping () async throws -> Void) {
confirmation = AdminAction(title: title) { run(work) }
}
}