1
This commit is contained in:
@@ -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 服务和微信小程序,无需为本功能修改数据库结构。
|
||||||
|
|
||||||
## 删除材料与不使用
|
## 删除材料与不使用
|
||||||
|
|
||||||
“报销类型”中的垃圾桶会确认后调用材料类型删除接口,立即从全局材料列表及所有报销类型的材料规则中删除该材料,不删除历史报销附件。即使材料已选“不使用”,也保留删除入口。删除失败不会在本机假装移除;成功后保留其他尚未保存的规则编辑。
|
“报销类型”中的垃圾桶会确认后调用材料类型删除接口,立即从全局材料列表及所有报销类型的材料规则中删除该材料,不删除历史报销附件。即使材料已选“不使用”,也保留删除入口。删除失败不会在本机假装移除;成功后保留其他尚未保存的规则编辑。
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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: "只有平台管理员可以修改上传材料类型") }
|
||||||
|
|||||||
+299
-127
@@ -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,20 +66,22 @@ struct AdminView: View {
|
|||||||
}
|
}
|
||||||
if model.access == nil { login }
|
if model.access == nil { login }
|
||||||
else {
|
else {
|
||||||
HStack {
|
if tab != "报销类型" {
|
||||||
Picker("项目", selection: $model.projectID) {
|
HStack {
|
||||||
Text("全部授权项目").tag(Int64(0))
|
Picker("项目", selection: $model.projectID) {
|
||||||
ForEach(model.access?.projects ?? []) { Text($0.name).tag($0.id) }
|
Text("全部授权项目").tag(Int64(0))
|
||||||
}.frame(maxWidth: 300)
|
ForEach(model.access?.projects ?? []) { Text($0.name).tag($0.id) }
|
||||||
Picker("组别", selection: $model.groupID) {
|
}.frame(maxWidth: 300)
|
||||||
Text("全部可见组").tag(Int64(0))
|
Picker("组别", selection: $model.groupID) {
|
||||||
ForEach(model.availableGroups) { Text(groupOption($0)).tag($0.id) }
|
Text("全部可见组").tag(Int64(0))
|
||||||
}.frame(maxWidth: 350)
|
ForEach(model.availableGroups) { Text(groupOption($0)).tag($0.id) }
|
||||||
Spacer()
|
}.frame(maxWidth: 350)
|
||||||
}.padding(16).disabled(model.busy)
|
Spacer()
|
||||||
|
}.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) {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
Text(previousTypeName == nil ? "新增报销类型" : "重命名报销类型").font(.title3.bold())
|
||||||
HStack {
|
TextField("类型名称,最多 40 字", text: $editedTypeName).textFieldStyle(.roundedBorder)
|
||||||
Text("报销类型管理").font(.title3.bold())
|
Text(previousTypeName == nil ? "新类型默认停用。创建后配置说明、提示和材料要求,再勾选启用并保存规则。" : "材料规则保持不变,历史报销记录保留提交时的类型名称。未提交的草稿需重新选择新名称。")
|
||||||
Spacer()
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Text("配置不同类型所需材料和提交提示").foregroundStyle(.secondary)
|
if let error = model.error { Text(error).foregroundStyle(.red) }
|
||||||
}
|
HStack {
|
||||||
if model.access?.platform != true {
|
Spacer()
|
||||||
Label("仅平台管理员可以修改报销类型配置,当前为只读。", systemImage: "lock").foregroundStyle(.secondary)
|
Button("取消") { showTypeEditor = false }
|
||||||
}
|
Button("保存") {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
let name = editedTypeName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
HStack {
|
let previous = previousTypeName
|
||||||
Label("报销类型规则", systemImage: "list.bullet.rectangle")
|
run {
|
||||||
.font(.headline)
|
try await model.saveExpenseType(name: name, previous: previous)
|
||||||
Spacer()
|
policySearch = ""
|
||||||
Picker("选择报销类型", selection: $selectedPolicyType) {
|
selectedPolicyType = name
|
||||||
if model.policies.isEmpty {
|
resetPolicyMaterialEditor()
|
||||||
Text("暂无报销类型").tag("")
|
showTypeEditor = false
|
||||||
} else {
|
|
||||||
ForEach(model.policies) { policy in
|
|
||||||
Text(policy.active ? policy.type : "\(policy.type)(已停用)")
|
|
||||||
.tag(policy.type)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(width: 280)
|
|
||||||
}
|
}
|
||||||
Text("选择不同报销类型后,下方说明、提示和材料要求会同步切换。")
|
}.buttonStyle(.borderedProminent)
|
||||||
.font(.caption)
|
.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<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)
|
||||||
|
}
|
||||||
|
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)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
Spacer()
|
||||||
if let policyIndex = model.policies.firstIndex(where: { $0.type == selectedPolicyType }) {
|
if model.access?.platform == true {
|
||||||
let policy = $model.policies[policyIndex]
|
Button {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
let value = policy.wrappedValue
|
||||||
HStack {
|
run {
|
||||||
Text(policy.wrappedValue.type).font(.headline)
|
try await model.updatePolicy(value)
|
||||||
Spacer()
|
lastSavedPolicy = model.policies.first { $0.type == value.type }
|
||||||
Toggle("启用", isOn: policy.active).disabled(model.access?.platform != true)
|
|
||||||
}
|
}
|
||||||
TextField("报销内容说明", text: policy.description, axis: .vertical)
|
} label: { Label("保存规则", systemImage: "checkmark").padding(.horizontal, 10) }
|
||||||
.textFieldStyle(.roundedBorder).lineLimit(2...4).disabled(model.access?.platform != true)
|
.buttonStyle(.borderedProminent).controlSize(.large)
|
||||||
TextField("提交提示", text: policy.tips, axis: .vertical)
|
}
|
||||||
.textFieldStyle(.roundedBorder).lineLimit(2...4).disabled(model.access?.platform != true)
|
}.font(.callout).padding(.horizontal, 24).padding(.vertical, 16)
|
||||||
Text("上传材料规则").font(.caption).foregroundStyle(.secondary)
|
.background(Color(nsColor: .windowBackgroundColor))
|
||||||
ForEach(model.uploadKinds.filter(\.active)) { kind in
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
HStack {
|
}
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
private func policyBasicInformation(policy: Binding<AdminExpensePolicy>) -> some View {
|
||||||
Text(kind.name)
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
Text(kind.hint).font(.caption).foregroundStyle(.secondary)
|
Label("基本信息", systemImage: "text.alignleft").font(.headline)
|
||||||
}
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
Spacer()
|
Text("报销内容说明").font(.callout.weight(.medium))
|
||||||
materialModeButtons(policy: policy, kind: kind)
|
TextField("例如:项目期间产生的交通与出行费用", text: policy.description, axis: .vertical)
|
||||||
if policy.wrappedValue.materials.contains(where: { $0.kind == kind.kind }) {
|
.textFieldStyle(.roundedBorder).lineLimit(2...4)
|
||||||
Button {
|
}
|
||||||
editingPolicyMaterialKind = kind.kind
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
newPolicyMaterialName = kind.name
|
Text("申请提示").font(.callout.weight(.medium))
|
||||||
newPolicyMaterialHint = kind.hint
|
TextField("告诉申请人需要补充哪些信息", text: policy.tips, axis: .vertical)
|
||||||
newPolicyMaterialAllowsImage = kind.allowImage
|
.textFieldStyle(.roundedBorder).lineLimit(2...4)
|
||||||
newPolicyMaterialAllowsPDF = kind.allowPDF
|
}
|
||||||
} label: { Image(systemName: "pencil") }
|
}.disabled(model.access?.platform != true)
|
||||||
.buttonStyle(.borderless)
|
}
|
||||||
.help("编辑当前类型材料")
|
private func policyMaterialsSection(policy: Binding<AdminExpensePolicy>) -> some View {
|
||||||
}
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
Button {
|
HStack {
|
||||||
confirm("删除材料类型“\(kind.name)”?将从所有报销类型的材料规则中移除,历史报销附件保留。此操作立即生效;仅暂停使用请选择“不使用”并保存规则。") {
|
Label("上传材料", systemImage: "paperclip").font(.headline)
|
||||||
try await model.deleteUploadKind(kind)
|
Spacer()
|
||||||
if editingPolicyMaterialKind == kind.kind { resetPolicyMaterialEditor() }
|
Button {
|
||||||
}
|
resetPolicyMaterialEditor()
|
||||||
} label: { Image(systemName: "trash") }
|
model.error = nil
|
||||||
.buttonStyle(.borderless)
|
showMaterialEditor = true
|
||||||
.tint(.red)
|
} label: { Label("添加材料", systemImage: "plus") }
|
||||||
.help("删除材料类型(所有报销类型生效,历史附件保留)")
|
.disabled(model.access?.platform != true)
|
||||||
.disabled(model.access?.platform != true)
|
}
|
||||||
}
|
Text("不使用的材料不会出现在申请表中;必填材料至少上传一份。")
|
||||||
}
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
Divider()
|
VStack(spacing: 0) {
|
||||||
Text(editingPolicyMaterialKind.isEmpty ? "新增本类型材料" : "编辑当前类型材料")
|
ForEach(model.uploadKinds.filter(\.active)) { kind in
|
||||||
.font(.subheadline.weight(.semibold))
|
policyMaterialRow(policy: policy, kind: kind)
|
||||||
HStack {
|
}
|
||||||
TextField("材料名称,如租赁合同", text: $newPolicyMaterialName)
|
if model.uploadKinds.filter(\.active).isEmpty {
|
||||||
TextField("材料提示", text: $newPolicyMaterialHint)
|
Text("还没有材料类型,点击“添加材料”开始配置。")
|
||||||
}
|
.foregroundStyle(.secondary).padding(20).frame(maxWidth: .infinity)
|
||||||
HStack {
|
}
|
||||||
Toggle("允许图片", isOn: $newPolicyMaterialAllowsImage)
|
}.background(Color(nsColor: .windowBackgroundColor).opacity(0.55))
|
||||||
Toggle("允许 PDF", isOn: $newPolicyMaterialAllowsPDF)
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
Spacer()
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.secondary.opacity(0.12)))
|
||||||
if !editingPolicyMaterialKind.isEmpty {
|
}
|
||||||
Button("取消编辑") { resetPolicyMaterialEditor() }
|
}
|
||||||
}
|
private func policyMaterialRow(policy: Binding<AdminExpensePolicy>, kind: AdminUploadKind) -> some View {
|
||||||
Button {
|
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<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)
|
||||||
|
}
|
||||||
|
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 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: {
|
}.buttonStyle(.borderedProminent)
|
||||||
Label(editingPolicyMaterialKind.isEmpty ? "添加到当前类型" : "更新当前类型材料",
|
.disabled(newPolicyMaterialName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || (!newPolicyMaterialAllowsImage && !newPolicyMaterialAllowsPDF))
|
||||||
systemImage: editingPolicyMaterialKind.isEmpty ? "plus" : "checkmark")
|
}
|
||||||
}
|
}.padding(24).frame(width: 480).disabled(model.busy || model.access?.platform != true)
|
||||||
.buttonStyle(.bordered)
|
.interactiveDismissDisabled(model.busy)
|
||||||
.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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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: " + ")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user