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