Lower deployment target to iOS 16, replace @Observable with ObservableObject, add navigation and UI compatibility shims, and include login smoke UI tests. Co-authored-by: Cursor <cursoragent@cursor.com>
424 lines
17 KiB
Swift
424 lines
17 KiB
Swift
//
|
||
// OrderDetailViews.swift
|
||
// suixinkan
|
||
//
|
||
// Created by Codex on 2026/6/22.
|
||
//
|
||
|
||
import SwiftUI
|
||
|
||
/// 门店订单详情页,展示订单接口补全后的支付、客户、项目和拍摄点信息。
|
||
struct StoreOrderDetailView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.ordersAPI) private var ordersAPI
|
||
@EnvironmentObject private var router: RouterPath
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
@Environment(\.globalLoading) private var globalLoading
|
||
|
||
let item: OrderEntity
|
||
@StateObject private var viewModel: OrderDetailViewModel
|
||
@StateObject private var refundViewModel = OrderRefundViewModel()
|
||
@State private var showRefundSheet = false
|
||
|
||
/// 使用列表订单初始化详情页,并创建详情 ViewModel。
|
||
init(item: OrderEntity) {
|
||
self.item = item
|
||
_viewModel = StateObject(wrappedValue: OrderDetailViewModel(item: item))
|
||
}
|
||
|
||
var body: some View {
|
||
List {
|
||
if let contextMessage = viewModel.contextMessage {
|
||
Section {
|
||
Label(contextMessage, systemImage: "info.circle")
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
|
||
Section("订单信息") {
|
||
OrderDetailRow(title: "订单号", value: viewModel.display.orderNumber)
|
||
Button("复制订单号") {
|
||
UIPasteboard.general.string = viewModel.display.orderNumber
|
||
toastCenter.show("订单号已复制")
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
OrderDetailRow(title: "订单状态", value: viewModel.display.orderStatusName)
|
||
OrderDetailRow(title: "订单类型", value: viewModel.display.orderTypeLabel)
|
||
OrderDetailRow(title: "创建时间", value: viewModel.display.createdAt)
|
||
OrderDetailRow(title: "付款时间", value: displayPayTime)
|
||
OrderDetailRow(title: "完成时间", value: viewModel.display.completeTime)
|
||
}
|
||
|
||
Section("支付信息") {
|
||
OrderDetailRow(title: "付款金额", value: "¥\(emptyToZero(viewModel.display.actualPayAmount))")
|
||
OrderDetailRow(title: "退款金额", value: "¥\(emptyToZero(viewModel.display.actualRefundAmount))")
|
||
OrderDetailRow(title: "付款方式", value: viewModel.display.payTypeName)
|
||
OrderDetailRow(title: "用户 UID", value: "\(viewModel.display.userId)")
|
||
}
|
||
|
||
Section("客户与项目") {
|
||
OrderDetailRow(title: "手机号", value: viewModel.display.phone)
|
||
OrderDetailRow(title: "关联项目", value: viewModel.display.projectName)
|
||
OrderDetailRow(title: "项目 ID", value: "\(viewModel.display.projectId)")
|
||
}
|
||
|
||
if let projectInfo = viewModel.projectInfo {
|
||
Section("项目配置") {
|
||
OrderDetailRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)")
|
||
OrderDetailRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)")
|
||
OrderDetailRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)")
|
||
OrderDetailRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)")
|
||
}
|
||
}
|
||
|
||
if let photoTravel = item.photoTravel {
|
||
Section("旅拍信息") {
|
||
OrderDetailRow(title: "核销时间", value: photoTravel.checkInTime)
|
||
OrderDetailRow(title: "修图张数", value: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum) 张")
|
||
OrderDetailRow(title: "视频剪辑", value: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum) 条")
|
||
}
|
||
}
|
||
|
||
if !viewModel.shootingList.isEmpty {
|
||
Section("拍摄点") {
|
||
ForEach(viewModel.shootingList) { shooting in
|
||
ShootingPointRow(item: shooting)
|
||
}
|
||
}
|
||
}
|
||
|
||
if !viewModel.display.remark.isEmpty {
|
||
Section("备注") {
|
||
Text(viewModel.display.remark)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
}
|
||
}
|
||
|
||
Section("后续功能") {
|
||
Button {
|
||
router.navigate(to: .orders(.historicalShooting(orderNumber: viewModel.display.orderNumber)))
|
||
} label: {
|
||
navigationRow("历史拍摄")
|
||
}
|
||
if item.orderType == 19 {
|
||
Button {
|
||
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: viewModel.display.orderNumber)))
|
||
} label: {
|
||
navigationRow("任务上传")
|
||
}
|
||
}
|
||
Button {
|
||
router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "视频预告")))
|
||
} label: {
|
||
navigationRow("视频预告")
|
||
}
|
||
Button {
|
||
router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "尾片上传")))
|
||
} label: {
|
||
navigationRow("尾片上传")
|
||
}
|
||
if refundViewModel.canRefund(item) {
|
||
Button {
|
||
refundViewModel.begin(item: item)
|
||
showRefundSheet = true
|
||
} label: {
|
||
navigationRow("退款")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.scrollContentBackground(.hidden)
|
||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||
.navigationTitle("订单详情")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.task {
|
||
await globalLoading.withLoading(message: "加载详情中...") {
|
||
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id)
|
||
}
|
||
}
|
||
.alert("提示", isPresented: errorBinding) {
|
||
Button("知道了", role: .cancel) {}
|
||
} message: {
|
||
Text(viewModel.errorMessage ?? "")
|
||
}
|
||
.sheet(isPresented: $showRefundSheet) {
|
||
OrderRefundSheet(
|
||
item: item,
|
||
viewModel: refundViewModel,
|
||
onCancel: { showRefundSheet = false },
|
||
onSubmit: {
|
||
Task { await submitRefund() }
|
||
}
|
||
)
|
||
.presentationDetents([.medium, .large])
|
||
}
|
||
}
|
||
|
||
private var displayPayTime: String {
|
||
let payTime = viewModel.display.payTime
|
||
if payTime.isEmpty || payTime == "0" || payTime.hasPrefix("1970") {
|
||
return item.depositPayTime
|
||
}
|
||
return payTime
|
||
}
|
||
|
||
private var errorBinding: Binding<Bool> {
|
||
Binding(
|
||
get: { viewModel.errorMessage != nil },
|
||
set: { visible in
|
||
if !visible { viewModel.clearError() }
|
||
}
|
||
)
|
||
}
|
||
|
||
/// 构建可跳转业务入口行。
|
||
private func navigationRow(_ label: String) -> some View {
|
||
HStack {
|
||
Text(label)
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
}
|
||
}
|
||
|
||
/// 提交普通订单退款并刷新详情。
|
||
private func submitRefund() async {
|
||
let success = await refundViewModel.submit(api: ordersAPI, item: item)
|
||
guard success else { return }
|
||
showRefundSheet = false
|
||
toastCenter.show("退款申请已提交")
|
||
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true)
|
||
}
|
||
|
||
/// 将空金额转换为 0,避免详情页出现孤立货币符号。
|
||
private func emptyToZero(_ value: String) -> String {
|
||
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "0" : value
|
||
}
|
||
}
|
||
|
||
/// 普通订单退款表单,负责收集退款模式、金额和原因。
|
||
private struct OrderRefundSheet: View {
|
||
let item: OrderEntity
|
||
@ObservedObject var viewModel: OrderRefundViewModel
|
||
let onCancel: () -> Void
|
||
let onSubmit: () -> Void
|
||
@FocusState private var focusedField: RefundInputField?
|
||
|
||
var body: some View {
|
||
NavigationStack {
|
||
Form {
|
||
Section("订单") {
|
||
OrderDetailRow(title: "订单号", value: item.orderNumber)
|
||
OrderDetailRow(title: "可退金额", value: "¥\(viewModel.availableAmountText(for: item))")
|
||
}
|
||
|
||
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)
|
||
.focused($focusedField, equals: .amount)
|
||
}
|
||
}
|
||
|
||
Section("退款原因") {
|
||
TextEditor(text: $viewModel.reason)
|
||
.frame(minHeight: 96)
|
||
.focused($focusedField, equals: .reason)
|
||
}
|
||
|
||
if let errorMessage = viewModel.errorMessage {
|
||
Section {
|
||
Text(errorMessage)
|
||
.font(.system(size: AppMetrics.FontSize.footnote))
|
||
.foregroundStyle(Color(hex: 0xEF4444))
|
||
}
|
||
}
|
||
}
|
||
.navigationTitle("订单退款")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.toolbar {
|
||
ToolbarItem(placement: .cancellationAction) {
|
||
Button("取消", action: onCancel)
|
||
.disabled(viewModel.submitting)
|
||
}
|
||
ToolbarItem(placement: .confirmationAction) {
|
||
Button(viewModel.submitting ? "提交中..." : "提交", action: onSubmit)
|
||
.disabled(viewModel.submitting)
|
||
}
|
||
ToolbarItemGroup(placement: .keyboard) {
|
||
Spacer()
|
||
Button("完成") {
|
||
focusedField = nil
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 退款表单当前聚焦字段。
|
||
private enum RefundInputField: Hashable {
|
||
case amount
|
||
case reason
|
||
}
|
||
|
||
/// 核销订单详情页,展示核销列表项信息并支持再次发起核销。
|
||
struct WriteOffOrderDetailView: View {
|
||
@EnvironmentObject private var accountContext: AccountContext
|
||
@Environment(\.ordersAPI) private var ordersAPI
|
||
@EnvironmentObject private var toastCenter: ToastCenter
|
||
|
||
let item: WriteOffOrderItem
|
||
@State private var verifying = false
|
||
@State private var showVerifyConfirmation = false
|
||
|
||
var body: some View {
|
||
List {
|
||
Section("核销订单") {
|
||
OrderDetailRow(title: "订单号", value: item.orderNumber)
|
||
Button("复制订单号") {
|
||
UIPasteboard.general.string = item.orderNumber
|
||
toastCenter.show("订单号已复制")
|
||
}
|
||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||
.foregroundStyle(AppDesign.primary)
|
||
OrderDetailRow(title: "项目名称", value: item.projectName)
|
||
OrderDetailRow(title: "核销状态", value: displayVerificationStatus)
|
||
OrderDetailRow(title: "订单状态", value: item.orderStatusName)
|
||
OrderDetailRow(title: "订单金额", value: "¥\(item.orderAmount)")
|
||
OrderDetailRow(title: "手机号", value: item.userPhone)
|
||
OrderDetailRow(title: "支付时间", value: item.payTime)
|
||
OrderDetailRow(title: "核销时间", value: item.orderVerificationTime)
|
||
}
|
||
|
||
Section {
|
||
Button(verifying ? "核销中..." : "确认核销") {
|
||
showVerifyConfirmation = true
|
||
}
|
||
.frame(maxWidth: .infinity, alignment: .center)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(.white)
|
||
.listRowBackground(canVerify ? AppDesign.primary : Color(hex: 0x9CA3AF))
|
||
.disabled(!canVerify || verifying)
|
||
}
|
||
}
|
||
.listStyle(.insetGrouped)
|
||
.scrollContentBackground(.hidden)
|
||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||
.navigationTitle("核销订单详情")
|
||
.navigationBarTitleDisplayMode(.inline)
|
||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||
Button("确认核销") {
|
||
Task { await verify() }
|
||
}
|
||
Button("取消", role: .cancel) {}
|
||
} message: {
|
||
Text("""
|
||
订单号:\(item.orderNumber)
|
||
项目:\(item.projectName.isEmpty ? "--" : item.projectName)
|
||
金额:¥\(item.orderAmount)
|
||
""")
|
||
}
|
||
}
|
||
|
||
private var displayVerificationStatus: String {
|
||
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
return text.isEmpty ? "待核销" : text
|
||
}
|
||
|
||
private var canVerify: Bool {
|
||
!displayVerificationStatus.contains("已核销") &&
|
||
!displayVerificationStatus.contains("已退款") &&
|
||
!displayVerificationStatus.contains("已完成")
|
||
}
|
||
|
||
/// 调用核销接口并提示结果。
|
||
private func verify() async {
|
||
guard !verifying else { return }
|
||
guard accountContext.currentScenic?.id != nil else {
|
||
toastCenter.show("缺少景区上下文")
|
||
return
|
||
}
|
||
|
||
verifying = true
|
||
defer { verifying = false }
|
||
|
||
do {
|
||
try await ordersAPI.writeOff(orderNumber: item.orderNumber)
|
||
toastCenter.show("核销成功")
|
||
} catch {
|
||
toastCenter.show(error.localizedDescription)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 订单详情键值行,统一二级页面的字段排版。
|
||
private struct OrderDetailRow: View {
|
||
let title: String
|
||
let value: String
|
||
|
||
var body: some View {
|
||
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||
.foregroundStyle(AppDesign.textSecondary)
|
||
.frame(width: 76, alignment: .leading)
|
||
Spacer(minLength: AppMetrics.Spacing.small)
|
||
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
.multilineTextAlignment(.trailing)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 拍摄点展示行,表示单个打卡点的拍摄人员、状态和评分。
|
||
private struct ShootingPointRow: View {
|
||
let item: StoreOrderShootingListItem
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||
HStack {
|
||
Text(title)
|
||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||
.foregroundStyle(AppDesign.textPrimary)
|
||
Spacer()
|
||
Text(statusText)
|
||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||
.foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary)
|
||
}
|
||
OrderDetailRow(title: "摄影师", value: item.photogName)
|
||
OrderDetailRow(title: "评分", value: String(format: "%.1f", max(item.startAvg, item.start)))
|
||
}
|
||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||
}
|
||
|
||
private var title: String {
|
||
let value = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName
|
||
return value.isEmpty ? "未命名打卡点" : value
|
||
}
|
||
|
||
private var statusText: String {
|
||
switch item.status {
|
||
case 1:
|
||
return "待拍摄"
|
||
case 2:
|
||
return "已拍摄"
|
||
default:
|
||
return "未知"
|
||
}
|
||
}
|
||
}
|