Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/OrderDetailViews.swift
汉秋 703078352c 从 iOS 17 Observation 迁移至 iOS 16 兼容的 Combine 架构
将最低部署版本降至 iOS 16,以 ObservableObject 替换 @Observable,新增导航与 UI 兼容层,并补充登录冒烟 UI 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 10:16:35 +08:00

424 lines
17 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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 "未知"
}
}
}