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:
596
suixinkan/Features/Orders/Views/DepositOrderViews.swift
Normal file
596
suixinkan/Features/Orders/Views/DepositOrderViews.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
357
suixinkan/Features/Orders/Views/OrderTailUploadViews.swift
Normal file
357
suixinkan/Features/Orders/Views/OrderTailUploadViews.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user