新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,41 @@
|
||||
//
|
||||
// ScenicSettlementAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
/// 景区结算服务协议,定义结算申请提交能力。
|
||||
@MainActor
|
||||
protocol ScenicSettlementServing {
|
||||
/// 提交单个景区的结算申请。
|
||||
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区结算 API,封装结算申请提交网络请求。
|
||||
final class ScenicSettlementAPI {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化景区结算 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 提交单个景区的结算申请。
|
||||
func scenicSettlementSubmit(_ request: ScenicSettlementSubmitRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/scenic-settlement/submit",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ScenicSettlementAPI: ScenicSettlementServing {}
|
||||
@ -0,0 +1,28 @@
|
||||
//
|
||||
// ScenicSettlementModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 景区结算可选项,表示一个可申请结算的景区。
|
||||
struct ScenicSettlementOption: Equatable, Identifiable {
|
||||
let id: Int
|
||||
let name: String
|
||||
var selected: Bool
|
||||
}
|
||||
|
||||
/// 景区结算提交请求实体。
|
||||
struct ScenicSettlementSubmitRequest: Encodable, Equatable {
|
||||
let scenicId: Int
|
||||
let applyAmount: String
|
||||
let applyRemark: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicId = "scenic_id"
|
||||
case applyAmount = "apply_amount"
|
||||
case applyRemark = "apply_remark"
|
||||
}
|
||||
}
|
||||
23
suixinkan/Features/ScenicSettlement/ScenicSettlement.md
Normal file
23
suixinkan/Features/ScenicSettlement/ScenicSettlement.md
Normal file
@ -0,0 +1,23 @@
|
||||
# 景区结算模块
|
||||
|
||||
## 模块职责
|
||||
|
||||
`Features/ScenicSettlement` 承接首页 `scenic_settlement` 和 `scenic_settlement_review` 权限入口,负责景区结算申请提交和结算相关审核记录展示。
|
||||
|
||||
## 结算申请
|
||||
|
||||
`ScenicSettlementViewModel` 从账号上下文读取已有景区,并通过 `ScenicPermissionAPI.scenicListAll` 加载全部可申请景区,排除已开通景区后供用户多选。
|
||||
|
||||
提交时校验金额必填、正数且最多两位小数,再按景区 ID 升序逐条调用 `/api/yf-handset-app/photog/scenic-settlement/submit`。全部成功后清空已选景区、金额和备注;任一请求失败时保留当前表单并提示错误。
|
||||
|
||||
## 结算审核
|
||||
|
||||
`ScenicSettlementReviewViewModel` 复用景区申请记录和权限申请记录接口:
|
||||
- `scenicApplicationPendingAll`
|
||||
- `roleApplyAll`
|
||||
|
||||
两个通道独立容错。单通道失败时展示另一通道数据并提示失败;双通道失败时展示整页失败和重新加载入口。
|
||||
|
||||
## 边界
|
||||
|
||||
旧 Android 工程中的景区结算仍是本地 mock 数据。本模块以旧 iOS 已接入的真实结算提交接口和审核记录聚合方式为迁移依据,不新增后端未体现的详情审批操作。
|
||||
@ -0,0 +1,264 @@
|
||||
//
|
||||
// ScenicSettlementViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区结算申请 ViewModel,管理可申请景区、多选、金额备注和提交。
|
||||
final class ScenicSettlementViewModel {
|
||||
var existingScenics: [BusinessScope] = []
|
||||
var options: [ScenicSettlementOption] = []
|
||||
var amountText = ""
|
||||
var remarkText = ""
|
||||
var isLoading = false
|
||||
var isSubmitting = false
|
||||
var loadFailed = false
|
||||
var loadFailureReason: String?
|
||||
var message: String?
|
||||
|
||||
private var selectedIds = Set<Int>()
|
||||
|
||||
/// 已选择景区数量。
|
||||
var selectedCount: Int {
|
||||
selectedIds.count
|
||||
}
|
||||
|
||||
/// 当前选择的景区 ID,供测试和提交使用。
|
||||
var selectedScenicIds: Set<Int> {
|
||||
selectedIds
|
||||
}
|
||||
|
||||
/// 金额校验状态。
|
||||
var amountValidation: ScenicSettlementAmountValidation {
|
||||
let trimmed = amountText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return .empty }
|
||||
guard trimmed.range(of: #"^\d+(\.\d{1,2})?$"#, options: .regularExpression) != nil else {
|
||||
return .invalidFormat
|
||||
}
|
||||
guard let decimal = Decimal(string: trimmed), decimal > 0 else {
|
||||
return .notPositive
|
||||
}
|
||||
return .valid
|
||||
}
|
||||
|
||||
/// 是否允许提交结算申请。
|
||||
var canSubmit: Bool {
|
||||
!isSubmitting && !selectedIds.isEmpty && amountValidation == .valid
|
||||
}
|
||||
|
||||
/// 加载已开通景区和可申请结算景区。
|
||||
func load(api: any ScenicPermissionServing, existingScenics: [BusinessScope]) async {
|
||||
isLoading = true
|
||||
loadFailed = false
|
||||
loadFailureReason = nil
|
||||
self.existingScenics = existingScenics
|
||||
defer { isLoading = false }
|
||||
|
||||
do {
|
||||
let all = try await api.scenicListAll().list
|
||||
let existingIds = Set(existingScenics.map(\.id))
|
||||
options = all
|
||||
.filter { !existingIds.contains($0.id) }
|
||||
.map { ScenicSettlementOption(id: $0.id, name: $0.name, selected: selectedIds.contains($0.id)) }
|
||||
} catch {
|
||||
options = []
|
||||
selectedIds.removeAll()
|
||||
loadFailed = true
|
||||
loadFailureReason = error.localizedDescription
|
||||
message = "可申请景区加载失败,请重试"
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换一个景区的结算申请选择状态。
|
||||
func toggleScenic(id: Int) {
|
||||
guard let index = options.firstIndex(where: { $0.id == id }) else { return }
|
||||
options[index].selected.toggle()
|
||||
if options[index].selected {
|
||||
selectedIds.insert(id)
|
||||
} else {
|
||||
selectedIds.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 提交结算申请,多景区按 ID 升序逐条提交。
|
||||
func submit(api: any ScenicSettlementServing) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard canSubmit else {
|
||||
message = validationMessage
|
||||
return false
|
||||
}
|
||||
|
||||
isSubmitting = true
|
||||
defer { isSubmitting = false }
|
||||
|
||||
let amount = normalizedAmount
|
||||
let remark = remarkText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
do {
|
||||
for scenicId in selectedIds.sorted() {
|
||||
try await api.scenicSettlementSubmit(
|
||||
ScenicSettlementSubmitRequest(
|
||||
scenicId: scenicId,
|
||||
applyAmount: amount,
|
||||
applyRemark: remark
|
||||
)
|
||||
)
|
||||
}
|
||||
selectedIds.removeAll()
|
||||
options = options.map { option in
|
||||
ScenicSettlementOption(id: option.id, name: option.name, selected: false)
|
||||
}
|
||||
amountText = ""
|
||||
remarkText = ""
|
||||
message = "提交成功,等待审核"
|
||||
return true
|
||||
} catch {
|
||||
message = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private var normalizedAmount: String {
|
||||
let decimal = Decimal(string: amountText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
|
||||
return String(format: "%.2f", NSDecimalNumber(decimal: decimal).doubleValue)
|
||||
}
|
||||
|
||||
private var validationMessage: String {
|
||||
if selectedIds.isEmpty { return "请先选择景区" }
|
||||
switch amountValidation {
|
||||
case .empty:
|
||||
return "请填写结算金额"
|
||||
case .invalidFormat:
|
||||
return "金额格式错误,最多支持两位小数"
|
||||
case .notPositive:
|
||||
return "金额需大于 0"
|
||||
case .valid:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区结算金额校验结果。
|
||||
enum ScenicSettlementAmountValidation: Equatable {
|
||||
case empty
|
||||
case invalidFormat
|
||||
case notPositive
|
||||
case valid
|
||||
|
||||
/// 对应的状态提示文案。
|
||||
var badgeText: String {
|
||||
switch self {
|
||||
case .empty: "待填写金额"
|
||||
case .invalidFormat: "金额格式错误"
|
||||
case .notPositive: "金额需大于 0"
|
||||
case .valid: "金额有效"
|
||||
}
|
||||
}
|
||||
|
||||
/// 对应的状态颜色。
|
||||
var badgeColor: Color {
|
||||
switch self {
|
||||
case .empty, .notPositive:
|
||||
return AppDesign.warning
|
||||
case .invalidFormat:
|
||||
return Color(hex: 0xDC2626)
|
||||
case .valid:
|
||||
return AppDesign.success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 景区结算审核 ViewModel,聚合景区申请和权限申请审核记录。
|
||||
final class ScenicSettlementReviewViewModel {
|
||||
var scenicApplications: [ScenicApplicationPendingResponse] = []
|
||||
var roleApplications: [RoleApplyPendingResponse] = []
|
||||
var isLoading = false
|
||||
var message: String?
|
||||
var loadFailedAll = false
|
||||
var scenicLoadFailed = false
|
||||
var roleLoadFailed = false
|
||||
|
||||
/// 待审核记录数量。
|
||||
var pendingCount: Int {
|
||||
scenicApplications.filter { $0.status == 1 }.count + roleApplications.filter { $0.status == 1 }.count
|
||||
}
|
||||
|
||||
/// 加载审核记录,单通道失败时保留另一通道数据。
|
||||
func load(api: any ScenicPermissionServing) async {
|
||||
isLoading = true
|
||||
loadFailedAll = false
|
||||
scenicLoadFailed = false
|
||||
roleLoadFailed = false
|
||||
message = nil
|
||||
defer { isLoading = false }
|
||||
|
||||
var errors: [String] = []
|
||||
var scenicLoaded = false
|
||||
var roleLoaded = false
|
||||
|
||||
do {
|
||||
scenicApplications = try await api.scenicApplicationPendingAll().items
|
||||
scenicLoaded = true
|
||||
} catch {
|
||||
scenicApplications = []
|
||||
scenicLoadFailed = true
|
||||
errors.append("景区申请记录加载失败")
|
||||
}
|
||||
|
||||
do {
|
||||
roleApplications = try await api.roleApplyAll()
|
||||
roleLoaded = true
|
||||
} catch {
|
||||
roleApplications = []
|
||||
roleLoadFailed = true
|
||||
errors.append("权限申请记录加载失败")
|
||||
}
|
||||
|
||||
loadFailedAll = !scenicLoaded && !roleLoaded
|
||||
if !errors.isEmpty {
|
||||
message = errors.joined(separator: ";")
|
||||
}
|
||||
}
|
||||
|
||||
/// 审核状态文案。
|
||||
func statusText(_ status: Int, fallback: String = "") -> String {
|
||||
let trimmed = fallback.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty { return trimmed }
|
||||
switch status {
|
||||
case 1: return "待审核"
|
||||
case 2: return "已通过"
|
||||
case 3: return "已驳回"
|
||||
case 9: return "已取消"
|
||||
default: return "未知"
|
||||
}
|
||||
}
|
||||
|
||||
/// 审核状态图标。
|
||||
func statusIcon(_ status: Int) -> String {
|
||||
switch status {
|
||||
case 2: return "checkmark.circle.fill"
|
||||
case 3: return "xmark.octagon.fill"
|
||||
case 1: return "clock.badge.exclamationmark.fill"
|
||||
default: return "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// 审核状态颜色。
|
||||
func statusColor(_ status: Int) -> Color {
|
||||
switch status {
|
||||
case 2: return AppDesign.success
|
||||
case 3: return Color(hex: 0xDC2626)
|
||||
case 1: return AppDesign.warning
|
||||
default: return AppDesign.textSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,432 @@
|
||||
//
|
||||
// ScenicSettlementViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 景区结算申请页,支持选择未开通结算的景区并提交金额。
|
||||
struct ScenicSettlementView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@Environment(ScenicSettlementAPI.self) private var scenicSettlementAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicSettlementViewModel()
|
||||
@State private var showScenicApplication = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
NewScenicBanner {
|
||||
showScenicApplication = true
|
||||
}
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
existingScenicsSection
|
||||
selectableScenicsSection
|
||||
settlementInfoSection
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
submitButton
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.navigationTitle("景区结算")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes)
|
||||
}
|
||||
.sheet(isPresented: $showScenicApplication) {
|
||||
NavigationStack {
|
||||
ScenicApplicationView()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var existingScenicsSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("已有景区")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
if viewModel.existingScenics.isEmpty {
|
||||
Text("暂无已开通景区")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.existingScenics) { scenic in
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.foregroundStyle(AppDesign.success)
|
||||
Text(scenic.name)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var selectableScenicsSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack {
|
||||
Text("可申请结算景区")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
Spacer()
|
||||
formBadge("已选 \(viewModel.selectedCount)", tint: viewModel.selectedCount == 0 ? AppDesign.textSecondary : AppDesign.primary)
|
||||
}
|
||||
|
||||
if viewModel.isLoading && viewModel.options.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, minHeight: 120)
|
||||
} else if viewModel.loadFailed {
|
||||
VStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
ContentUnavailableView(
|
||||
"可申请景区加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text(viewModel.loadFailureReason ?? "请检查网络后重试")
|
||||
)
|
||||
Button("重新加载") {
|
||||
Task { await viewModel.load(api: scenicPermissionAPI, existingScenics: accountContext.scenicScopes) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 160)
|
||||
} else if viewModel.options.isEmpty {
|
||||
Text("暂无可申请结算景区")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.options) { option in
|
||||
Button {
|
||||
viewModel.toggleScenic(id: option.id)
|
||||
} label: {
|
||||
HStack {
|
||||
Text(option.name)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||||
.foregroundStyle(option.selected ? AppDesign.primary : AppDesign.textPrimary)
|
||||
Spacer()
|
||||
Image(systemName: option.selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(option.selected ? AppDesign.primary : Color(hex: 0xCBD5E1))
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.padding(.vertical, AppMetrics.Spacing.xSmall)
|
||||
.background(option.selected ? AppDesign.primarySoft : Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var settlementInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack {
|
||||
Text("结算信息")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
formBadge("已选 \(viewModel.selectedCount)", tint: viewModel.selectedCount == 0 ? AppDesign.textSecondary : AppDesign.primary)
|
||||
formBadge(viewModel.amountValidation.badgeText, tint: viewModel.amountValidation.badgeColor)
|
||||
}
|
||||
TextField("申请金额(必填)", text: Binding(
|
||||
get: { viewModel.amountText },
|
||||
set: { viewModel.amountText = $0 }
|
||||
))
|
||||
.keyboardType(.decimalPad)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(minHeight: 44)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
TextField("备注信息(可选)", text: Binding(
|
||||
get: { viewModel.remarkText },
|
||||
set: { viewModel.remarkText = $0 }
|
||||
), axis: .vertical)
|
||||
.lineLimit(2...4)
|
||||
.textFieldStyle(.plain)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.frame(minHeight: 86, alignment: .topLeading)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var submitButton: some View {
|
||||
Button(viewModel.isSubmitting ? "提交中..." : "提交审核") {
|
||||
Task { _ = await viewModel.submit(api: scenicSettlementAPI) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: 12))
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!viewModel.canSubmit)
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white)
|
||||
}
|
||||
|
||||
private func formBadge(_ text: String, tint: Color) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(tint)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 22)
|
||||
.background(tint.opacity(0.12), in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
/// 景区结算审核记录页,展示景区申请和权限申请审核状态。
|
||||
struct ScenicSettlementReviewView: View {
|
||||
@Environment(ScenicPermissionAPI.self) private var scenicPermissionAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@State private var viewModel = ScenicSettlementReviewViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
if viewModel.loadFailedAll {
|
||||
fullFailureView
|
||||
} else {
|
||||
reviewSummary
|
||||
scenicSection
|
||||
roleSection
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
}
|
||||
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
|
||||
.navigationTitle("结算审核")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.load(api: scenicPermissionAPI)
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.load(api: scenicPermissionAPI)
|
||||
}
|
||||
.onChange(of: viewModel.message) { _, message in
|
||||
guard let message else { return }
|
||||
toastCenter.show(message)
|
||||
viewModel.message = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var fullFailureView: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
ContentUnavailableView(
|
||||
"审核记录加载失败",
|
||||
systemImage: "exclamationmark.triangle",
|
||||
description: Text("景区申请与权限申请均未加载成功,请检查网络后重试")
|
||||
)
|
||||
Button("重新加载") {
|
||||
Task { await viewModel.load(api: scenicPermissionAPI) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 34)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: 260)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var reviewSummary: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
summaryCard("景区申请", "\(viewModel.scenicApplications.count)")
|
||||
summaryCard("权限申请", "\(viewModel.roleApplications.count)")
|
||||
summaryCard("待审核", "\(viewModel.pendingCount)")
|
||||
}
|
||||
}
|
||||
|
||||
private var scenicSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
sectionHeader("景区申请记录", count: viewModel.scenicApplications.count, icon: "mountain.2.fill")
|
||||
if viewModel.scenicLoadFailed {
|
||||
sectionFailureRow("景区申请记录加载失败")
|
||||
} else if viewModel.scenicApplications.isEmpty {
|
||||
Text("暂无景区申请记录")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.scenicApplications) { item in
|
||||
reviewRow(
|
||||
title: item.scenicName,
|
||||
code: item.code,
|
||||
subtitle: item.createdAt,
|
||||
status: viewModel.statusText(item.status),
|
||||
status: item.status
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var roleSection: some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
sectionHeader("权限申请记录", count: viewModel.roleApplications.count, icon: "person.badge.shield.checkmark.fill")
|
||||
if viewModel.roleLoadFailed {
|
||||
sectionFailureRow("权限申请记录加载失败")
|
||||
} else if viewModel.roleApplications.isEmpty {
|
||||
Text("暂无权限申请记录")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.roleApplications) { item in
|
||||
reviewRow(
|
||||
title: "\(item.roleName) · \(scenicNames(item.scenicList))",
|
||||
code: item.code,
|
||||
subtitle: item.createdAt,
|
||||
status: viewModel.statusText(item.status, fallback: item.statusLabel),
|
||||
status: item.status
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func sectionFailureRow(_ text: String) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(AppDesign.warning)
|
||||
Text(text)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Spacer(minLength: 0)
|
||||
Button("重试") {
|
||||
Task { await viewModel.load(api: scenicPermissionAPI) }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.xSmall)
|
||||
.background(Color(hex: 0xFFF7ED), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func reviewRow(title: String, code: String, subtitle: String, status statusText: String, status: Int) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Label("申请编号:\(code.settlementDash)", systemImage: "number.square.fill")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 22)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
HStack {
|
||||
Label(statusText, systemImage: viewModel.statusIcon(status))
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(viewModel.statusColor(status))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 22)
|
||||
.background(viewModel.statusColor(status).opacity(0.12), in: Capsule())
|
||||
Spacer(minLength: 0)
|
||||
Label(subtitle.settlementDash, systemImage: "clock")
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.xSmall)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
private func summaryCard(_ title: String, _ value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.title, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(AppMetrics.Spacing.small)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func sectionHeader(_ title: String, count: Int, icon: String) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
Spacer()
|
||||
Text("\(count)")
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 22)
|
||||
.background(AppDesign.primarySoft, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
private func scenicNames(_ items: [RoleApplyScenicItem]) -> String {
|
||||
let names = items.map(\.name).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
return names.isEmpty ? "--" : names.joined(separator: "、")
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewScenicBanner: View {
|
||||
let onApplyClick: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: "building.2.fill")
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xFF7B00))
|
||||
Text("没有找到心仪的景区")
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .medium))
|
||||
.foregroundStyle(Color(hex: 0xFF7B00))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.85)
|
||||
Spacer()
|
||||
Button(action: onApplyClick) {
|
||||
HStack(spacing: 4) {
|
||||
Text("申请平台开通新景区")
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.8)
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0xFF7B00))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
.background(Color(hex: 0xFFF0E2))
|
||||
}
|
||||
}
|
||||
|
||||
private extension String {
|
||||
var settlementDash: String {
|
||||
let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? "--" : trimmed
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user