新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
101
suixinkan/Features/Orders/Views/DateRangePickerSheet.swift
Normal file
101
suixinkan/Features/Orders/Views/DateRangePickerSheet.swift
Normal file
@ -0,0 +1,101 @@
|
||||
//
|
||||
// DateRangePickerSheet.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 日期范围选择 Sheet,对齐 Android DateRangePickerDialog。
|
||||
struct DateRangePickerSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let initialStartDate: Date?
|
||||
let initialEndDate: Date?
|
||||
let onConfirm: (Date?, Date?) -> Void
|
||||
|
||||
@State private var draftStartDate = Date()
|
||||
@State private var draftEndDate = Date()
|
||||
@State private var hasStartDate = false
|
||||
@State private var hasEndDate = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Toggle("设置开始日期", isOn: $hasStartDate)
|
||||
if hasStartDate {
|
||||
DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date)
|
||||
}
|
||||
Toggle("设置结束日期", isOn: $hasEndDate)
|
||||
if hasEndDate {
|
||||
DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date)
|
||||
}
|
||||
}
|
||||
.navigationTitle("时间筛选")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("清空") {
|
||||
onConfirm(nil, nil)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("确定") {
|
||||
let start = hasStartDate ? draftStartDate : nil
|
||||
let end = hasEndDate ? draftEndDate : nil
|
||||
if let start, let end {
|
||||
onConfirm(min(start, end), max(start, end))
|
||||
} else {
|
||||
onConfirm(start, end)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let initialStartDate {
|
||||
draftStartDate = initialStartDate
|
||||
hasStartDate = true
|
||||
}
|
||||
if let initialEndDate {
|
||||
draftEndDate = initialEndDate
|
||||
hasEndDate = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单列表日期范围展示文案工具。
|
||||
enum OrderDateRangeFormatter {
|
||||
private static let formatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
/// 格式化日期范围展示文案。
|
||||
static func displayText(start: Date?, end: Date?, placeholder: String = "时间筛选") -> String {
|
||||
let startText = start.map { formatter.string(from: $0) } ?? ""
|
||||
let endText = end.map { formatter.string(from: $0) } ?? ""
|
||||
switch (startText.isEmpty, endText.isEmpty) {
|
||||
case (true, true):
|
||||
return placeholder
|
||||
case (false, false):
|
||||
return "\(startText) - \(endText)"
|
||||
case (false, true):
|
||||
return "\(startText) - "
|
||||
case (true, false):
|
||||
return " - \(endText)"
|
||||
}
|
||||
}
|
||||
|
||||
/// 将日期格式化为 API 查询参数。
|
||||
static func queryValue(from date: Date?) -> String? {
|
||||
guard let date else { return nil }
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
207
suixinkan/Features/Orders/Views/OrderDialogViews.swift
Normal file
207
suixinkan/Features/Orders/Views/OrderDialogViews.swift
Normal file
@ -0,0 +1,207 @@
|
||||
//
|
||||
// OrderDialogViews.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 普通订单退款弹窗,对齐 Android OrderRefundDialog。
|
||||
struct OrderRefundDialogView: View {
|
||||
@ObservedObject var viewModel: OrderRefundViewModel
|
||||
let order: OrderEntity
|
||||
let onDismiss: () -> Void
|
||||
let onSuccess: () -> Void
|
||||
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("订单号") {
|
||||
Text(order.orderNumber)
|
||||
}
|
||||
|
||||
Section("退款方式") {
|
||||
Picker("退款方式", selection: $viewModel.mode) {
|
||||
ForEach(OrderRefundMode.allCases) { mode in
|
||||
Text(mode.title).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
if viewModel.mode == .partial {
|
||||
TextField("退款金额", text: $viewModel.amount)
|
||||
.keyboardType(.decimalPad)
|
||||
} else {
|
||||
Text("可退金额:¥\(viewModel.availableAmountText(for: order))")
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("退款原因") {
|
||||
TextField("请输入退款原因", text: $viewModel.reason, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
|
||||
if let errorMessage = viewModel.errorMessage {
|
||||
Section {
|
||||
Text(errorMessage)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("订单退款")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消", action: onDismiss)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(viewModel.submitting ? "提交中" : "确认") {
|
||||
Task {
|
||||
let success = await viewModel.submit(api: ordersAPI, item: order)
|
||||
if success {
|
||||
onSuccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.submitting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 赠送修图/视频弹窗,对齐 Android SendGiftDialog。
|
||||
struct SendGiftDialogView: View {
|
||||
@State private var photoNum: Int
|
||||
@State private var videoNum: Int
|
||||
let context: OrdersViewModel.SendGiftContext
|
||||
let onDismiss: () -> Void
|
||||
let onConfirm: (Int, Int) -> Void
|
||||
|
||||
init(context: OrdersViewModel.SendGiftContext, onDismiss: @escaping () -> Void, onConfirm: @escaping (Int, Int) -> Void) {
|
||||
self.context = context
|
||||
self.onDismiss = onDismiss
|
||||
self.onConfirm = onConfirm
|
||||
_photoNum = State(initialValue: context.sendPhotoNum)
|
||||
_videoNum = State(initialValue: context.sendVideoNum)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
stepperRow(
|
||||
title: "修图张数(已有\(context.nowPhotoNum)张,最多赠20张)",
|
||||
value: $photoNum,
|
||||
range: 0...20
|
||||
)
|
||||
stepperRow(
|
||||
title: "视频剪辑(已有\(context.nowVideoNum)条,最多赠5条)",
|
||||
value: $videoNum,
|
||||
range: 0...5
|
||||
)
|
||||
}
|
||||
}
|
||||
.navigationTitle("赠送修图/视频")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消", action: onDismiss)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("确认") {
|
||||
onConfirm(photoNum, videoNum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stepperRow(title: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
Spacer()
|
||||
Button("-") { value.wrappedValue = max(range.lowerBound, value.wrappedValue - 1) }
|
||||
.buttonStyle(.plain)
|
||||
Text("\(value.wrappedValue)")
|
||||
.frame(minWidth: 30)
|
||||
Button("+") { value.wrappedValue = min(range.upperBound, value.wrappedValue + 1) }
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 店长订单退款弹窗,支持全额/部分退款。
|
||||
struct StoreOrderRefundDialogView: View {
|
||||
let orderNumber: String
|
||||
let actualPayAmount: String
|
||||
let initialMode: OrderRefundMode
|
||||
let onDismiss: () -> Void
|
||||
let onConfirm: (OrderRefundMode, String, String) -> Void
|
||||
|
||||
@State private var mode: OrderRefundMode
|
||||
@State private var amount = ""
|
||||
@State private var reason = ""
|
||||
|
||||
init(
|
||||
orderNumber: String,
|
||||
actualPayAmount: String,
|
||||
initialMode: OrderRefundMode,
|
||||
onDismiss: @escaping () -> Void,
|
||||
onConfirm: @escaping (OrderRefundMode, String, String) -> Void
|
||||
) {
|
||||
self.orderNumber = orderNumber
|
||||
self.actualPayAmount = actualPayAmount
|
||||
self.initialMode = initialMode
|
||||
self.onDismiss = onDismiss
|
||||
self.onConfirm = onConfirm
|
||||
_mode = State(initialValue: initialMode)
|
||||
_amount = State(initialValue: actualPayAmount)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("订单号") {
|
||||
Text(orderNumber)
|
||||
}
|
||||
Section("退款方式") {
|
||||
Picker("退款方式", selection: $mode) {
|
||||
ForEach(OrderRefundMode.allCases) { item in
|
||||
Text(item.title).tag(item)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
if mode == .partial {
|
||||
TextField("退款金额", text: $amount)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
}
|
||||
Section("退款原因") {
|
||||
TextField("请输入退款原因", text: $reason, axis: .vertical)
|
||||
.lineLimit(3...6)
|
||||
}
|
||||
}
|
||||
.navigationTitle("申请退款")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消", action: onDismiss)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("提交") {
|
||||
let refundAmount = mode == .full ? actualPayAmount : amount
|
||||
onConfirm(mode, refundAmount, reason)
|
||||
}
|
||||
.disabled(reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
237
suixinkan/Features/Orders/Views/OrderListCardView.swift
Normal file
237
suixinkan/Features/Orders/Views/OrderListCardView.swift
Normal file
@ -0,0 +1,237 @@
|
||||
//
|
||||
// OrderListCardView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 主订单列表卡片,对齐 Android OrderView。
|
||||
struct OrderListCardView: View {
|
||||
let item: OrderEntity
|
||||
let hideOrderWriteActions: Bool
|
||||
let showOrderSource: Bool
|
||||
let sourceSelection: OrderSourceSelection?
|
||||
let orderSourcePeople: [OrderSourcePerson]
|
||||
let leadsForPerson: (OrderSourcePerson) -> [OrderSourceLead]
|
||||
let leadsLoading: Bool
|
||||
|
||||
let onRefund: () -> Void
|
||||
let onVerify: () -> Void
|
||||
let onUploadTask: () -> Void
|
||||
let onRefinedChange: (Int) -> Void
|
||||
let onSendGift: () -> Void
|
||||
let onCancelOrder: () -> Void
|
||||
let onHistoricalPhoto: () -> Void
|
||||
let onCallPhone: () -> Void
|
||||
let onOrderSourceSheetOpen: () -> Void
|
||||
let onOrderSourcePersonClick: (OrderSourcePerson) -> Void
|
||||
let onOrderSourceSelectionChange: (OrderSourceSelection) -> Void
|
||||
let callSourcePhone: (String) -> Void
|
||||
|
||||
private var showRefundButton: Bool {
|
||||
!hideOrderWriteActions &&
|
||||
(item.orderStatus == 18 || item.orderStatus == 30) &&
|
||||
item.orderType != 19
|
||||
}
|
||||
|
||||
private var showVerifyButton: Bool {
|
||||
!hideOrderWriteActions && (
|
||||
(item.photoTravel?.needCheckIn == true && item.orderStatus != 30) ||
|
||||
item.orderType == 19
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
HStack(alignment: .center) {
|
||||
Text("订单号:\(item.orderNumber)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.black)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Spacer()
|
||||
OrderStatusChip(text: item.orderStatusName, status: item.orderStatus)
|
||||
}
|
||||
|
||||
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
.padding(.top, 4)
|
||||
|
||||
if !item.orderTypeLabel.isEmpty {
|
||||
Text(item.orderTypeLabel)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4))
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
if showOrderSource {
|
||||
OrderSourcePicker(
|
||||
selection: sourceSelection,
|
||||
people: orderSourcePeople,
|
||||
leadsForPerson: leadsForPerson,
|
||||
leadsLoading: leadsLoading,
|
||||
onSheetOpen: onOrderSourceSheetOpen,
|
||||
onPersonClick: onOrderSourcePersonClick,
|
||||
onSelectionChange: onOrderSourceSelectionChange,
|
||||
callPhone: callSourcePhone
|
||||
)
|
||||
.padding(.top, 10)
|
||||
}
|
||||
|
||||
if showRefundButton || showVerifyButton {
|
||||
HStack(spacing: 12) {
|
||||
if showRefundButton {
|
||||
OrderActionButton(
|
||||
title: "退款",
|
||||
textColor: AppDesign.orderRefundText,
|
||||
backgroundColor: AppDesign.orderRefundBackground,
|
||||
maxWidth: showVerifyButton ? nil : .infinity,
|
||||
action: onRefund
|
||||
)
|
||||
.frame(maxWidth: showVerifyButton ? .infinity : nil)
|
||||
}
|
||||
if showVerifyButton {
|
||||
OrderActionButton(title: "核销订单", action: onVerify)
|
||||
}
|
||||
}
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
if !hideOrderWriteActions && item.orderType == 19 {
|
||||
Button(action: onUploadTask) {
|
||||
Text("上传任务")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 48)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
if item.orderStatus == 18 || item.orderStatus == 30 {
|
||||
OrderRefinedChoiceSection(
|
||||
currentSelection: item.isRefined,
|
||||
readOnly: hideOrderWriteActions,
|
||||
onSelectionChange: onRefinedChange
|
||||
)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
if item.orderStatus == 50,
|
||||
!item.actualRefundAmount.isEmpty,
|
||||
item.actualRefundAmount != "0" {
|
||||
OrderInfoRow(title: "退款金额", value: "¥\(item.actualRefundAmount)", valueColor: AppDesign.primary)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
VStack(spacing: 8) {
|
||||
OrderInfoRow(title: "付款金额", value: "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
|
||||
OrderInfoRow(
|
||||
title: "付款时间",
|
||||
value: OrderListUI.displayPayTime(payTime: item.payTime, depositPayTime: item.depositPayTime)
|
||||
)
|
||||
OrderInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime)
|
||||
OrderInfoRow(title: "付款方式", value: item.payTypeName.isEmpty ? "--" : item.payTypeName)
|
||||
OrderInfoRow(title: "用户 UID", value: "\(item.userId)")
|
||||
OrderInfoRow(title: "手机号", value: OrderListUI.maskPhone(item.phone)) {
|
||||
HStack(spacing: 7) {
|
||||
Text(OrderListUI.maskPhone(item.phone))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.black)
|
||||
if !hideOrderWriteActions {
|
||||
OrderPhoneCallButton(action: onCallPhone)
|
||||
}
|
||||
}
|
||||
}
|
||||
OrderInfoRow(title: "关联项目", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||||
|
||||
if let photoTravel = item.photoTravel, !photoTravel.checkInTime.isEmpty {
|
||||
OrderInfoRow(title: "核销时间", value: photoTravel.checkInTime)
|
||||
}
|
||||
|
||||
if let photoTravel = item.photoTravel,
|
||||
item.orderType != 19,
|
||||
item.orderType != 14 {
|
||||
OrderGiftStepperRow(
|
||||
title: "修图张数",
|
||||
valueText: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum) 张",
|
||||
editable: photoTravel.canGiftRetouch && !hideOrderWriteActions,
|
||||
onTap: onSendGift
|
||||
)
|
||||
OrderGiftStepperRow(
|
||||
title: "视频剪辑",
|
||||
valueText: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum) 条",
|
||||
editable: photoTravel.canGiftRetouch && !hideOrderWriteActions,
|
||||
onTap: onSendGift
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.top, 13)
|
||||
|
||||
if !item.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
Divider().overlay(AppDesign.orderInputBackground).padding(.top, 14)
|
||||
Text("备注信息:")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
.padding(.top, 10)
|
||||
Text(item.remark)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
|
||||
if let materials = item.multiTravel?.materialList, !materials.isEmpty {
|
||||
Divider().overlay(AppDesign.orderInputBackground).padding(.top, 12)
|
||||
Text("历史拍摄信息:")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
.padding(.top, 12)
|
||||
|
||||
Button(action: onHistoricalPhoto) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(Array(materials.prefix(3).enumerated()), id: \.offset) { index, material in
|
||||
ZStack {
|
||||
RemoteImage(
|
||||
urlString: material.previewURLString,
|
||||
contentMode: .fill
|
||||
) {
|
||||
Color(hex: 0xF4F4F4)
|
||||
}
|
||||
.aspectRatio(1.59, contentMode: .fill)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
if materials.count > 3 && index == 2 {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.black.opacity(0.5))
|
||||
Text("查看更多")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
if item.photoTravel?.canCancelOrder == true && !hideOrderWriteActions {
|
||||
OrderActionButton(title: "取消订单", action: onCancelOrder)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
||||
}
|
||||
}
|
||||
276
suixinkan/Features/Orders/Views/OrderListComponents.swift
Normal file
276
suixinkan/Features/Orders/Views/OrderListComponents.swift
Normal file
@ -0,0 +1,276 @@
|
||||
//
|
||||
// OrderListComponents.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 订单列表 UI 工具,集中状态色与通用展示逻辑。
|
||||
enum OrderListUI {
|
||||
/// 返回订单状态对应的文字色和背景色。
|
||||
static func statusColors(for status: Int) -> (text: Color, background: Color) {
|
||||
switch status {
|
||||
case 18:
|
||||
return (AppDesign.orderStatusPaidText, AppDesign.orderStatusPaidBackground)
|
||||
case 30:
|
||||
return (AppDesign.orderStatusCompletedText, AppDesign.orderStatusCompletedBackground)
|
||||
case 50:
|
||||
return (AppDesign.orderStatusRefundedText, AppDesign.orderStatusRefundedBackground)
|
||||
default:
|
||||
return (AppDesign.orderStatusOtherText, AppDesign.orderStatusOtherBackground)
|
||||
}
|
||||
}
|
||||
|
||||
/// 掩码手机号,保留前三后四。
|
||||
static func maskPhone(_ phone: String) -> String {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed }
|
||||
return "\(trimmed.prefix(3))****\(trimmed.suffix(4))"
|
||||
}
|
||||
|
||||
/// 付款时间展示,兼容 depositPayTime 兜底。
|
||||
static func displayPayTime(payTime: String, depositPayTime: String) -> String {
|
||||
if payTime.isEmpty {
|
||||
return depositPayTime.isEmpty ? "--" : depositPayTime
|
||||
}
|
||||
if payTime.hasPrefix("1970") || payTime == "0" {
|
||||
return depositPayTime.isEmpty ? payTime : depositPayTime
|
||||
}
|
||||
return payTime
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单状态 Chip,对齐 Android OrderView 状态标签。
|
||||
struct OrderStatusChip: View {
|
||||
let text: String
|
||||
let status: Int
|
||||
|
||||
var body: some View {
|
||||
let colors = OrderListUI.statusColors(for: status)
|
||||
Text(text.isEmpty ? "--" : text)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(colors.text)
|
||||
.padding(.horizontal, 4)
|
||||
.padding(.vertical, 2)
|
||||
.background(colors.background, in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单卡片信息行,标签在左、值在右。
|
||||
struct OrderInfoRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
var valueColor: Color = .black
|
||||
var trailing: AnyView?
|
||||
|
||||
init(title: String, value: String, valueColor: Color = .black, @ViewBuilder trailing: () -> some View = { EmptyView() }) {
|
||||
self.title = title
|
||||
self.value = value
|
||||
self.valueColor = valueColor
|
||||
let built = trailing()
|
||||
self.trailing = built is EmptyView ? nil : AnyView(built)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text(title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
Spacer(minLength: 8)
|
||||
if let trailing {
|
||||
trailing
|
||||
} else {
|
||||
Text(value)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(valueColor)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单卡片操作按钮,对齐 Android AppButton 样式。
|
||||
struct OrderActionButton: View {
|
||||
let title: String
|
||||
var textColor: Color = .white
|
||||
var backgroundColor: Color = AppDesign.primary
|
||||
var maxWidth: CGFloat? = .infinity
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(textColor)
|
||||
.frame(maxWidth: maxWidth)
|
||||
.frame(height: 48)
|
||||
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单卡片拨号按钮。
|
||||
struct OrderPhoneCallButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "phone.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 24, height: 24)
|
||||
.background(AppDesign.primarySoft, in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要剪辑修图单选区,对齐 Android ExclusiveChoiceButtons。
|
||||
struct OrderRefinedChoiceSection: View {
|
||||
let currentSelection: Int
|
||||
let readOnly: Bool
|
||||
let onSelectionChange: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("需要剪辑修图")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
Spacer()
|
||||
if readOnly {
|
||||
Text(refinedDisplayText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.black)
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
refinedChoiceButton(title: "是", value: 1)
|
||||
refinedChoiceButton(title: "否", value: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var refinedDisplayText: String {
|
||||
switch currentSelection {
|
||||
case 1: return "是"
|
||||
case 2: return "否"
|
||||
default: return "--"
|
||||
}
|
||||
}
|
||||
|
||||
private func refinedChoiceButton(title: String, value: Int) -> some View {
|
||||
let selected = currentSelection == value
|
||||
return Button {
|
||||
onSelectionChange(value)
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(AppDesign.primary, lineWidth: selected ? 0 : 1.5)
|
||||
.background(selected ? Circle().fill(AppDesign.primary) : nil)
|
||||
.frame(width: 20, height: 20)
|
||||
if selected {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
Text(title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 修图/视频数量赠送步进控件。
|
||||
struct OrderGiftStepperRow: View {
|
||||
let title: String
|
||||
let valueText: String
|
||||
let editable: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
Spacer()
|
||||
if editable {
|
||||
HStack(spacing: 8) {
|
||||
giftStepButton(symbol: "-", action: onTap)
|
||||
Text(valueText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.black)
|
||||
giftStepButton(symbol: "+", action: onTap)
|
||||
}
|
||||
} else {
|
||||
Text(valueText)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.black)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func giftStepButton(symbol: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(symbol)
|
||||
.font(.system(size: 16, weight: .bold))
|
||||
.foregroundStyle(Color(hex: 0xE5E7EB))
|
||||
.frame(width: 24, height: 24)
|
||||
.overlay(Circle().stroke(Color(hex: 0xE5E7EB), lineWidth: 1))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销管理页横向 pill Tab。
|
||||
struct WriteOffStatusTabBar: View {
|
||||
let filters: [OrderStatusFilter]
|
||||
let selectedStatus: Int
|
||||
let onSelect: (Int) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(filters) { filter in
|
||||
let selected = selectedStatus == filter.id
|
||||
Button {
|
||||
onSelect(filter.id)
|
||||
} label: {
|
||||
Text(filter.title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(selected ? .white : AppDesign.orderLabelColor)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 32)
|
||||
.background(
|
||||
selected ? AppDesign.primary : Color(hex: 0xF3F4F6),
|
||||
in: RoundedRectangle(cornerRadius: 16)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单列表 `.searchable` 搜索行为,区分「输入中」与「已提交查询」,清空时重新拉全量。
|
||||
enum OrderListSearchBehavior {
|
||||
/// 归一化搜索文本。
|
||||
static func normalized(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
/// 清空搜索框后是否需要重新请求(此前已提交过非空查询)。
|
||||
static func shouldReloadAfterClear(newText: String, appliedQuery: String) -> Bool {
|
||||
normalized(newText).isEmpty && !appliedQuery.isEmpty
|
||||
}
|
||||
}
|
||||
71
suixinkan/Features/Orders/Views/OrderListHeaderView.swift
Normal file
71
suixinkan/Features/Orders/Views/OrderListHeaderView.swift
Normal file
@ -0,0 +1,71 @@
|
||||
//
|
||||
// OrderListHeaderView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 订单列表筛选头部,展示状态与时间筛选;手机号搜索由页面级 `.searchable` 提供。
|
||||
struct OrderListHeaderView: View {
|
||||
let filterTitle: String
|
||||
let timeFilterTitle: String
|
||||
let statusFilters: [OrderStatusFilter]
|
||||
let selectedStatus: Int
|
||||
let onStatusSelected: (Int) -> Void
|
||||
let onTimeFilterTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Menu {
|
||||
ForEach(statusFilters) { filter in
|
||||
Button(filter.title) {
|
||||
onStatusSelected(filter.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
OrderListFilterPill(title: filterTitle, showsChevron: true)
|
||||
}
|
||||
|
||||
Button(action: onTimeFilterTap) {
|
||||
OrderListFilterPill(title: timeFilterTitle, showsCalendar: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单列表筛选 pill 按钮样式。
|
||||
private struct OrderListFilterPill: View {
|
||||
let title: String
|
||||
var showsChevron = false
|
||||
var showsCalendar = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Text(title)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Spacer(minLength: 4)
|
||||
if showsChevron {
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
}
|
||||
if showsCalendar {
|
||||
Image(systemName: "calendar")
|
||||
.font(.system(size: 15))
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 35)
|
||||
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
262
suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift
Normal file
262
suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift
Normal file
@ -0,0 +1,262 @@
|
||||
//
|
||||
// StoreAdminOrderListView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 店长角色订单 Tab 列表,对齐 Android DepositOrderListScreen。
|
||||
struct StoreAdminOrderListView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@StateObject private var viewModel = StoreAdminOrderListViewModel()
|
||||
|
||||
@State private var showDateFilterSheet = false
|
||||
@State private var refundOrder: StoreOrderListItem?
|
||||
@State private var refundMode: OrderRefundMode = .full
|
||||
@State private var showScanner = false
|
||||
@State private var appliedSearchQuery = ""
|
||||
|
||||
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if currentStoreId == nil {
|
||||
AppContentUnavailableView(
|
||||
"缺少门店上下文",
|
||||
systemImage: "storefront",
|
||||
description: Text("请先选择门店后查看订单。")
|
||||
)
|
||||
} else {
|
||||
listContent
|
||||
}
|
||||
}
|
||||
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||||
.navigationTitle("订单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $viewModel.searchPhone, prompt: "输入游客手机号")
|
||||
.onSubmit(of: .search) {
|
||||
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: viewModel.searchPhone) { newValue in
|
||||
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||||
appliedSearchQuery = ""
|
||||
Task { await reload() }
|
||||
}
|
||||
.task { await reload() }
|
||||
.onChange(of: accountContext.currentStore?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.sheet(isPresented: $showDateFilterSheet) {
|
||||
DateRangePickerSheet(
|
||||
initialStartDate: viewModel.filterStartDate,
|
||||
initialEndDate: viewModel.filterEndDate
|
||||
) { start, end in
|
||||
viewModel.filterStartDate = start
|
||||
viewModel.filterEndDate = end
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
.sheet(item: $refundOrder) { order in
|
||||
StoreOrderRefundDialogView(
|
||||
orderNumber: order.orderNumber,
|
||||
actualPayAmount: order.actualPayAmount,
|
||||
initialMode: refundMode,
|
||||
onDismiss: { refundOrder = nil },
|
||||
onConfirm: { mode, amount, reason in
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.refund(
|
||||
api: ordersAPI,
|
||||
storeId: currentStoreId,
|
||||
orderNumber: order.orderNumber,
|
||||
mode: mode,
|
||||
amount: amount,
|
||||
reason: reason
|
||||
)
|
||||
refundOrder = nil
|
||||
toastCenter.show("退款申请已提交")
|
||||
await reload(showLoading: false)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showScanner) {
|
||||
OrderScannerPage(
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { _ in
|
||||
showScanner = false
|
||||
toastCenter.show("核销成功")
|
||||
Task { await reload(showLoading: false) }
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var listContent: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
LazyVStack(spacing: 12) {
|
||||
if viewModel.loading && viewModel.orders.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||||
} else if viewModel.orders.isEmpty {
|
||||
AppContentUnavailableView("暂无订单", systemImage: "tray")
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.orders) { item in
|
||||
StoreAdminOrderCardView(
|
||||
item: item,
|
||||
onTap: { handleOrderTap(item) },
|
||||
onRefund: {
|
||||
refundMode = .full
|
||||
refundOrder = item
|
||||
},
|
||||
onPartialRefund: {
|
||||
refundMode = .partial
|
||||
refundOrder = item
|
||||
},
|
||||
onVerify: { showScanner = true },
|
||||
onCallPhone: { dialPhone(item.phone) }
|
||||
)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.orders.last?.id else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
if viewModel.loadingMore {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
} header: {
|
||||
OrderListHeaderView(
|
||||
filterTitle: viewModel.currentFilterTitle,
|
||||
timeFilterTitle: OrderDateRangeFormatter.displayText(
|
||||
start: viewModel.filterStartDate,
|
||||
end: viewModel.filterEndDate
|
||||
),
|
||||
statusFilters: OrderFilters.storeAdminStatusFilters,
|
||||
selectedStatus: viewModel.selectedStatus,
|
||||
onStatusSelected: { status in
|
||||
viewModel.selectedStatus = status
|
||||
Task { await reload() }
|
||||
},
|
||||
onTimeFilterTap: { showDateFilterSheet = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
}
|
||||
|
||||
private func handleOrderTap(_ item: StoreOrderListItem) {
|
||||
if item.orderType == StoreOrderType.multiTravelShoot {
|
||||
router.navigate(to: .orders(.depositDetail(orderNumber: item.orderNumber)))
|
||||
} else {
|
||||
toastCenter.show("该订单不可查看详情")
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
|
||||
try await viewModel.reload(api: ordersAPI, storeId: currentStoreId, showLoading: showLoading)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(api: ordersAPI, storeId: currentStoreId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func dialPhone(_ phone: String) {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// 店长订单卡片,对齐 Android DepositOrderCard。
|
||||
private struct StoreAdminOrderCardView: View {
|
||||
let item: StoreOrderListItem
|
||||
let onTap: () -> Void
|
||||
let onRefund: () -> Void
|
||||
let onPartialRefund: () -> Void
|
||||
let onVerify: () -> Void
|
||||
let onCallPhone: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Button(action: onTap) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("订单号:\(item.orderNumber)")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.black)
|
||||
Spacer()
|
||||
OrderStatusChip(text: item.orderStatusName, status: item.orderStatus)
|
||||
}
|
||||
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||||
OrderInfoRow(title: "订单金额", value: "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
|
||||
OrderInfoRow(title: "付款时间", value: item.payTime.isEmpty ? "--" : item.payTime)
|
||||
OrderInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime)
|
||||
OrderInfoRow(title: "用户 UID", value: "\(item.userId)")
|
||||
OrderInfoRow(title: "手机号", value: OrderListUI.maskPhone(item.phone)) {
|
||||
HStack(spacing: 7) {
|
||||
Text(OrderListUI.maskPhone(item.phone))
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
OrderPhoneCallButton(action: onCallPhone)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
OrderActionButton(
|
||||
title: "退款",
|
||||
textColor: AppDesign.orderRefundText,
|
||||
backgroundColor: AppDesign.orderRefundBackground,
|
||||
action: onRefund
|
||||
)
|
||||
OrderActionButton(
|
||||
title: "部分退款",
|
||||
textColor: AppDesign.orderLabelColor,
|
||||
backgroundColor: .white,
|
||||
action: onPartialRefund
|
||||
)
|
||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppDesign.orderBorderLight, lineWidth: 1))
|
||||
}
|
||||
|
||||
if item.orderType == StoreOrderType.offlineProduct {
|
||||
OrderActionButton(title: "核销", action: onVerify)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
382
suixinkan/Features/Orders/Views/StoreOrderListView.swift
Normal file
382
suixinkan/Features/Orders/Views/StoreOrderListView.swift
Normal file
@ -0,0 +1,382 @@
|
||||
//
|
||||
// StoreOrderListView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 摄影师/非店长角色的订单 Tab 主列表,对齐 Android OrderListScreen。
|
||||
struct StoreOrderListView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var permissionContext: PermissionContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@EnvironmentObject private var router: RouterPath
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
@StateObject private var viewModel = OrdersViewModel()
|
||||
@StateObject private var refundViewModel = OrderRefundViewModel()
|
||||
|
||||
@State private var showDateFilterSheet = false
|
||||
@State private var showScanner = false
|
||||
@State private var pendingVerifyOrderNumber: String?
|
||||
@State private var showVerifyConfirmation = false
|
||||
@State private var bindAcquirerSaleUserId: Int?
|
||||
@State private var referralBindOrderNumber: String?
|
||||
@State private var appliedSearchQuery = ""
|
||||
|
||||
private var currentScenicId: Int? { accountContext.currentScenic?.id }
|
||||
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||||
private var currentAppRole: AppRoleCode? { permissionContext.currentAppRole }
|
||||
private var hideOrderWriteActions: Bool { OrdersViewModel.isScenicAdminReadOnly(appRole: currentAppRole) }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if currentScenicId == nil {
|
||||
AppContentUnavailableView(
|
||||
"缺少经营上下文",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("请先在首页选择景区后查看订单。")
|
||||
)
|
||||
} else {
|
||||
orderListContent
|
||||
}
|
||||
}
|
||||
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||||
.navigationTitle("订单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $viewModel.searchPhone, prompt: "输入手机号搜索")
|
||||
.onSubmit(of: .search) {
|
||||
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: viewModel.searchPhone) { newValue in
|
||||
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||||
appliedSearchQuery = ""
|
||||
Task { await reload() }
|
||||
}
|
||||
.task { await reload() }
|
||||
.onChange(of: accountContext.currentScenic?.id) { _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.sheet(isPresented: $showDateFilterSheet) {
|
||||
DateRangePickerSheet(
|
||||
initialStartDate: viewModel.filterStartDate,
|
||||
initialEndDate: viewModel.filterEndDate
|
||||
) { start, end in
|
||||
viewModel.filterStartDate = start
|
||||
viewModel.filterEndDate = end
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
.sheet(item: refundOrderBinding) { order in
|
||||
OrderRefundDialogView(
|
||||
viewModel: refundViewModel,
|
||||
order: order,
|
||||
onDismiss: { viewModel.refundingOrder = nil },
|
||||
onSuccess: {
|
||||
viewModel.refundingOrder = nil
|
||||
toastCenter.show("退款申请已提交")
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(item: sendGiftBinding) { context in
|
||||
SendGiftDialogView(
|
||||
context: context,
|
||||
onDismiss: { viewModel.sendGiftContext = nil },
|
||||
onConfirm: { photo, video in
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.submitSendGift(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
appRole: currentAppRole,
|
||||
photoNum: photo,
|
||||
videoNum: video
|
||||
)
|
||||
toastCenter.show("赠送成功")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.fullScreenCover(isPresented: $showScanner) {
|
||||
OrderScannerPage(
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { rawCode in
|
||||
showScanner = false
|
||||
handleScanResult(rawCode)
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
)
|
||||
}
|
||||
.confirmationDialog("确认取消该订单?", isPresented: $showCancelConfirmation, titleVisibility: .visible) {
|
||||
Button("确认取消", role: .destructive) {
|
||||
guard let orderNumber = pendingCancelOrderNumber else { return }
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.cancelOrder(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
appRole: currentAppRole,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
pendingCancelOrderNumber = nil
|
||||
toastCenter.show("订单已取消")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("取消", role: .cancel) { pendingCancelOrderNumber = nil }
|
||||
}
|
||||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||
Button("确认核销") {
|
||||
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||||
Task { await verify(orderNumber: orderNumber) }
|
||||
}
|
||||
Button("取消", role: .cancel) { pendingVerifyOrderNumber = nil }
|
||||
}
|
||||
.fullScreenCover(item: bindAcquirerBinding) { item in
|
||||
NavigationStack {
|
||||
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
|
||||
bindAcquirerSaleUserId = nil
|
||||
})
|
||||
}
|
||||
}
|
||||
.appNavigationDestination(item: referralBindBinding) { item in
|
||||
ReferralOrderSelectView(orderNumber: item.orderNumber) {
|
||||
Task { await reload(showLoading: false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@State private var pendingCancelOrderNumber: String?
|
||||
@State private var showCancelConfirmation = false
|
||||
|
||||
private var orderListContent: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
LazyVStack(spacing: 12) {
|
||||
if viewModel.loading && viewModel.storeOrders.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||||
} else if viewModel.storeOrders.isEmpty {
|
||||
AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.storeOrders) { item in
|
||||
OrderListCardView(
|
||||
item: item,
|
||||
hideOrderWriteActions: hideOrderWriteActions,
|
||||
showOrderSource: viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole),
|
||||
sourceSelection: viewModel.getOrderSourceSelection(for: item),
|
||||
orderSourcePeople: viewModel.orderSourcePeople,
|
||||
leadsForPerson: { person in
|
||||
viewModel.orderSourceLeads[person.key] ?? []
|
||||
},
|
||||
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
|
||||
onRefund: {
|
||||
refundViewModel.begin(item: item)
|
||||
viewModel.refundingOrder = item
|
||||
},
|
||||
onVerify: {
|
||||
pendingVerifyOrderNumber = item.orderNumber
|
||||
showScanner = true
|
||||
},
|
||||
onUploadTask: {
|
||||
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: item.orderNumber)))
|
||||
},
|
||||
onRefinedChange: { value in
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.updateRefinedChoice(
|
||||
api: ordersAPI,
|
||||
orderNumber: item.orderNumber,
|
||||
isRefined: value
|
||||
)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
},
|
||||
onSendGift: { viewModel.prepareSendGift(for: item) },
|
||||
onCancelOrder: {
|
||||
pendingCancelOrderNumber = item.orderNumber
|
||||
showCancelConfirmation = true
|
||||
},
|
||||
onHistoricalPhoto: {
|
||||
router.navigate(to: .orders(.historicalShooting(orderNumber: item.orderNumber)))
|
||||
},
|
||||
onCallPhone: { dialPhone(item.phone) },
|
||||
onOrderSourceSheetOpen: {
|
||||
Task {
|
||||
await viewModel.prepareOrderSourceSheet(
|
||||
api: cooperationOrderAPI,
|
||||
storeId: currentStoreId
|
||||
)
|
||||
}
|
||||
},
|
||||
onOrderSourcePersonClick: { person in
|
||||
Task {
|
||||
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
|
||||
}
|
||||
},
|
||||
onOrderSourceSelectionChange: { selection in
|
||||
Task {
|
||||
do {
|
||||
try await viewModel.bindOrderSource(
|
||||
api: cooperationOrderAPI,
|
||||
orderNumber: item.orderNumber,
|
||||
selection: selection
|
||||
)
|
||||
toastCenter.show("绑定成功")
|
||||
await reload(showLoading: false)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
},
|
||||
callSourcePhone: dialPhone
|
||||
)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.storeOrders.last?.id else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
if viewModel.loadingMore {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 16)
|
||||
} header: {
|
||||
OrderListHeaderView(
|
||||
filterTitle: viewModel.currentFilterTitle,
|
||||
timeFilterTitle: OrderDateRangeFormatter.displayText(
|
||||
start: viewModel.filterStartDate,
|
||||
end: viewModel.filterEndDate
|
||||
),
|
||||
statusFilters: OrderFilters.statusFilters,
|
||||
selectedStatus: viewModel.selectedStatus,
|
||||
onStatusSelected: { status in
|
||||
viewModel.selectedStatus = status
|
||||
Task { await reload() }
|
||||
},
|
||||
onTimeFilterTap: { showDateFilterSheet = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
}
|
||||
|
||||
private var refundOrderBinding: Binding<OrderEntity?> {
|
||||
Binding(
|
||||
get: { viewModel.refundingOrder },
|
||||
set: { viewModel.refundingOrder = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var sendGiftBinding: Binding<OrdersViewModel.SendGiftContext?> {
|
||||
Binding(
|
||||
get: { viewModel.sendGiftContext },
|
||||
set: { viewModel.sendGiftContext = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
|
||||
Binding(
|
||||
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
|
||||
set: { bindAcquirerSaleUserId = $0?.saleUserId }
|
||||
)
|
||||
}
|
||||
|
||||
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
|
||||
Binding(
|
||||
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
|
||||
set: { referralBindOrderNumber = $0?.orderNumber }
|
||||
)
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await globalLoading.withOptionalLoading(showLoading && viewModel.storeOrders.isEmpty, message: "加载中...") {
|
||||
try await viewModel.reloadStoreOrders(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
appRole: currentAppRole,
|
||||
showLoading: showLoading
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMoreStoreOrders(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
appRole: currentAppRole
|
||||
)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
try await globalLoading.withLoading(message: "核销中...") {
|
||||
try await viewModel.verify(
|
||||
api: ordersAPI,
|
||||
scenicId: scenicId,
|
||||
storeId: currentStoreId,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
pendingVerifyOrderNumber = nil
|
||||
toastCenter.show("核销成功")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
|
||||
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
|
||||
toastCenter.show("请扫描正确的获客员二维码")
|
||||
return
|
||||
}
|
||||
bindAcquirerSaleUserId = saleUserId
|
||||
return
|
||||
}
|
||||
|
||||
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
|
||||
toastCenter.show("扫码成功,但未识别到有效订单号。")
|
||||
return
|
||||
}
|
||||
pendingVerifyOrderNumber = orderNumber
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
|
||||
private func dialPhone(_ phone: String) {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
309
suixinkan/Features/Orders/Views/WriteOffListView.swift
Normal file
309
suixinkan/Features/Orders/Views/WriteOffListView.swift
Normal file
@ -0,0 +1,309 @@
|
||||
//
|
||||
// WriteOffListView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/29.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 核销管理独立页,对齐 Android WriteOffListScreen。
|
||||
struct WriteOffListView: View {
|
||||
@EnvironmentObject private var accountContext: AccountContext
|
||||
@EnvironmentObject private var appRouter: AppRouter
|
||||
@Environment(\.ordersAPI) private var ordersAPI
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.globalLoading) private var globalLoading
|
||||
@Environment(\.openURL) private var openURL
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@StateObject private var viewModel = WriteOffListViewModel()
|
||||
|
||||
@State private var manualOrderNumber = ""
|
||||
@State private var showScanner = false
|
||||
@State private var showVerifyConfirmation = false
|
||||
@State private var pendingVerifyOrderNumber: String?
|
||||
@State private var matchedScannedOrderNumber: String?
|
||||
@State private var showStartDateSheet = false
|
||||
@State private var showEndDateSheet = false
|
||||
@State private var appliedSearchQuery = ""
|
||||
|
||||
private var currentScenicId: Int? { accountContext.currentScenic?.id }
|
||||
private var currentStoreId: Int? { accountContext.currentStore?.id }
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if currentScenicId == nil {
|
||||
AppContentUnavailableView("缺少经营上下文", systemImage: "mountain.2")
|
||||
} else {
|
||||
listContent
|
||||
}
|
||||
}
|
||||
.background(AppDesign.orderPageBackground.ignoresSafeArea())
|
||||
.navigationTitle("核销管理")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.searchable(text: $viewModel.searchPhone, prompt: "搜索手机号")
|
||||
.onSubmit(of: .search) {
|
||||
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: viewModel.searchPhone) { newValue in
|
||||
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
|
||||
appliedSearchQuery = ""
|
||||
Task { await reload() }
|
||||
}
|
||||
.task {
|
||||
await reload()
|
||||
await consumePendingScanCodeIfNeeded()
|
||||
}
|
||||
.onChange(of: appRouter.pendingOrderScanCode) { _ in
|
||||
Task { await consumePendingScanCodeIfNeeded() }
|
||||
}
|
||||
.fullScreenCover(isPresented: $showScanner) {
|
||||
OrderScannerPage(
|
||||
onClose: { showScanner = false },
|
||||
onSuccess: { rawCode in
|
||||
showScanner = false
|
||||
handleScanResult(rawCode)
|
||||
},
|
||||
onFailure: { error in
|
||||
showScanner = false
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
)
|
||||
}
|
||||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||
Button("确认核销") {
|
||||
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||||
Task { await verify(orderNumber: orderNumber) }
|
||||
}
|
||||
Button("取消", role: .cancel) {
|
||||
pendingVerifyOrderNumber = nil
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showStartDateSheet) {
|
||||
singleDateSheet(title: "开始时间", date: viewModel.filterStartDate) { date in
|
||||
viewModel.filterStartDate = date
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showEndDateSheet) {
|
||||
singleDateSheet(title: "结束时间", date: viewModel.filterEndDate) { date in
|
||||
viewModel.filterEndDate = date
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var listContent: some View {
|
||||
ScrollViewReader { scrollProxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 12) {
|
||||
WriteOffStatusTabBar(
|
||||
filters: OrderFilters.writeOffStatusFilters,
|
||||
selectedStatus: viewModel.selectedStatus,
|
||||
onSelect: { status in
|
||||
viewModel.selectedStatus = status
|
||||
Task { await reload() }
|
||||
}
|
||||
)
|
||||
dateFilterSection
|
||||
|
||||
if viewModel.loading && viewModel.orders.isEmpty {
|
||||
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
|
||||
} else if viewModel.orders.isEmpty {
|
||||
AppContentUnavailableView("暂无核销订单", systemImage: "tray")
|
||||
.frame(minHeight: 220)
|
||||
} else {
|
||||
ForEach(viewModel.orders) { item in
|
||||
writeOffCard(item)
|
||||
.id(item.orderNumber)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.orders.last?.id else { return }
|
||||
Task { await loadMore() }
|
||||
}
|
||||
}
|
||||
if viewModel.loadingMore {
|
||||
ProgressView().frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.onChange(of: matchedScannedOrderNumber) { orderNumber in
|
||||
guard let orderNumber else { return }
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
scrollProxy.scrollTo(orderNumber, anchor: .center)
|
||||
}
|
||||
}
|
||||
}
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
}
|
||||
|
||||
private var dateFilterSection: some View {
|
||||
HStack(spacing: 8) {
|
||||
dateFilterButton(title: "开始时间", date: viewModel.filterStartDate) {
|
||||
showStartDateSheet = true
|
||||
}
|
||||
dateFilterButton(title: "结束时间", date: viewModel.filterEndDate) {
|
||||
showEndDateSheet = true
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func dateFilterButton(title: String, date: Date?, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
Image(systemName: "calendar")
|
||||
Text(date.map { OrderDateRangeFormatter.displayText(start: $0, end: nil, placeholder: title) } ?? title)
|
||||
.font(.system(size: 13))
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(AppDesign.orderLabelColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 35)
|
||||
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func writeOffCard(_ item: WriteOffOrderItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("订单号:\(item.orderNumber)")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
Spacer()
|
||||
OrderStatusChip(
|
||||
text: viewModel.displayVerificationStatus(for: item),
|
||||
status: viewModel.canVerify(item) ? 18 : 30
|
||||
)
|
||||
}
|
||||
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
|
||||
OrderInfoRow(title: "手机号", value: item.userPhone.isEmpty ? "--" : item.userPhone) {
|
||||
HStack(spacing: 7) {
|
||||
Text(item.userPhone)
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
OrderPhoneCallButton {
|
||||
dialPhone(item.userPhone)
|
||||
}
|
||||
}
|
||||
}
|
||||
OrderInfoRow(title: "订单状态", value: item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
|
||||
HStack {
|
||||
Text("¥\(item.orderAmount)")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Spacer()
|
||||
Button(viewModel.canVerify(item) ? "核销" : "已处理") {
|
||||
pendingVerifyOrderNumber = item.orderNumber
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(height: 32)
|
||||
.background(
|
||||
viewModel.canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF),
|
||||
in: RoundedRectangle(cornerRadius: 8)
|
||||
)
|
||||
.disabled(!viewModel.canVerify(item) || viewModel.isVerifying)
|
||||
}
|
||||
}
|
||||
.padding(15)
|
||||
.background(
|
||||
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white,
|
||||
in: RoundedRectangle(cornerRadius: 12)
|
||||
)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : .clear, lineWidth: 2)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private func singleDateSheet(title: String, date: Date?, onConfirm: @escaping (Date?) -> Void) -> some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
DatePicker(title, selection: Binding(
|
||||
get: { date ?? Date() },
|
||||
set: { onConfirm($0) }
|
||||
), displayedComponents: .date)
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("清空") { onConfirm(nil) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
|
||||
try await viewModel.reload(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
storeId: currentStoreId,
|
||||
showLoading: showLoading
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMore() async {
|
||||
do {
|
||||
try await viewModel.loadMore(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
try await globalLoading.withLoading(message: "核销中...") {
|
||||
try await viewModel.verify(
|
||||
api: ordersAPI,
|
||||
scenicId: scenicId,
|
||||
storeId: currentStoreId,
|
||||
orderNumber: orderNumber
|
||||
)
|
||||
}
|
||||
manualOrderNumber = ""
|
||||
pendingVerifyOrderNumber = nil
|
||||
matchedScannedOrderNumber = nil
|
||||
toastCenter.show("核销成功")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func consumePendingScanCodeIfNeeded() async {
|
||||
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
|
||||
handleScanResult(rawCode)
|
||||
}
|
||||
|
||||
private func handleScanResult(_ rawCode: String) {
|
||||
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
|
||||
toastCenter.show("扫码成功,但未识别到有效订单号。")
|
||||
return
|
||||
}
|
||||
manualOrderNumber = result.orderNumber
|
||||
pendingVerifyOrderNumber = result.orderNumber
|
||||
if let matched = result.matched {
|
||||
matchedScannedOrderNumber = matched.orderNumber
|
||||
}
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
|
||||
private func dialPhone(_ phone: String) {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
|
||||
openURL(url)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user