Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/OrderDetailViews.swift
汉秋 1a0d1c25f4 Add order tail flows and migrate queue, message, settlement, and audit modules.
Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 13:39:02 +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 {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(RouterPath.self) private var router
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.globalLoading) private var globalLoading
let item: OrderEntity
@State private var viewModel: OrderDetailViewModel
@State private var refundViewModel = OrderRefundViewModel()
@State private var showRefundSheet = false
/// 使 ViewModel
init(item: OrderEntity) {
self.item = item
_viewModel = State(initialValue: 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
@Bindable 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 {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(ToastCenter.self) private var 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 "未知"
}
}
}