Add order tail flows and migrate queue, message, settlement, and audit modules.
Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -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