This commit is contained in:
csj
2026-09-13 10:52:50 +08:00
parent e1fdb816ab
commit b17e3246f0
5 changed files with 381 additions and 133 deletions
+16
View File
@@ -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 服务和微信小程序,无需为本功能修改数据库结构。
## 删除材料与不使用 ## 删除材料与不使用
“报销类型”中的垃圾桶会确认后调用材料类型删除接口,立即从全局材料列表及所有报销类型的材料规则中删除该材料,不删除历史报销附件。即使材料已选“不使用”,也保留删除入口。删除失败不会在本机假装移除;成功后保留其他尚未保存的规则编辑。 “报销类型”中的垃圾桶会确认后调用材料类型删除接口,立即从全局材料列表及所有报销类型的材料规则中删除该材料,不删除历史报销附件。即使材料已选“不使用”,也保留删除入口。删除失败不会在本机假装移除;成功后保留其他尚未保存的规则编辑。
+3 -3
View File
@@ -59,7 +59,7 @@ struct AdminExpense: Codable, Identifiable {
let amountCents: Int64; let amountText: String; let note: String; let status: String; let state: String 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] 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 kind: String
let name: String let name: String
let hint: String let hint: String
@@ -80,8 +80,8 @@ struct AdminUploadKind: Codable, Identifiable {
var sortOrder: Int var sortOrder: Int
var id: String { kind } var id: String { kind }
} }
struct AdminExpensePolicy: Codable, Identifiable { struct AdminExpensePolicy: Codable, Identifiable, Equatable {
let type: String var type: String
var description: String var description: String
var tips: String var tips: String
var requiredKinds: [String] var requiredKinds: [String]
+34 -3
View File
@@ -363,15 +363,46 @@ private final class AdminRedirectGuard: NSObject, URLSessionTaskDelegate, @unche
try await reload() try await reload()
} }
func loadAudit() async throws { audit = try await request("/api/admin/audit") } 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 { func updatePolicy(_ policy: AdminExpensePolicy) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改报销类型") } 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, "description": policy.description, "tips": policy.tips,
"requiredKinds": policy.materials.filter(\.required).map(\.kind), "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 "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 { func updateUploadKind(_ kind: AdminUploadKind) async throws {
guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改上传材料类型") } guard access?.platform == true else { throw AdminFailure(message: "只有平台管理员可以修改上传材料类型") }
+261 -89
View File
@@ -30,6 +30,12 @@ struct AdminView: View {
@State private var materialActive = true @State private var materialActive = true
@State private var editingMaterialKind = "" @State private var editingMaterialKind = ""
@State private var selectedPolicyType = "" @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 newPolicyMaterialName = ""
@State private var newPolicyMaterialHint = "" @State private var newPolicyMaterialHint = ""
@State private var newPolicyMaterialAllowsImage = true @State private var newPolicyMaterialAllowsImage = true
@@ -60,6 +66,7 @@ struct AdminView: View {
} }
if model.access == nil { login } if model.access == nil { login }
else { else {
if tab != "报销类型" {
HStack { HStack {
Picker("项目", selection: $model.projectID) { Picker("项目", selection: $model.projectID) {
Text("全部授权项目").tag(Int64(0)) Text("全部授权项目").tag(Int64(0))
@@ -71,9 +78,10 @@ struct AdminView: View {
}.frame(maxWidth: 350) }.frame(maxWidth: 350)
Spacer() Spacer()
}.padding(16).disabled(model.busy) }.padding(16).disabled(model.busy)
}
Picker("管理视图", selection: $tab) { Picker("管理视图", selection: $tab) {
ForEach(["报销审批", "成员与邀请", "组别管理", "项目与授权", "报销类型", "操作记录"], id: \.self) { Text($0) } ForEach(["报销审批", "成员与邀请", "组别管理", "项目与授权", "报销类型", "操作记录"], id: \.self) { Text($0) }
}.pickerStyle(.segmented).padding(.horizontal, 16) }.pickerStyle(.segmented).padding(.horizontal, 16).padding(.top, tab == "报销类型" ? 16 : 0)
Group { Group {
switch tab { switch tab {
case "成员与邀请": members case "成员与邀请": members
@@ -96,7 +104,14 @@ struct AdminView: View {
.onChange(of: model.policies.map(\.type)) { _, types in .onChange(of: model.policies.map(\.type)) { _, types in
if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" } if !types.contains(selectedPolicyType) { selectedPolicyType = types.first ?? "" }
} }
.onChange(of: selectedPolicyType) { _, _ in resetPolicyMaterialEditor() }
.sheet(item: $model.selected) { detail($0) } .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) { .sheet(isPresented: $showDateExport) {
dateExport dateExport
} }
@@ -572,98 +587,275 @@ struct AdminView: View {
TableColumn("详情", value: \.detail) TableColumn("详情", value: \.detail)
}.padding(16) }.padding(16)
} }
private var policies: some View { private var expenseTypeEditor: some View {
ScrollView { 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
}
}.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) { VStack(alignment: .leading, spacing: 16) {
HStack { HStack {
Text("报销类型管理").font(.title3.bold()) Text("报销类型").font(.headline)
Text("\(model.policies.count)").font(.caption).foregroundStyle(.secondary)
Spacer() Spacer()
Text("配置不同类型所需材料和提交提示").foregroundStyle(.secondary)
} }
if model.access?.platform != true { HStack(spacing: 8) {
Label("仅平台管理员可以修改报销类型配置,当前为只读。", systemImage: "lock").foregroundStyle(.secondary) 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("清空搜索")
} }
VStack(alignment: .leading, spacing: 12) { }.padding(10).background(Color(nsColor: .controlBackgroundColor)).clipShape(RoundedRectangle(cornerRadius: 8))
HStack { ScrollView {
Label("报销类型规则", systemImage: "list.bullet.rectangle") LazyVStack(spacing: 6) {
.font(.headline) ForEach(filteredPolicies) { policy in
Spacer() policySidebarRow(policy)
Picker("选择报销类型", selection: $selectedPolicyType) { }
if model.policies.isEmpty { if filteredPolicies.isEmpty {
Text("暂无报销类型").tag("") 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 { } else {
ForEach(model.policies) { policy in VStack(spacing: 12) {
Text(policy.active ? policy.type : "\(policy.type)(已停用)") Image(systemName: "doc.badge.plus").font(.system(size: 34)).foregroundStyle(Color.accentColor)
.tag(policy.type) 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<AdminExpensePolicy>) -> 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)
} }
.frame(width: 280) policyBasicInformation(policy: policy)
policyMaterialsSection(policy: policy)
}.padding(24).frame(maxWidth: 960, alignment: .leading).frame(maxWidth: .infinity)
} }
Text("选择不同报销类型后,下方说明、提示和材料要求会同步切换。") Divider()
.font(.caption) HStack(spacing: 12) {
if lastSavedPolicy == policy.wrappedValue {
Label("规则已保存", systemImage: "checkmark.circle.fill").foregroundStyle(.green)
} else {
Text(model.access?.platform == true ? "修改后请保存 · 切换类型不会自动保存" : "当前为只读模式")
.foregroundStyle(.secondary) .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() Spacer()
Toggle("启用", isOn: policy.active).disabled(model.access?.platform != true) if model.access?.platform == true {
}
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 { Button {
let value = policy.wrappedValue
run {
try await model.updatePolicy(value)
lastSavedPolicy = model.policies.first { $0.type == value.type }
}
} 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<AdminExpensePolicy>) -> 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<AdminExpensePolicy>) -> 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<AdminExpensePolicy>, 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 editingPolicyMaterialKind = kind.kind
newPolicyMaterialName = kind.name newPolicyMaterialName = kind.name
newPolicyMaterialHint = kind.hint newPolicyMaterialHint = kind.hint
newPolicyMaterialAllowsImage = kind.allowImage newPolicyMaterialAllowsImage = kind.allowImage
newPolicyMaterialAllowsPDF = kind.allowPDF newPolicyMaterialAllowsPDF = kind.allowPDF
} label: { Image(systemName: "pencil") } model.error = nil
.buttonStyle(.borderless) showMaterialEditor = true
.help("编辑当前类型材料")
} }
Button { Divider()
confirm("删除材料类型“\(kind.name)”?将从所有报销类型的材料规则中移除,历史报销附件保留。此操作立即生效;仅暂停使用请选择“不使用”并保存规则。") { Button("删除材料类型…", role: .destructive) {
confirm("删除材料类型“\(kind.name)”?将从所有报销类型中移除,历史附件保留。仅暂停使用请选择“不使用”并保存规则。") {
try await model.deleteUploadKind(kind) try await model.deleteUploadKind(kind)
if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() } if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() }
} }
} label: { Image(systemName: "trash") } }
.buttonStyle(.borderless) } label: { Image(systemName: "ellipsis").frame(width: 20, height: 24) }
.tint(.red) .menuStyle(.borderlessButton).frame(width: 28).help("材料操作:编辑、删除")
.help("删除材料类型(所有报销类型生效,历史附件保留)")
.disabled(model.access?.platform != true) .disabled(model.access?.platform != true)
}.padding(14)
} }
private func policyMaterialEditor(policy: Binding<AdminExpensePolicy>) -> 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)
} }
Divider() VStack(alignment: .leading, spacing: 6) {
Text(editingPolicyMaterialKind.isEmpty ? "新增本类型材料" : "编辑当前类型材料") Text("上传提示").font(.callout.weight(.medium))
.font(.subheadline.weight(.semibold)) TextField("例如:请上传完整合同,包含签字页", text: $newPolicyMaterialHint).textFieldStyle(.roundedBorder)
HStack {
TextField("材料名称,如租赁合同", text: $newPolicyMaterialName)
TextField("材料提示", text: $newPolicyMaterialHint)
} }
HStack { HStack {
Toggle("允许图片", isOn: $newPolicyMaterialAllowsImage) Text("允许格式").font(.callout.weight(.medium))
Toggle("允许 PDF", isOn: $newPolicyMaterialAllowsPDF) Toggle("图片", isOn: $newPolicyMaterialAllowsImage)
Toggle("PDF", isOn: $newPolicyMaterialAllowsPDF)
}
if let error = model.error { Text(error).font(.callout).foregroundStyle(.red) }
HStack {
Spacer() Spacer()
if !editingPolicyMaterialKind.isEmpty { Button("取消") { showMaterialEditor = false; resetPolicyMaterialEditor() }
Button("取消编辑") { resetPolicyMaterialEditor() } Button("保存材料") {
}
Button {
let name = newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines) let name = newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines)
let hint = newPolicyMaterialHint.trimmingCharacters(in: .whitespacesAndNewlines) let hint = newPolicyMaterialHint.trimmingCharacters(in: .whitespacesAndNewlines)
let kind = editingPolicyMaterialKind.isEmpty let kind = editingPolicyMaterialKind.isEmpty
@@ -687,35 +879,15 @@ struct AdminView: View {
run { run {
try await model.updateUploadKind(nextKind) try await model.updateUploadKind(nextKind)
try await model.updatePolicy(nextPolicy) try await model.updatePolicy(nextPolicy)
lastSavedPolicy = model.policies.first { $0.type == nextPolicy.type }
showMaterialEditor = false
resetPolicyMaterialEditor() 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) }.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)
.padding(14)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
} else {
ContentUnavailableView("暂无可配置的报销类型", systemImage: "list.bullet.rectangle")
.frame(maxWidth: .infinity, minHeight: 180)
}
}.padding(20)
}
} }
private func materialFormats(_ kind: AdminUploadKind) -> String { private func materialFormats(_ kind: AdminUploadKind) -> String {
[kind.allowImage ? "图片" : nil, kind.allowPDF ? "PDF" : nil].compactMap { $0 }.joined(separator: " + ") [kind.allowImage ? "图片" : nil, kind.allowPDF ? "PDF" : nil].compactMap { $0 }.joined(separator: " + ")
+29
View File
@@ -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")
}
}