新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 14:46:10 +08:00
parent c4de3d17d0
commit 08492df6ce
51 changed files with 3668 additions and 102 deletions

View 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)
}
}

View 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)
}
}
}
}
}

View 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)")
}
}

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

View 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))
}
}

View 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))
}
}

View 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)
}
}

View 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)
}
}