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>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent fb8430889b
commit 1a0d1c25f4
63 changed files with 9823 additions and 58 deletions

View File

@ -0,0 +1,596 @@
//
// 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)
}
}
}
}
}