From b17e3246f04a948c5f4207fbe4c05f002ae17327 Mon Sep 17 00:00:00 2001 From: csj <2073278411@qq.com> Date: Sun, 13 Sep 2026 10:52:50 +0800 Subject: [PATCH] 1 --- PROJECT-ADMIN.md | 16 ++ reimburse/AdminModels.swift | 6 +- reimburse/AdminStore.swift | 37 ++- reimburse/AdminView.swift | 426 ++++++++++++++++++++--------- tests/AdminPolicyEditorTests.swift | 29 ++ 5 files changed, 381 insertions(+), 133 deletions(-) create mode 100644 tests/AdminPolicyEditorTests.swift diff --git a/PROJECT-ADMIN.md b/PROJECT-ADMIN.md index 36827dc..d2cfd0c 100644 --- a/PROJECT-ADMIN.md +++ b/PROJECT-ADMIN.md @@ -1,5 +1,21 @@ # 管理端接入 +## 报销类型页面布局 + +报销类型页面采用左侧搜索/选择类型、右侧编辑规则的分栏布局。类型列表和规则区域分别滚动,底部保存栏始终可见;保存成功显示反馈,继续修改后恢复保存提示。新增类型固定在左下方,重命名/删除集中在详情标题的更多菜单中。 + +基本信息包含明确的说明和提示标签;上传材料逐行配置不使用/选填/必填,编辑与删除位于该行更多菜单。添加、编辑材料使用弹窗,保存材料时也保存当前类型规则。只读权限及删除确认保持不变。全局类型页面不显示无关的项目/组别筛选。 + +保存规则只合并当前类型的服务端结果,不覆盖其他类型尚未保存的编辑。切换类型不会自动保存;刷新或退出前仍应主动保存。 + +回归验证:`swiftc reimburse/AdminModels.swift reimburse/AdminStore.swift tests/AdminPolicyEditorTests.swift -o /tmp/admin-policy-editor-tests && /tmp/admin-policy-editor-tests`。 + +## 报销类型管理 + +“报销类型”页新增“类型管理”区域,可查看全部启用/停用类型。平台管理员可新增、重命名或确认删除,普通管理账号只读。新增名称限制 1–40 字且不可重名,默认停用;创建后在下方配置规则、勾选启用并保存。删除立即生效,不是停用;历史报销记录及附件保留,历史类型名称不随重命名改变。类型配置对所有项目生效。 + +服务端拒绝时弹窗保留输入并显示错误;成功后更新列表和当前选择,不清空其他类型尚未保存的规则。需同步更新 Go 服务和微信小程序,无需为本功能修改数据库结构。 + ## 删除材料与不使用 “报销类型”中的垃圾桶会确认后调用材料类型删除接口,立即从全局材料列表及所有报销类型的材料规则中删除该材料,不删除历史报销附件。即使材料已选“不使用”,也保留删除入口。删除失败不会在本机假装移除;成功后保留其他尚未保存的规则编辑。 diff --git a/reimburse/AdminModels.swift b/reimburse/AdminModels.swift index abe33f3..01a45fc 100644 --- a/reimburse/AdminModels.swift +++ b/reimburse/AdminModels.swift @@ -59,7 +59,7 @@ struct AdminExpense: Codable, Identifiable { let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String let date: String; let version: Int; let files: [String: AdminAttachmentList]; let events: [AdminEvent] } -struct AdminExpenseMaterial: Codable, Identifiable { +struct AdminExpenseMaterial: Codable, Identifiable, Equatable { let kind: String let name: String let hint: String @@ -80,8 +80,8 @@ struct AdminUploadKind: Codable, Identifiable { var sortOrder: Int var id: String { kind } } -struct AdminExpensePolicy: Codable, Identifiable { - let type: String +struct AdminExpensePolicy: Codable, Identifiable, Equatable { + var type: String var description: String var tips: String var requiredKinds: [String] diff --git a/reimburse/AdminStore.swift b/reimburse/AdminStore.swift index a8146c8..a155de9 100644 --- a/reimburse/AdminStore.swift +++ b/reimburse/AdminStore.swift @@ -363,15 +363,46 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche try await reload() } func loadAudit() async throws { audit = try await request("/api/admin/audit") } + func saveExpenseType(name: String, previous: String?) async throws { + guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以管理报销类型") } + let name = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, name.unicodeScalars.count <= 40 else { throw AdminFailure(message: "类型名称须为 1 至 40 字") } + let suffix = previous.map { "/" + ($0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0) } ?? "" + do { + let _: Bool = try await request("/api/admin/expense-types" + suffix, method: previous == nil ? "POST" : "PUT", body: ["name": name]) + } catch let failure as AdminFailure where failure.status == 409 { + throw AdminFailure(message: "该类型名称已存在,请换一个名称或刷新列表后重试。") + } + if let previous, let index = policies.firstIndex(where: { $0.type == previous }) { + policies[index].type = name + } else if previous == nil { + policies.append(AdminExpensePolicy(type: name, description: name + "报销", tips: "请说明费用用途并提供相关材料。", requiredKinds: [], materials: [], active: false)) + } + } + func deleteExpenseType(_ type: String) async throws { + guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以管理报销类型") } + let encoded = type.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? type + let _: Bool = try await request("/api/admin/expense-types/\(encoded)", method: "DELETE") + policies.removeAll { $0.type == type } + } func updatePolicy(_ policy: AdminExpensePolicy) async throws { guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改报销类型") } - let _: Bool = try await request("/api/admin/expense-policies/\(policy.type.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? policy.type)", method: "PUT", body: [ + let _: Bool = try await request("/api/admin/expense-policies/\(policy.type.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? policy.type)", method: "PUT", body: [ "description": policy.description, "tips": policy.tips, "requiredKinds": policy.materials.filter(\.required).map(\.kind), - "materials": policy.materials.map { ["kind": $0.kind, "required": $0.required] }, + "materials": policy.materials.map { ["kind": $0.kind, "required": $0.required] as [String: Any] }, "active": policy.active ]) - policies = try await request("/api/expense-policies") + let refreshed: [AdminExpensePolicy] = try await request("/api/expense-policies") + guard let saved = refreshed.first(where: { $0.type == policy.type }) else { + throw AdminFailure(message: "该报销类型已被修改或删除,请刷新列表。") + } + mergeSavedPolicy(saved) + } + func mergeSavedPolicy(_ saved: AdminExpensePolicy) { + if let index = policies.firstIndex(where: { $0.type == saved.type }) { + policies[index] = saved + } } func updateUploadKind(_ kind: AdminUploadKind) async throws { guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改上传材料类型") } diff --git a/reimburse/AdminView.swift b/reimburse/AdminView.swift index 4f34044..8db76d5 100644 --- a/reimburse/AdminView.swift +++ b/reimburse/AdminView.swift @@ -30,6 +30,12 @@ struct AdminView: View { @State private var materialActive = true @State private var editingMaterialKind = "" @State private var selectedPolicyType = "" + @State private var policySearch = "" + @State private var showMaterialEditor = false + @State private var lastSavedPolicy: AdminExpensePolicy? + @State private var showTypeEditor = false + @State private var previousTypeName: String? + @State private var editedTypeName = "" @State private var newPolicyMaterialName = "" @State private var newPolicyMaterialHint = "" @State private var newPolicyMaterialAllowsImage = true @@ -60,20 +66,22 @@ struct AdminView: View { } if model.access == nil { login } else { - HStack { - Picker("项目", selection: $model.projectID) { - Text("全部授权项目").tag(Int64(0)) - ForEach(model.access?.projects ?? []) { Text($0.name).tag($0.id) } - }.frame(maxWidth: 300) - Picker("组别", selection: $model.groupID) { - Text("全部可见组").tag(Int64(0)) - ForEach(model.availableGroups) { Text(groupOption($0)).tag($0.id) } - }.frame(maxWidth: 350) - Spacer() - }.padding(16).disabled(model.busy) + 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) + }.pickerStyle(.segmented).padding(.horizontal, 16).padding(.top, tab == "报销类型" ? 16 : 0) Group { switch tab { case "成员与邀请": members @@ -96,7 +104,14 @@ struct AdminView: View { .onChange(of: model.policies.map(\.type)) { _, types in if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" } } + .onChange(of: selectedPolicyType) { _, _ in resetPolicyMaterialEditor() } .sheet(item: $model.selected) { detail($0) } + .sheet(isPresented: $showTypeEditor) { expenseTypeEditor } + .sheet(isPresented: $showMaterialEditor) { + if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) { + policyMaterialEditor(policy: $model.policies[index]) + } + } .sheet(isPresented: $showDateExport) { dateExport } @@ -572,98 +587,275 @@ struct AdminView: View { TableColumn("详情", value: \.detail) }.padding(16) } - private var policies: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("报销类型管理").font(.title3.bold()) - Spacer() - Text("配置不同类型所需材料和提交提示").foregroundStyle(.secondary) - } - if model.access?.platform != true { - Label("仅平台管理员可以修改报销类型配置,当前为只读。", systemImage: "lock").foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 12) { - HStack { - Label("报销类型规则", systemImage: "list.bullet.rectangle") - .font(.headline) - Spacer() - Picker("选择报销类型", selection: $selectedPolicyType) { - if model.policies.isEmpty { - Text("暂无报销类型").tag("") - } else { - ForEach(model.policies) { policy in - Text(policy.active ? policy.type : "\(policy.type)(已停用)") - .tag(policy.type) - } - } - } - .frame(width: 280) + private var expenseTypeEditor: some View { + VStack(alignment: .leading, spacing: 16) { + Text(previousTypeName == nil ? "新增报销类型" : "重命名报销类型").font(.title3.bold()) + TextField("类型名称,最多 40 字", text: $editedTypeName).textFieldStyle(.roundedBorder) + Text(previousTypeName == nil ? "新类型默认停用。创建后配置说明、提示和材料要求,再勾选启用并保存规则。" : "材料规则保持不变,历史报销记录保留提交时的类型名称。未提交的草稿需重新选择新名称。") + .font(.caption).foregroundStyle(.secondary) + if let error = model.error { Text(error).foregroundStyle(.red) } + HStack { + Spacer() + Button("取消") { showTypeEditor = false } + Button("保存") { + let name = editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines) + let previous = previousTypeName + run { + try await model.saveExpenseType(name: name, previous: previous) + policySearch = "" + selectedPolicyType = name + resetPolicyMaterialEditor() + showTypeEditor = false } - Text("选择不同报销类型后,下方说明、提示和材料要求会同步切换。") - .font(.caption) + }.buttonStyle(.borderedProminent) + .disabled(editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines).unicodeScalars.count > 40) + } + }.padding(24).frame(width: 440).disabled(model.busy).interactiveDismissDisabled(model.busy) + } + private var filteredPolicies: [AdminExpensePolicy] { + let query = policySearch.trimmingCharacters(in: .whitespacesAndNewlines) + return model.policies.filter { query.isEmpty || $0.type.localizedCaseInsensitiveContains(query) } + } + private func beginTypeEditor(_ policy: AdminExpensePolicy? = nil) { + model.error = nil + previousTypeName = policy?.type + editedTypeName = policy?.type ?? "" + showTypeEditor = true + } + private var expenseTypeManagement: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("报销类型").font(.headline) + Text("\(model.policies.count)").font(.caption).foregroundStyle(.secondary) + Spacer() + } + HStack(spacing: 8) { + Image(systemName: "magnifyingglass").foregroundStyle(.secondary) + TextField("搜索类型", text: $policySearch).textFieldStyle(.plain) + if !policySearch.isEmpty { + Button { policySearch = "" } label: { Image(systemName: "xmark.circle.fill") } + .buttonStyle(.plain).foregroundStyle(.secondary).help("清空搜索") + } + }.padding(10).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 8)) + ScrollView { + LazyVStack(spacing: 6) { + ForEach(filteredPolicies) { policy in + policySidebarRow(policy) + } + if filteredPolicies.isEmpty { + Text(model.policies.isEmpty ? "还没有报销类型" : "没有匹配的类型") + .font(.callout).foregroundStyle(.secondary).padding(.vertical, 24) + } + } + } + Button { beginTypeEditor() } label: { + Label("新增报销类型", systemImage: "plus").frame(maxWidth: .infinity).padding(.vertical, 5) + }.buttonStyle(.borderedProminent).disabled(model.access?.platform != true) + Text("类型配置对所有项目生效") + .font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity) + }.padding(16).frame(width: 244).frame(maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + } + private func policySidebarRow(_ policy: AdminExpensePolicy) -> some View { + let selected = policy.type == selectedPolicyType + return Button { + selectedPolicyType = policy.type + } label: { + HStack(spacing: 10) { + Image(systemName: "doc.text").foregroundStyle(selected ? Color.accentColor : Color.secondary) + VStack(alignment: .leading, spacing: 5) { + Text(policy.type).font(.system(size: 13, weight: selected ? .semibold : .regular)) + .lineLimit(2).multilineTextAlignment(.leading) + HStack(spacing: 5) { + Circle().fill(policy.active ? Color.green : Color.secondary.opacity(0.45)).frame(width: 5, height: 5) + Text(policy.active ? "启用" : "停用").font(.caption).foregroundStyle(.secondary) + } + } + Spacer(minLength: 0) + if selected { Image(systemName: "chevron.right").font(.caption.weight(.semibold)).foregroundStyle(Color.accentColor) } + }.padding(12).frame(maxWidth: .infinity, alignment: .leading) + .background(selected ? Color.accentColor.opacity(0.1) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 9)) + .contentShape(Rectangle()) + }.buttonStyle(.plain).accessibilityLabel("\(policy.type),\(policy.active ? "启用" : "停用")") + .accessibilityAddTraits(selected ? [.isSelected] : []) + } + private var policies: some View { + HStack(spacing: 0) { + expenseTypeManagement + Divider() + if let index = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) { + policyDetail(policy: $model.policies[index]) + } else { + VStack(spacing: 12) { + Image(systemName: "doc.badge.plus").font(.system(size: 34)).foregroundStyle(Color.accentColor) + Text("从一个报销类型开始").font(.title3.weight(.semibold)) + Text("新增类型后,设置说明和上传材料要求。") + .foregroundStyle(.secondary) + Button("新增报销类型") { beginTypeEditor() } + .buttonStyle(.borderedProminent).disabled(model.access?.platform != true) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + }.background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.12))) + .padding(16) + } + private func policyDetail(policy: Binding) -> some View { + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 16) { + VStack(alignment: .leading, spacing: 5) { + Text(policy.wrappedValue.type).font(.title2.weight(.semibold)) + Text("设置申请人看到的说明与材料要求").font(.callout).foregroundStyle(.secondary) + } + Spacer() + Toggle("启用此类型", isOn: policy.active).toggleStyle(.switch) + .disabled(model.access?.platform != true) + Menu { + Button("重命名类型") { beginTypeEditor(policy.wrappedValue) } + Divider() + Button("删除类型…", role: .destructive) { + let type = policy.wrappedValue.type + confirm("删除报销类型“\(type)”?将删除该类型及材料规则。历史报销记录和附件保留。") { + try await model.deleteExpenseType(type) + if selectedPolicyType == type { selectedPolicyType = model.policies.first?.type ?? "" } + } + } + } label: { Image(systemName: "ellipsis.circle").font(.title3) } + .menuStyle(.borderlessButton).frame(width: 28) + .help("类型操作:重命名、删除").disabled(model.access?.platform != true) + }.padding(24) + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if model.access?.platform != true { + Label("只读 · 仅平台管理员可修改", systemImage: "lock").font(.callout).foregroundStyle(.secondary) + } + policyBasicInformation(policy: policy) + policyMaterialsSection(policy: policy) + }.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity) + } + Divider() + HStack(spacing: 12) { + if lastSavedPolicy == policy.wrappedValue { + Label("规则已保存", systemImage: "checkmark.circle.fill").foregroundStyle(.green) + } else { + Text(model.access?.platform == true ? "修改后请保存 · 切换类型不会自动保存" : "当前为只读模式") .foregroundStyle(.secondary) } - .padding(.vertical, 4) - if let policyIndex = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) { - let policy = $model.policies[policyIndex] - VStack(alignment: .leading, spacing: 10) { - HStack { - Text(policy.wrappedValue.type).font(.headline) - Spacer() - Toggle("启用", isOn: policy.active).disabled(model.access?.platform != true) + Spacer() + if model.access?.platform == true { + Button { + let value = policy.wrappedValue + run { + try await model.updatePolicy(value) + lastSavedPolicy = model.policies.first { $0.type == value.type } } - TextField("报销内容说明", text: policy.description, axis: .vertical) - .textFieldStyle(.roundedBorder).lineLimit(2...4).disabled(model.access?.platform != true) - TextField("提交提示", text: policy.tips, axis: .vertical) - .textFieldStyle(.roundedBorder).lineLimit(2...4).disabled(model.access?.platform != true) - Text("上传材料规则").font(.caption).foregroundStyle(.secondary) - ForEach(model.uploadKinds.filter(\.active)) { kind in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(kind.name) - Text(kind.hint).font(.caption).foregroundStyle(.secondary) - } - Spacer() - materialModeButtons(policy: policy, kind: kind) - if policy.wrappedValue.materials.contains(where: { $0.kind == kind.kind }) { - Button { - editingPolicyMaterialKind = kind.kind - newPolicyMaterialName = kind.name - newPolicyMaterialHint = kind.hint - newPolicyMaterialAllowsImage = kind.allowImage - newPolicyMaterialAllowsPDF = kind.allowPDF - } label: { Image(systemName: "pencil") } - .buttonStyle(.borderless) - .help("编辑当前类型材料") - } - Button { - confirm("删除材料类型“\(kind.name)”?将从所有报销类型的材料规则中移除,历史报销附件保留。此操作立即生效;仅暂停使用请选择“不使用”并保存规则。") { - try await model.deleteUploadKind(kind) - if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() } - } - } label: { Image(systemName: "trash") } - .buttonStyle(.borderless) - .tint(.red) - .help("删除材料类型(所有报销类型生效,历史附件保留)") - .disabled(model.access?.platform != true) - } - } - Divider() - Text(editingPolicyMaterialKind.isEmpty ? "新增本类型材料" : "编辑当前类型材料") - .font(.subheadline.weight(.semibold)) - HStack { - TextField("材料名称,如租赁合同", text: $newPolicyMaterialName) - TextField("材料提示", text: $newPolicyMaterialHint) - } - HStack { - Toggle("允许图片", isOn: $newPolicyMaterialAllowsImage) - Toggle("允许 PDF", isOn: $newPolicyMaterialAllowsPDF) - Spacer() - if !editingPolicyMaterialKind.isEmpty { - Button("取消编辑") { resetPolicyMaterialEditor() } - } - Button { + } label: { Label("保存规则", systemImage: "checkmark").padding(.horizontal, 10) } + .buttonStyle(.borderedProminent).controlSize(.large) + } + }.font(.callout).padding(.horizontal, 24).padding(.vertical, 16) + .background(Color(nsColor: .windowBackgroundColor)) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func policyBasicInformation(policy: Binding) -> some View { + VStack(alignment: .leading, spacing: 16) { + Label("基本信息", systemImage: "text.alignleft").font(.headline) + VStack(alignment: .leading, spacing: 7) { + Text("报销内容说明").font(.callout.weight(.medium)) + TextField("例如:项目期间产生的交通与出行费用", text: policy.description, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(2...4) + } + VStack(alignment: .leading, spacing: 7) { + Text("申请提示").font(.callout.weight(.medium)) + TextField("告诉申请人需要补充哪些信息", text: policy.tips, axis: .vertical) + .textFieldStyle(.roundedBorder).lineLimit(2...4) + } + }.disabled(model.access?.platform != true) + } + private func policyMaterialsSection(policy: Binding) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Label("上传材料", systemImage: "paperclip").font(.headline) + Spacer() + Button { + resetPolicyMaterialEditor() + model.error = nil + showMaterialEditor = true + } label: { Label("添加材料", systemImage: "plus") } + .disabled(model.access?.platform != true) + } + Text("不使用的材料不会出现在申请表中;必填材料至少上传一份。") + .font(.caption).foregroundStyle(.secondary) + VStack(spacing: 0) { + ForEach(model.uploadKinds.filter(\.active)) { kind in + policyMaterialRow(policy: policy, kind: kind) + } + if model.uploadKinds.filter(\.active).isEmpty { + Text("还没有材料类型,点击“添加材料”开始配置。") + .foregroundStyle(.secondary).padding(20).frame(maxWidth: .infinity) + } + }.background(Color(nsColor: .windowBackgroundColor).opacity(0.55)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12))) + } + } + private func policyMaterialRow(policy: Binding, kind: AdminUploadKind) -> some View { + HStack(spacing: 14) { + Image(systemName: kind.allowPDF ? "doc.text" : "photo") + .foregroundStyle(Color.accentColor).frame(width: 32, height: 36) + VStack(alignment: .leading, spacing: 4) { + Text(kind.name).font(.callout.weight(.medium)) + Text(kind.hint.isEmpty ? materialFormats(kind) : kind.hint) + .font(.caption).foregroundStyle(.secondary).lineLimit(2).help(kind.hint) + }.frame(maxWidth: .infinity, alignment: .leading) + materialModeButtons(policy: policy, kind: kind) + .disabled(model.access?.platform != true) + Menu { + Button("编辑材料…") { + editingPolicyMaterialKind = kind.kind + newPolicyMaterialName = kind.name + newPolicyMaterialHint = kind.hint + newPolicyMaterialAllowsImage = kind.allowImage + newPolicyMaterialAllowsPDF = kind.allowPDF + model.error = nil + showMaterialEditor = true + } + Divider() + Button("删除材料类型…", role: .destructive) { + confirm("删除材料类型“\(kind.name)”?将从所有报销类型中移除,历史附件保留。仅暂停使用请选择“不使用”并保存规则。") { + try await model.deleteUploadKind(kind) + if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() } + } + } + } label: { Image(systemName: "ellipsis").frame(width: 20, height: 24) } + .menuStyle(.borderlessButton).frame(width: 28).help("材料操作:编辑、删除") + .disabled(model.access?.platform != true) + }.padding(14) + } + private func policyMaterialEditor(policy: Binding) -> some View { + VStack(alignment: .leading, spacing: 18) { + Text(editingPolicyMaterialKind.isEmpty ? "添加上传材料" : "编辑上传材料").font(.title3.bold()) + Text("用于“\(policy.wrappedValue.type)” · 保存时会一并保存当前类型的规则。") + .font(.caption).foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 6) { + Text("材料名称").font(.callout.weight(.medium)) + TextField("例如:租赁合同", text: $newPolicyMaterialName).textFieldStyle(.roundedBorder) + } + VStack(alignment: .leading, spacing: 6) { + Text("上传提示").font(.callout.weight(.medium)) + TextField("例如:请上传完整合同,包含签字页", text: $newPolicyMaterialHint).textFieldStyle(.roundedBorder) + } + HStack { + Text("允许格式").font(.callout.weight(.medium)) + Toggle("图片", isOn: $newPolicyMaterialAllowsImage) + Toggle("PDF", isOn: $newPolicyMaterialAllowsPDF) + } + if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) } + HStack { + Spacer() + Button("取消") { showMaterialEditor = false; resetPolicyMaterialEditor() } + Button("保存材料") { let name = newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines) let hint = newPolicyMaterialHint.trimmingCharacters(in: .whitespacesAndNewlines) let kind = editingPolicyMaterialKind.isEmpty @@ -687,35 +879,15 @@ struct AdminView: View { run { try await model.updateUploadKind(nextKind) try await model.updatePolicy(nextPolicy) + lastSavedPolicy = model.policies.first { $0.type == nextPolicy.type } + showMaterialEditor = false resetPolicyMaterialEditor() } - } label: { - Label(editingPolicyMaterialKind.isEmpty ? "添加到当前类型" : "更新当前类型材料", - systemImage: editingPolicyMaterialKind.isEmpty ? "plus" : "checkmark") - } - .buttonStyle(.bordered) - .disabled(newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - (!newPolicyMaterialAllowsImage && !newPolicyMaterialAllowsPDF)) - } - .disabled(model.access?.platform != true) - HStack { - Spacer() - if model.access?.platform == true { - Button { let value = policy.wrappedValue; run { try await model.updatePolicy(value) } } label: { - Label("保存规则", systemImage: "checkmark") - }.buttonStyle(.borderedProminent) - } - } - } - .padding(14) - .background(.quaternary.opacity(0.35)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } else { - ContentUnavailableView("暂无可配置的报销类型", systemImage: "list.bullet.rectangle") - .frame(maxWidth: .infinity, minHeight: 180) - } - }.padding(20) - } + }.buttonStyle(.borderedProminent) + .disabled(newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (!newPolicyMaterialAllowsImage && !newPolicyMaterialAllowsPDF)) + } + }.padding(24).frame(width: 480).disabled(model.busy || model.access?.platform != true) + .interactiveDismissDisabled(model.busy) } private func materialFormats(_ kind: AdminUploadKind) -> String { [kind.allowImage ? "图片" : nil, kind.allowPDF ? "PDF" : nil].compactMap { $0 }.joined(separator: " + ") diff --git a/tests/AdminPolicyEditorTests.swift b/tests/AdminPolicyEditorTests.swift new file mode 100644 index 0000000..f71dd60 --- /dev/null +++ b/tests/AdminPolicyEditorTests.swift @@ -0,0 +1,29 @@ +import Foundation + +@main +struct AdminPolicyEditorTests { + @MainActor static func main() { + let store = AdminStore() + let travel = AdminExpensePolicy(type: "交通差旅", description: "原说明", tips: "提示", requiredKinds: [], materials: [], active: true) + var accommodation = AdminExpensePolicy(type: "住宿费用", description: "未保存的说明", tips: "提示", requiredKinds: [], materials: [], active: true) + accommodation.tips = "未保存的提示" + accommodation.active = false + store.policies = [travel, accommodation] + var saved = travel + saved.description = "已保存的说明" + store.mergeSavedPolicy(saved) + precondition(store.policies.count == 2) + precondition(store.policies[0] == saved) + precondition(store.policies[1] == accommodation) + var edited = saved + edited.active = false + precondition(edited != saved) + edited = saved + edited.materials = [AdminExpenseMaterial(kind: "invoice", name: "发票", hint: "提示", required: true, allowImage: true, allowPDF: true, active: true, sortOrder: 0)] + precondition(edited != saved) + store.policies = [accommodation] + store.mergeSavedPolicy(saved) + precondition(store.policies == [accommodation]) + print("Policy editor state tests passed") + } +}