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:
2026-06-25 13:39:02 +08:00
parent fb8430889b
commit 1a0d1c25f4
63 changed files with 9823 additions and 58 deletions

View File

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