同步已迁移的 iOS 模块

This commit is contained in:
2026-06-22 15:35:12 +08:00
parent 0a0d4fbd79
commit 08465fffde
48 changed files with 4455 additions and 15 deletions

View File

@ -0,0 +1,622 @@
//
// 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())
}
}