Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/OrdersView.swift
2026-06-22 15:35:12 +08:00

623 lines
26 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.

//
// OrdersView.swift
// suixinkan
//
// Created by Codex on 2026/6/22.
//
import SwiftUI
/// Tab
struct OrdersView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(PermissionContext.self) private var permissionContext
@Environment(AppRouter.self) private var appRouter
@Environment(RouterPath.self) private var router
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = OrdersViewModel()
@State private var manualOrderNumber = ""
@State private var showDateFilterSheet = false
@State private var showVerifyConfirmation = false
@State private var pendingVerifyOrderNumber: String?
private var currentScenicId: Int? {
accountContext.currentScenic?.id
}
private var currentRoleId: Int? {
permissionContext.currentRole?.id
}
private var contentMaxWidth: CGFloat {
horizontalSizeClass == .regular ? 900 : .infinity
}
private var currentFilterTitle: String {
OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
}
private var timeFilterTitle: String {
if let start = viewModel.filterStartDate, let end = viewModel.filterEndDate {
return "\(Self.dateFormatter.string(from: start))\(Self.dateFormatter.string(from: end))"
}
return "时间筛选"
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.small) {
entrySection
if currentScenicId == nil {
ContentUnavailableView(
"缺少经营上下文",
systemImage: "mountain.2",
description: Text("请先在首页选择景区后查看订单。")
)
.frame(minHeight: 360)
} else if viewModel.selectedEntry == .storeOrders {
storeOrdersSection
} else {
writeOffSection
}
}
.frame(maxWidth: contentMaxWidth)
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
.padding(.top, AppMetrics.Spacing.medium)
.padding(.bottom, AppMetrics.Spacing.pageVertical)
.frame(maxWidth: .infinity)
}
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("订单")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await reload(showLoading: false) }
.task {
viewModel.selectedEntry = appRouter.selectedOrdersEntry
await reload()
}
.onChange(of: appRouter.selectedOrdersEntry) { _, entry in
guard viewModel.selectedEntry != entry else { return }
viewModel.selectedEntry = entry
Task { await reload() }
}
.onChange(of: accountContext.currentScenic?.id) { _, _ in
Task { await reload() }
}
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
Button("确认核销") {
guard let orderNumber = pendingVerifyOrderNumber else { return }
Task { await verify(orderNumber: orderNumber) }
}
Button("取消", role: .cancel) {
pendingVerifyOrderNumber = nil
}
} message: {
Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "")
}
.sheet(isPresented: $showDateFilterSheet) {
OrdersDateFilterSheet(
startDate: $viewModel.filterStartDate,
endDate: $viewModel.filterEndDate,
onApply: { Task { await reload() } }
)
}
}
private var entrySection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
HStack(spacing: 0) {
entryButton("订单管理", entry: .storeOrders)
entryButton("核销订单", entry: .verificationOrders)
}
.padding(AppMetrics.Spacing.xxxSmall)
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 8))
HStack(spacing: AppMetrics.Spacing.small) {
contextPill(
icon: viewModel.selectedEntry == .storeOrders ? "mountain.2" : "qrcode",
title: viewModel.selectedEntry == .storeOrders ? "当前景区" : "核销订单",
value: viewModel.selectedEntry == .storeOrders ? (accountContext.currentScenic?.name ?? "--") : "\(viewModel.writeOffTotal)"
)
contextPill(
icon: "doc.text",
title: viewModel.selectedEntry == .storeOrders ? "订单总数" : "当前景区",
value: viewModel.selectedEntry == .storeOrders ? "\(viewModel.storeTotal)" : (accountContext.currentScenic?.name ?? "--")
)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var storeOrdersSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
storeFilterSection
storeList
}
}
private var writeOffSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
writeOffActionSection
writeOffList
}
}
private var storeFilterSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Menu {
ForEach(OrderFilters.statusFilters) { filter in
Button(filter.title) {
viewModel.selectedStatus = filter.id
Task { await reload() }
}
}
} label: {
filterLabel(title: currentFilterTitle, icon: "chevron.down")
}
Button {
showDateFilterSheet = true
} label: {
filterLabel(title: timeFilterTitle, icon: "calendar")
}
.buttonStyle(.plain)
}
HStack(spacing: AppMetrics.Spacing.xSmall) {
TextField("输入手机号搜索", text: $viewModel.searchPhone)
.keyboardType(.phonePad)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(.system(size: AppMetrics.FontSize.subheadline))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 40)
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
Button("搜索") {
Task { await reload() }
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 40)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 6))
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var storeList: some View {
LazyVStack(spacing: AppMetrics.Spacing.small) {
if viewModel.loading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.xxLarge)
} else if viewModel.storeOrders.isEmpty {
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
.frame(minHeight: 260)
} else {
ForEach(viewModel.storeOrders) { item in
storeOrderCard(item)
.onAppear {
guard item.id == viewModel.storeOrders.last?.id else { return }
Task { await loadMoreStoreOrders() }
}
}
if viewModel.loadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.small)
}
}
}
}
private var writeOffActionSection: some View {
VStack(spacing: AppMetrics.Spacing.small) {
Button {
router.navigate(to: .placeholder(title: "扫码核销"))
} label: {
Label("扫码核销", systemImage: "qrcode.viewfinder")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: 42)
.background(Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.accessibilityIdentifier("orders.scan")
HStack(spacing: AppMetrics.Spacing.xSmall) {
TextField("手动输入订单号", text: $manualOrderNumber)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.font(.system(size: AppMetrics.FontSize.subheadline))
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 42)
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 8))
.accessibilityIdentifier("orders.manualOrderNumber")
Button(viewModel.isVerifying ? "核销中" : "核销") {
triggerManualVerify()
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.medium)
.frame(height: 42)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
.disabled(viewModel.isVerifying)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private var writeOffList: some View {
LazyVStack(spacing: AppMetrics.Spacing.small) {
if viewModel.loading {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.xxLarge)
} else if viewModel.writeOffOrders.isEmpty {
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
.frame(minHeight: 260)
} else {
ForEach(viewModel.writeOffOrders) { item in
writeOffOrderCard(item)
.onAppear {
guard item.id == viewModel.writeOffOrders.last?.id else { return }
Task { await loadMoreWriteOffOrders() }
}
}
if viewModel.loadingMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding(.vertical, AppMetrics.Spacing.small)
}
}
}
}
private func entryButton(_ title: String, entry: OrdersEntry) -> some View {
let selected = viewModel.selectedEntry == entry
return Button {
switchEntry(entry)
} label: {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium))
.foregroundStyle(selected ? AppDesign.primary : AppDesign.textSecondary)
.frame(maxWidth: .infinity)
.frame(height: 36)
.background(selected ? .white : .clear, in: RoundedRectangle(cornerRadius: 6))
}
.buttonStyle(.plain)
}
private func contextPill(icon: String, title: String, value: String) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Image(systemName: icon)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 28, height: 28)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
Text(value)
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.75)
}
Spacer(minLength: 0)
}
.padding(AppMetrics.Spacing.xSmall)
.frame(maxWidth: .infinity)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
}
private func filterLabel(title: String, icon: String) -> some View {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(Color(hex: 0x4B5563))
.lineLimit(1)
.minimumScaleFactor(0.75)
Spacer(minLength: AppMetrics.Spacing.xSmall)
Image(systemName: icon)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(Color(hex: 0x4B5563))
}
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(maxWidth: .infinity)
.frame(height: 38)
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
}
private func storeOrderCard(_ item: OrderEntity) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
Text("订单号:\(item.orderNumber)")
.font(.system(size: AppMetrics.FontSize.title3))
.foregroundStyle(.black)
.lineLimit(1)
.minimumScaleFactor(0.72)
Spacer()
statusBadge(item.orderStatusName, color: statusColor(for: item.orderStatus))
}
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
if !item.orderTypeLabel.isEmpty {
Text(item.orderTypeLabel)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.xSmall)
.padding(.vertical, AppMetrics.Spacing.xxSmall)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4))
}
orderInfoRow("付款金额", "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
orderInfoRow("付款时间", item.payTime.isEmpty ? "--" : item.payTime)
orderInfoRow("完成时间", item.completeTime.isEmpty ? "--" : item.completeTime)
orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName)
orderInfoRow("手机号", maskPhoneNumber(item.phone))
orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName)
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
.contentShape(Rectangle())
.onTapGesture {
router.navigate(to: .placeholder(title: "订单详情"))
}
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
}
private func writeOffOrderCard(_ item: WriteOffOrderItem) -> some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
Text("订单号:\(item.orderNumber)")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(.black)
.lineLimit(1)
Spacer()
statusBadge(displayVerificationStatus(for: item), color: writeOffStatusColor(for: item))
}
Text(item.payTime.isEmpty ? "--" : item.payTime)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
orderInfoRow("项目名称", item.projectName.isEmpty ? "--" : item.projectName)
orderInfoRow("手机号", item.userPhone.isEmpty ? "--" : item.userPhone)
orderInfoRow("订单状态", item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
HStack {
Text("¥\(item.orderAmount)")
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
.foregroundStyle(AppDesign.primary)
Spacer()
Button("详情") {
router.navigate(to: .placeholder(title: "核销订单详情"))
}
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 32)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
.buttonStyle(.plain)
.accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)")
Button(verifyButtonTitle(for: item)) {
pendingVerifyOrderNumber = item.orderNumber
showVerifyConfirmation = true
}
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 32)
.background(canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8))
.buttonStyle(.plain)
.disabled(!canVerify(item) || viewModel.isVerifying)
.accessibilityIdentifier("orders.writeoff.verify.\(item.orderNumber)")
}
}
.padding(AppMetrics.Spacing.medium)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 8))
}
private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(Color(hex: 0x4B5563))
.frame(width: 72, alignment: .leading)
Spacer(minLength: AppMetrics.Spacing.xSmall)
Text(value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(valueColor)
.multilineTextAlignment(.trailing)
.lineLimit(2)
.minimumScaleFactor(0.75)
}
}
private func statusBadge(_ text: String, color: Color) -> some View {
Text(text.isEmpty ? "--" : text)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(color)
.padding(.horizontal, AppMetrics.Spacing.xSmall)
.padding(.vertical, AppMetrics.Spacing.xxSmall)
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
}
private func switchEntry(_ entry: OrdersEntry) {
guard viewModel.selectedEntry != entry else { return }
viewModel.selectedEntry = entry
appRouter.selectOrders(entry: entry)
Task { await reload() }
}
private func reload(showLoading: Bool = true) async {
do {
try await viewModel.reload(
api: ordersAPI,
scenicId: currentScenicId,
roleId: currentRoleId,
showLoading: showLoading
)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func loadMoreStoreOrders() async {
do {
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func loadMoreWriteOffOrders() async {
do {
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func triggerManualVerify() {
let orderNumber = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard !orderNumber.isEmpty else {
toastCenter.show("请输入订单号")
return
}
pendingVerifyOrderNumber = orderNumber
showVerifyConfirmation = true
}
private func verify(orderNumber: String) async {
guard let scenicId = currentScenicId else { return }
do {
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber)
manualOrderNumber = ""
pendingVerifyOrderNumber = nil
toastCenter.show("核销成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func canVerify(_ item: WriteOffOrderItem) -> Bool {
let status = displayVerificationStatus(for: item)
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
}
private func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? "待核销" : text
}
private func verifyButtonTitle(for item: WriteOffOrderItem) -> String {
canVerify(item) ? "核销" : "已处理"
}
private func writeOffStatusColor(for item: WriteOffOrderItem) -> Color {
canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E)
}
private func statusColor(for status: Int) -> Color {
switch status {
case 18:
return AppDesign.primary
case 30:
return Color(hex: 0x22C55E)
case 50:
return Color(hex: 0xEF4444)
default:
return Color(hex: 0x6B7280)
}
}
private func maskPhoneNumber(_ phone: String) -> String {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed }
let prefix = trimmed.prefix(3)
let suffix = trimmed.suffix(4)
return "\(prefix)****\(suffix)"
}
private static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}
/// Sheet
private struct OrdersDateFilterSheet: View {
@Environment(\.dismiss) private var dismiss
@Binding var startDate: Date?
@Binding var endDate: Date?
let onApply: () -> Void
@State private var draftStartDate = Date()
@State private var draftEndDate = Date()
var body: some View {
NavigationStack {
Form {
DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date)
DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date)
}
.navigationTitle("时间筛选")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("清空") {
startDate = nil
endDate = nil
onApply()
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("确定") {
startDate = min(draftStartDate, draftEndDate)
endDate = max(draftStartDate, draftEndDate)
onApply()
dismiss()
}
}
}
.onAppear {
draftStartDate = startDate ?? Date()
draftEndDate = endDate ?? Date()
}
}
}
}
#Preview {
NavigationStack {
OrdersView()
.environment(AccountContext())
.environment(PermissionContext())
.environment(AppRouter())
.environment(RouterPath())
.environment(OrdersAPI(client: APIClient()))
.environment(ToastCenter())
}
}