Files
suixinkan_ios_new/suixinkan/Features/Orders/Views/DepositOrderViews.swift
汉秋 c39c3d3c75 新增订单长尾流程,并迁移排队、消息、结算与审核模块
将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 13:39:02 +08:00

597 lines
24 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.

//
// DepositOrderViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import SwiftUI
/// 退
struct DepositOrderEntryView: 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
@State private var viewModel = DepositOrderListViewModel()
@State private var orderNumber = ""
@State private var pendingWriteOff: DepositOrderListItem?
@State private var pendingRefund: DepositOrderListItem?
@State private var refundReason = ""
var body: some View {
List {
Section("订单查询") {
TextField("请输入押金订单号", text: $orderNumber)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
Button("查看订单详情") {
navigateToDetail(orderNumber)
}
.disabled(orderNumber.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Section {
if viewModel.orders.isEmpty {
ContentUnavailableView("暂无押金订单", systemImage: "doc.text.magnifyingglass")
.frame(maxWidth: .infinity, minHeight: 180)
} else {
ForEach(viewModel.orders) { item in
DepositOrderRow(
item: item,
isOperating: viewModel.operatingOrderNumber == item.orderNumber,
onDetail: { navigateToDetail(item.orderNumber) },
onWriteOff: { pendingWriteOff = item },
onRefund: { pendingRefund = item }
)
}
if viewModel.hasMore {
Button(viewModel.loadingMore ? "加载中..." : "加载更多") {
Task { await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: false) }
}
.frame(maxWidth: .infinity, alignment: .center)
.disabled(viewModel.loadingMore)
}
}
} header: {
HStack {
Text("押金订单列表")
Spacer()
Text("\(viewModel.orders.count)/\(viewModel.total)")
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("押金订单")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await reload(showLoading: false) }
.task { await reload(showLoading: true) }
.confirmationDialog("确认核销该押金订单?", isPresented: writeOffBinding, titleVisibility: .visible) {
Button("确认核销") {
guard let pendingWriteOff else { return }
Task { await writeOff(pendingWriteOff) }
}
Button("取消", role: .cancel) {}
} message: {
Text(pendingWriteOff?.orderNumber ?? "")
}
.alert("申请退款", isPresented: refundBinding) {
TextField("请输入退款原因", text: $refundReason)
Button("提交") {
guard let pendingRefund else { return }
Task { await refund(pendingRefund) }
}
Button("取消", role: .cancel) {
pendingRefund = nil
refundReason = ""
}
} message: {
Text("请填写退款原因后提交。")
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
private var writeOffBinding: Binding<Bool> {
Binding(
get: { pendingWriteOff != nil },
set: { if !$0 { pendingWriteOff = nil } }
)
}
private var refundBinding: Binding<Bool> {
Binding(
get: { pendingRefund != nil },
set: { if !$0 { pendingRefund = nil; refundReason = "" } }
)
}
private var errorBinding: Binding<Bool> {
Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.clearError() } }
)
}
///
private func navigateToDetail(_ rawOrderNumber: String) {
let text = rawOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
router.navigate(to: .orders(.depositDetail(orderNumber: text)))
}
///
private func reload(showLoading: Bool) async {
if showLoading {
await globalLoading.withLoading {
await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: true)
}
} else {
await viewModel.reload(api: ordersAPI, scenicId: accountContext.currentScenic?.id, reset: true)
}
}
///
private func writeOff(_ item: DepositOrderListItem) async {
pendingWriteOff = nil
let success = await viewModel.writeOff(api: ordersAPI, scenicId: accountContext.currentScenic?.id, orderNumber: item.orderNumber)
if success {
toastCenter.show("核销成功")
}
}
/// 退
private func refund(_ item: DepositOrderListItem) async {
let reason = refundReason
pendingRefund = nil
refundReason = ""
let success = await viewModel.refund(api: ordersAPI, scenicId: accountContext.currentScenic?.id, orderNumber: item.orderNumber, reason: reason)
if success {
toastCenter.show("退款申请已提交")
}
}
}
///
private struct DepositOrderRow: View {
let item: DepositOrderListItem
let isOperating: Bool
let onDetail: () -> Void
let onWriteOff: () -> Void
let onRefund: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text(item.orderNumber.isEmpty ? "--" : item.orderNumber)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Text(item.statusName.isEmpty ? "状态\(item.status)" : item.statusName)
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(statusColor)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(height: 24)
.background(statusColor.opacity(0.12), in: Capsule())
}
HStack {
Text("¥\(item.amount.isEmpty ? "0.00" : item.amount)")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Spacer()
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
if !item.userPhone.isEmpty {
Text("手机号:\(item.userPhone)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
HStack(spacing: AppMetrics.Spacing.small) {
rowButton("详情", tint: AppDesign.primary, action: onDetail)
rowButton(isOperating ? "处理中..." : "核销", tint: Color(hex: 0x22C55E), disabled: isOperating, action: onWriteOff)
rowButton(isOperating ? "处理中..." : "退款", tint: Color(hex: 0xF59E0B), disabled: isOperating, action: onRefund)
}
}
.padding(.vertical, AppMetrics.Spacing.xSmall)
}
private var statusColor: Color {
switch item.status {
case 20:
return AppDesign.primary
case 30:
return Color(hex: 0x22C55E)
case 50:
return Color(hex: 0xEF4444)
default:
return Color(hex: 0xF59E0B)
}
}
///
private func rowButton(_ title: String, tint: Color, disabled: Bool = false, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(title)
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(disabled ? AppDesign.textSecondary : tint)
.frame(maxWidth: .infinity)
.frame(height: 34)
.background((disabled ? Color(hex: 0xEEF2F7) : tint.opacity(0.12)), in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
.disabled(disabled)
}
}
///
struct DepositOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(RouterPath.self) private var router
@Environment(\.globalLoading) private var globalLoading
let orderNumber: String
@State private var viewModel = DepositOrderDetailViewModel()
var body: some View {
List {
if let detail = viewModel.detail {
Section("订单信息") {
OrderDetailValueRow(title: "订单号", value: detail.orderNumber)
OrderDetailValueRow(title: "订单状态", value: detail.orderStatusName)
OrderDetailValueRow(title: "订单类型", value: detail.orderTypeLabel)
OrderDetailValueRow(title: "项目名称", value: detail.projectName)
OrderDetailValueRow(title: "订单金额", value: "¥\(detail.actualPayAmount.isEmpty ? "0.00" : detail.actualPayAmount)")
OrderDetailValueRow(title: "退款金额", value: "¥\(detail.actualRefundAmount.isEmpty ? "0" : detail.actualRefundAmount)")
OrderDetailValueRow(title: "手机号", value: detail.phone)
OrderDetailValueRow(title: "下单时间", value: detail.createdAt)
OrderDetailValueRow(title: "付款时间", value: detail.payTime)
OrderDetailValueRow(title: "完成时间", value: detail.completeTime)
}
if let projectInfo = detail.multiTravel?.projectInfo {
Section("项目配置") {
OrderDetailValueRow(title: "打卡点数", value: "\(projectInfo.settleSpotNum)")
OrderDetailValueRow(title: "单点底片", value: "\(projectInfo.singleSpotMaterialNum)")
OrderDetailValueRow(title: "单点精修", value: "\(projectInfo.singleSpotPhotoNum)")
OrderDetailValueRow(title: "单点视频", value: "\(projectInfo.singleSpotVideoNum)")
}
}
Section("拍摄详情") {
let shootingList = detail.multiTravel?.shootingList ?? []
if shootingList.isEmpty {
ContentUnavailableView("暂无拍摄记录", systemImage: "photo.stack")
} else {
ForEach(shootingList) { item in
Button {
router.navigate(
to: .orders(
.depositShootingInfo(
orderNumber: detail.orderNumber,
scenicSpotId: item.scenicSpotId,
photogUid: item.photogUid
)
)
)
} label: {
DepositShootingPointRow(item: item)
}
}
}
}
} else {
Section {
ContentUnavailableView("暂无订单详情", systemImage: "doc.text.magnifyingglass")
.frame(maxWidth: .infinity, minHeight: 220)
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("押金订单详情")
.navigationBarTitleDisplayMode(.inline)
.task {
await globalLoading.withLoading {
await viewModel.load(api: ordersAPI, storeId: accountContext.currentStore?.id, orderNumber: orderNumber)
}
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
private var errorBinding: Binding<Bool> {
Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.clearError() } }
)
}
}
///
private struct DepositShootingPointRow: View {
let item: StoreOrderShootingListItem
var body: some View {
HStack {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text(item.photogName.isEmpty ? "摄影师未分配" : item.photogName)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
VStack(alignment: .trailing, spacing: AppMetrics.Spacing.xSmall) {
Text(item.status == 2 ? "已拍摄" : "待拍摄")
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
.foregroundStyle(item.status == 2 ? Color(hex: 0x22C55E) : AppDesign.primary)
Text(String(format: "评分 %.1f", max(item.startAvg, item.start)))
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
}
private var title: String {
let text = item.scenicSpotName.isEmpty ? item.staffName : item.scenicSpotName
return text.isEmpty ? "未命名打卡点" : text
}
}
///
struct DepositOrderShootingInfoView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(\.globalLoading) private var globalLoading
let orderNumber: String
let scenicSpotId: Int
let photogUid: Int
@State private var viewModel = DepositOrderShootingInfoViewModel()
@State private var selectedTab: DepositShootingMediaTab = .negative
var body: some View {
List {
if let detail = viewModel.detail {
Section("拍摄点") {
OrderDetailValueRow(title: "名称", value: detail.scenicSpotName)
OrderDetailValueRow(title: "订单类型", value: detail.orderTypeName)
}
Section("评分与评价") {
if let comment = detail.orderComment {
RatingRow(title: "拍摄满意度", score: comment.starShooting)
RatingRow(title: "修图满意度", score: comment.starRetouching)
RatingRow(title: "景区满意度", score: comment.starScenery)
RatingRow(title: "服务满意度", score: comment.starService)
RatingRow(title: "相机满意度", score: comment.starCamera)
Text(comment.content.isEmpty ? "暂无评价内容" : comment.content)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
} else {
ContentUnavailableView("暂无评价信息", systemImage: "star")
}
}
Section {
Picker("媒体类型", selection: $selectedTab) {
Text("底片").tag(DepositShootingMediaTab.negative)
Text("成片").tag(DepositShootingMediaTab.complete)
}
.pickerStyle(.segmented)
OrderMediaGrid(files: selectedTab == .negative ? detail.materialList : detail.completeList)
} header: {
Text("媒体文件")
}
} else {
Section {
ContentUnavailableView("暂无拍摄信息", systemImage: "photo.stack")
.frame(maxWidth: .infinity, minHeight: 220)
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("拍摄信息")
.navigationBarTitleDisplayMode(.inline)
.task {
await globalLoading.withLoading {
await viewModel.load(
api: ordersAPI,
storeId: accountContext.currentStore?.id,
orderNumber: orderNumber,
scenicSpotId: scenicSpotId,
photogUid: photogUid
)
}
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
private var errorBinding: Binding<Bool> {
Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.clearError() } }
)
}
}
///
struct HistoricalShootingInfoView: View {
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(\.globalLoading) private var globalLoading
let orderNumber: String
@State private var viewModel = HistoricalShootingInfoViewModel()
var body: some View {
List {
Section("订单") {
OrderDetailValueRow(title: "订单号", value: orderNumber)
OrderDetailValueRow(title: "项目名称", value: viewModel.projectName)
OrderDetailValueRow(title: "项目类型", value: viewModel.projectTypeName)
}
Section("历史拍摄") {
if viewModel.spots.isEmpty {
ContentUnavailableView("暂无历史拍摄信息", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity, minHeight: 220)
} else {
ForEach(viewModel.spots) { spot in
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Label(spot.scenicSpotName.isEmpty ? "未命名打卡点" : spot.scenicSpotName, systemImage: "mappin.and.ellipse")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
Spacer()
Text("摄影师:\(spot.photographerDisplayName)")
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
OrderMediaGrid(files: spot.files)
}
.padding(.vertical, AppMetrics.Spacing.xSmall)
}
}
}
}
.listStyle(.insetGrouped)
.scrollContentBackground(.hidden)
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
.navigationTitle("历史拍摄")
.navigationBarTitleDisplayMode(.inline)
.task {
await globalLoading.withLoading {
await viewModel.load(api: ordersAPI, orderNumber: orderNumber)
}
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
private var errorBinding: Binding<Bool> {
Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.clearError() } }
)
}
}
///
private enum DepositShootingMediaTab: Hashable {
case negative
case complete
}
///
private struct OrderDetailValueRow: View {
let title: String
let value: String
var body: some View {
HStack(alignment: .top) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
Spacer(minLength: AppMetrics.Spacing.medium)
Text(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "--" : value)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.multilineTextAlignment(.trailing)
}
}
}
///
private struct RatingRow: View {
let title: String
let score: Int
var body: some View {
HStack {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
Spacer()
Text(String(repeating: "", count: max(score, 0)))
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(Color(hex: 0xF59E0B))
}
}
}
///
private struct OrderMediaGrid: View {
let files: [OrderMediaFile]
private let columns = [
GridItem(.flexible(), spacing: AppMetrics.Spacing.small),
GridItem(.flexible(), spacing: AppMetrics.Spacing.small),
GridItem(.flexible(), spacing: AppMetrics.Spacing.small)
]
var body: some View {
if files.isEmpty {
ContentUnavailableView("暂无媒体文件", systemImage: "photo")
.frame(maxWidth: .infinity, minHeight: 140)
} else {
LazyVGrid(columns: columns, spacing: AppMetrics.Spacing.small) {
ForEach(files) { file in
Link(destination: URL(string: file.fileUrl) ?? APIEnvironment.production.baseURL) {
ZStack(alignment: .bottomTrailing) {
RemoteImage(urlString: file.previewURLString) {
RoundedRectangle(cornerRadius: 8)
.fill(AppDesign.primarySoft)
}
.frame(height: 88)
.clipShape(RoundedRectangle(cornerRadius: 8))
if file.isVideo {
Image(systemName: "play.circle.fill")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(.white)
.shadow(radius: 2)
.padding(AppMetrics.Spacing.xSmall)
}
}
}
.buttonStyle(.plain)
}
}
}
}
}