Files
suixinkan_ios_new/suixinkan/Features/ScenicSettlement/Views/ScenicSettlementViews.swift
汉秋 c39c3d3c75 新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 13:39:02 +08:00

433 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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
}
}