新增订单长尾流程,并迁移排队、消息、结算与审核模块

将定金订单、历史拍摄与多行程上传接入 Orders,并以真实页面替换首页排队管理、消息中心、景区结算与提现审核占位入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent 311a70d610
commit c39c3d3c75
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)
}
}
}
}
}

View File

@ -17,6 +17,8 @@ struct StoreOrderDetailView: View {
let item: OrderEntity
@State private var viewModel: OrderDetailViewModel
@State private var refundViewModel = OrderRefundViewModel()
@State private var showRefundSheet = false
/// 使 ViewModel
init(item: OrderEntity) {
@ -96,10 +98,36 @@ struct StoreOrderDetailView: View {
}
Section("后续功能") {
placeholderButton("历史拍摄", title: "历史拍摄")
placeholderButton("任务上传", title: "任务上传")
placeholderButton("视频预告", title: "视频预告")
placeholderButton("退款", title: "订单退款")
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)
@ -117,6 +145,17 @@ struct StoreOrderDetailView: View {
} 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 {
@ -136,20 +175,24 @@ struct StoreOrderDetailView: View {
)
}
///
private func placeholderButton(_ label: String, title: String) -> some View {
Button {
router.navigate(to: .placeholder(title: title))
} label: {
HStack {
Text(label)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
///
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)
}
.foregroundStyle(AppDesign.textPrimary)
}
/// 退
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
@ -158,6 +201,79 @@ struct StoreOrderDetailView: View {
}
}
/// 退退
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

View File

@ -0,0 +1,357 @@
//
// OrderTailUploadViews.swift
// suixinkan
//
// Created by Codex on 2026/6/25.
//
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
///
struct MultiTravelTaskUploadView: View {
@Environment(AccountContext.self) private var accountContext
@Environment(OrdersAPI.self) private var ordersAPI
@Environment(OSSUploadService.self) private var uploadService
@Environment(ToastCenter.self) private var toastCenter
@Environment(\.dismiss) private var dismiss
@Environment(\.globalLoading) private var globalLoading
@State private var viewModel: MultiTravelTaskUploadViewModel
@State private var showSpotPicker = false
@State private var showCloudSelection = false
@State private var pickerItems: [PhotosPickerItem] = []
/// 使
init(initialOrderNumber: String) {
_viewModel = State(initialValue: MultiTravelTaskUploadViewModel(initialOrderNumber: initialOrderNumber))
}
var body: some View {
ScrollView {
VStack(spacing: AppMetrics.Spacing.medium) {
orderSection
cloudFileSection
localFileSection
submitButton
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
}
.background(Color(hex: 0xF5F7FA))
.navigationTitle("任务上传")
.navigationBarTitleDisplayMode(.inline)
.task {
await viewModel.loadSpots(api: ordersAPI)
}
.sheet(isPresented: $showCloudSelection) {
NavigationStack {
TaskCloudFileSelectionView { items in
viewModel.mergeCloudFiles(items)
showCloudSelection = false
}
}
}
.confirmationDialog("选择打卡点", isPresented: $showSpotPicker, titleVisibility: .visible) {
ForEach(viewModel.spots) { spot in
Button(spot.name.isEmpty ? "打卡点 \(spot.id)" : spot.name) {
viewModel.selectSpot(id: spot.id)
}
}
Button("取消", role: .cancel) {}
}
.onChange(of: pickerItems) { _, items in
Task { await importPickerItems(items) }
}
.alert("提示", isPresented: errorBinding) {
Button("知道了", role: .cancel) {}
} message: {
Text(viewModel.errorMessage ?? "")
}
}
///
private var orderSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("基础信息")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
TextField("关联订单号", text: $viewModel.orderNumber)
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.appInputFieldStyle(cornerRadius: AppMetrics.CornerRadius.input, minHeight: AppMetrics.ControlSize.inputHeight)
HStack(spacing: AppMetrics.Spacing.small) {
Button(viewModel.isLoadingSpots ? "加载中..." : "刷新打卡点") {
Task { await viewModel.loadSpots(api: ordersAPI) }
}
.disabled(viewModel.isLoadingSpots)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
.frame(width: 104, height: 40)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
Button {
showSpotPicker = true
} label: {
HStack(spacing: AppMetrics.Spacing.xSmall) {
Text(viewModel.selectedSpotName)
.lineLimit(1)
Spacer()
Image(systemName: "chevron.down")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
.foregroundStyle(AppDesign.textPrimary)
.padding(.horizontal, AppMetrics.Spacing.small)
.frame(maxWidth: .infinity)
.frame(height: 40)
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
.buttonStyle(.plain)
.disabled(viewModel.spots.isEmpty)
}
if viewModel.spots.isEmpty && !viewModel.isLoadingSpots {
Text("暂无可选打卡点,请确认订单已完成核销。")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var cloudFileSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("云盘素材")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
Button("选择云盘文件") {
showCloudSelection = true
}
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
if viewModel.selectedCloudFiles.isEmpty {
Text("未选择云盘文件")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.selectedCloudFiles) { file in
OrderTaskAttachmentRow(
title: file.fileName.isEmpty ? "文件 \(file.id)" : file.fileName,
subtitle: "云盘文件"
) {
viewModel.removeCloudFile(id: file.id)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var localFileSection: some View {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
HStack {
Text("本地素材")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Spacer()
PhotosPicker(selection: $pickerItems, maxSelectionCount: 9, matching: .any(of: [.images, .videos])) {
Text("选择图片/视频")
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.primary)
}
}
if viewModel.selectedLocalFiles.isEmpty {
Text("未选择本地文件")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
} else {
ForEach(viewModel.selectedLocalFiles) { file in
OrderTaskAttachmentRow(title: file.fileName, subtitle: file.statusText) {
viewModel.removeLocalFile(id: file.id)
}
}
}
}
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
}
///
private var submitButton: some View {
Button {
Task { await submit() }
} label: {
Text(viewModel.isSubmitting ? "保存中..." : "保存任务素材")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
.background(viewModel.canSubmit ? AppDesign.primary : Color(hex: 0xC9CED6), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
}
.disabled(!viewModel.canSubmit)
}
private var errorBinding: Binding<Bool> {
Binding(
get: { viewModel.errorMessage != nil },
set: { visible in
if !visible { viewModel.clearError() }
}
)
}
///
private func importPickerItems(_ items: [PhotosPickerItem]) async {
for item in items {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { continue }
viewModel.addLocalFile(data: data, fileName: Self.fileName(for: item))
} catch {
toastCenter.show(error.localizedDescription)
}
}
pickerItems = []
}
///
private func submit() async {
let success = await globalLoading.withLoading {
await viewModel.submit(api: ordersAPI, uploadService: uploadService, scenicId: accountContext.currentScenic?.id)
}
if success {
toastCenter.show("任务素材已保存")
dismiss()
} else if let message = viewModel.errorMessage {
toastCenter.show(message)
}
}
/// PhotosPicker
private static func fileName(for item: PhotosPickerItem) -> String {
let isVideo = item.supportedContentTypes.contains { $0.conforms(to: .movie) || $0.conforms(to: .video) }
return "\(UUID().uuidString).\(isVideo ? "mp4" : "jpg")"
}
}
///
struct OrderTrailerView: View {
@State private var showAlbumTrailer = false
let orderNumber: String
let title: String
var body: some View {
VStack(spacing: AppMetrics.Spacing.medium) {
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text(title)
.font(.system(size: AppMetrics.FontSize.title3, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
Text("订单号:\(orderNumber.isEmpty ? "--" : orderNumber)")
.font(.system(size: AppMetrics.FontSize.subheadline))
.foregroundStyle(AppDesign.textSecondary)
Text("用于完成订单尾片素材的最终核对与提交,按“选择相册 -> 预览文件 -> 确认上传”流程操作。")
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
VStack(alignment: .leading, spacing: AppMetrics.Spacing.small) {
Text("提交流程")
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
trailerStep("1. 进入相册预览上传并选择目标相册")
trailerStep("2. 核对素材内容与顺序后确认上传")
trailerStep("3. 上传完成后返回订单页继续处理")
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(AppMetrics.Spacing.medium)
.background(.white, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.card))
Button("打开相册预览上传") {
showAlbumTrailer = true
}
.font(.system(size: AppMetrics.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.frame(height: AppMetrics.ControlSize.primaryButtonHeight)
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.button))
Spacer()
}
.padding(AppMetrics.Spacing.pageHorizontal)
.padding(.vertical, AppMetrics.Spacing.medium)
.background(Color(hex: 0xF5F7FA).ignoresSafeArea())
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.sheet(isPresented: $showAlbumTrailer) {
NavigationStack {
AlbumTrailerEntryView()
}
}
}
///
private func trailerStep(_ text: String) -> some View {
HStack(alignment: .top, spacing: AppMetrics.Spacing.small) {
Circle()
.fill(AppDesign.primary)
.frame(width: 6, height: 6)
.padding(.top, 6)
Text(text)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(AppDesign.textSecondary)
}
}
}
///
private struct OrderTaskAttachmentRow: View {
let title: String
let subtitle: String
let onDelete: () -> Void
var body: some View {
HStack(spacing: AppMetrics.Spacing.small) {
Image(systemName: "paperclip")
.foregroundStyle(AppDesign.primary)
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
.background(AppDesign.primarySoft, in: Circle())
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxSmall) {
Text(title)
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
.foregroundStyle(AppDesign.textPrimary)
.lineLimit(1)
Text(subtitle)
.font(.system(size: AppMetrics.FontSize.caption))
.foregroundStyle(AppDesign.textSecondary)
}
Spacer()
Button(action: onDelete) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(AppDesign.placeholder)
.frame(width: AppMetrics.ControlSize.iconTapArea, height: AppMetrics.ControlSize.iconTapArea)
}
.accessibilityLabel("删除附件")
}
.padding(AppMetrics.Spacing.small)
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: AppMetrics.CornerRadius.input))
}
}