Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
433 lines
19 KiB
Swift
433 lines
19 KiB
Swift
//
|
||
// ScenicSettlementViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/25.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 景区结算申请页,支持选择未开通结算的景区并提交金额。
|
||
struct ScenicSettlementView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.scenicPermissionAPI) private var scenicPermissionAPI
|
||
@Environment(\.scenicSettlementAPI) private var scenicSettlementAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@StateObject 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) {
|
||
AppContentUnavailableView(
|
||
"可申请景区加载失败",
|
||
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) private var scenicPermissionAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@StateObject 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) {
|
||
AppContentUnavailableView(
|
||
"审核记录加载失败",
|
||
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
|
||
}
|
||
}
|