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:
@ -32,6 +32,30 @@ protocol OrderServing {
|
||||
|
||||
/// 获取门店订单详情。
|
||||
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
|
||||
|
||||
/// 获取押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem>
|
||||
|
||||
/// 核销押金订单。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws
|
||||
|
||||
/// 申请押金订单退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws
|
||||
|
||||
/// 获取订单某个拍摄点的底片、成片和评价信息。
|
||||
func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse
|
||||
|
||||
/// 申请普通订单退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws
|
||||
|
||||
/// 获取订单历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse
|
||||
|
||||
/// 获取多点旅拍订单已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem]
|
||||
|
||||
/// 提交多点旅拍任务素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -131,4 +155,106 @@ final class OrdersAPI: OrderServing {
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取押金订单列表。
|
||||
func depositOrderList(scenicId: Int, page: Int = 1, pageSize: Int = 10) async throws -> ListPayload<DepositOrderListItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 核销押金订单。
|
||||
func depositOrderWriteOff(orderNumber: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-writeoff",
|
||||
body: DepositOrderWriteOffRequest(orderNumber: orderNumber)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 申请押金订单退款。
|
||||
func depositOrderRefund(orderNumber: String, refundReason: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/deposit-refund",
|
||||
body: DepositOrderRefundRequest(orderNumber: orderNumber, refundReason: refundReason)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取订单某个拍摄点的底片、成片和评价信息。
|
||||
func storeOrderShootingDetail(storeId: Int, orderNumber: String, scenicSpotId: Int, photogUid: Int) async throws -> StoreOrderShootingDetailResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/app/store/order/shooting-detail",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "store_id", value: "\(storeId)"),
|
||||
URLQueryItem(name: "order_number", value: orderNumber),
|
||||
URLQueryItem(name: "scenic_spot_id", value: "\(scenicSpotId)"),
|
||||
URLQueryItem(name: "photog_uid", value: "\(photogUid)")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 申请普通订单退款。
|
||||
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/refund",
|
||||
body: OrderRefundRequest(
|
||||
orderNumber: orderNumber,
|
||||
refundType: refundType.rawValue,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: refundReason
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取订单历史拍摄信息。
|
||||
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/shoot-history",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取多点旅拍订单已核销打卡点列表。
|
||||
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list",
|
||||
queryItems: [URLQueryItem(name: "order_number", value: orderNumber)]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 提交多点旅拍任务素材。
|
||||
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/multi-travel/upload-material",
|
||||
body: request
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,10 +197,101 @@ struct WriteOffRequest: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单列表实体,表示首页押金订单卡片的基础信息和操作状态。
|
||||
struct DepositOrderListItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let orderNumber: String
|
||||
let userPhone: String
|
||||
let amount: String
|
||||
let status: Int
|
||||
let statusName: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case orderNumber = "order_number"
|
||||
case userPhone = "user_phone"
|
||||
case amount
|
||||
case status
|
||||
case statusName = "status_name"
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码押金订单字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
amount = try container.decodeLossyString(forKey: .amount)
|
||||
status = try container.decodeLossyInt(forKey: .status) ?? 0
|
||||
statusName = try container.decodeLossyString(forKey: .statusName)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单核销请求实体。
|
||||
struct DepositOrderWriteOffRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
/// 押金订单退款请求实体。
|
||||
struct DepositOrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通订单退款模式,表示全额退款或部分退款。
|
||||
enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable {
|
||||
case full = 1
|
||||
case partial = 3
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
/// 退款模式展示文案。
|
||||
var title: String {
|
||||
switch self {
|
||||
case .full:
|
||||
return "全额退款"
|
||||
case .partial:
|
||||
return "部分退款"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 普通订单退款请求实体。
|
||||
struct OrderRefundRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let refundType: Int
|
||||
let refundAmount: String
|
||||
let refundReason: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case refundType = "refund_type"
|
||||
case refundAmount = "refund_amount"
|
||||
case refundReason = "refund_reason"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情路由实体,表示订单模块内部可 push 的二级页面。
|
||||
enum OrdersRoute: Hashable {
|
||||
case storeDetail(OrderEntity)
|
||||
case writeOffDetail(WriteOffOrderItem)
|
||||
case depositDetail(orderNumber: String)
|
||||
case depositShootingInfo(orderNumber: String, scenicSpotId: Int, photogUid: Int)
|
||||
case historicalShooting(orderNumber: String)
|
||||
case multiTravelTaskUpload(orderNumber: String)
|
||||
case orderTrailer(orderNumber: String, title: String)
|
||||
}
|
||||
|
||||
/// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。
|
||||
@ -346,6 +437,226 @@ struct StoreOrderShootingListItem: Decodable, Identifiable, Equatable, Hashable
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单拍摄信息响应实体,表示某个打卡点的评价、底片和成片。
|
||||
struct StoreOrderShootingDetailResponse: Decodable, Equatable, Hashable {
|
||||
let scenicSpotId: Int
|
||||
let scenicSpotName: String
|
||||
let orderType: Int
|
||||
let orderTypeName: String
|
||||
let orderComment: StoreOrderComment?
|
||||
let materialList: [OrderMediaFile]
|
||||
let completeList: [OrderMediaFile]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeName = "order_type_name"
|
||||
case orderComment = "order_comment"
|
||||
case materialList = "material_list"
|
||||
case completeList = "complete_list"
|
||||
}
|
||||
|
||||
/// 宽松解码拍摄信息详情。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeName = try container.decodeLossyString(forKey: .orderTypeName)
|
||||
orderComment = try container.decodeIfPresent(StoreOrderComment.self, forKey: .orderComment)
|
||||
materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? []
|
||||
completeList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .completeList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单评价实体,表示拍摄、修图、景区、服务和相机评分。
|
||||
struct StoreOrderComment: Decodable, Equatable, Hashable {
|
||||
let starShooting: Int
|
||||
let starRetouching: Int
|
||||
let starScenery: Int
|
||||
let starService: Int
|
||||
let starCamera: Int
|
||||
let content: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case starShooting = "star_shooting"
|
||||
case starRetouching = "star_retouching"
|
||||
case starScenery = "star_scenery"
|
||||
case starService = "star_service"
|
||||
case starCamera = "star_camera"
|
||||
case content
|
||||
case createdAt = "created_at"
|
||||
}
|
||||
|
||||
/// 宽松解码评价字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
starShooting = try container.decodeLossyInt(forKey: .starShooting) ?? 0
|
||||
starRetouching = try container.decodeLossyInt(forKey: .starRetouching) ?? 0
|
||||
starScenery = try container.decodeLossyInt(forKey: .starScenery) ?? 0
|
||||
starService = try container.decodeLossyInt(forKey: .starService) ?? 0
|
||||
starCamera = try container.decodeLossyInt(forKey: .starCamera) ?? 0
|
||||
content = try container.decodeLossyString(forKey: .content)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单媒体文件实体,表示底片、成片或历史拍摄媒体。
|
||||
struct OrderMediaFile: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(fileUrl)-\(fileName)-\(uploadTime)" }
|
||||
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
let fileType: Int
|
||||
let fileSize: Int64
|
||||
let coverUrl: String
|
||||
let uploadTime: String
|
||||
|
||||
var isVideo: Bool {
|
||||
fileType == 1
|
||||
}
|
||||
|
||||
var previewURLString: String {
|
||||
coverUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? fileUrl : coverUrl
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
case fileType = "file_type"
|
||||
case fileSize = "file_size"
|
||||
case coverUrl = "cover_url"
|
||||
case uploadTime = "upload_time"
|
||||
}
|
||||
|
||||
/// 宽松解码媒体文件字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
fileName = try container.decodeLossyString(forKey: .fileName)
|
||||
fileUrl = try container.decodeLossyString(forKey: .fileUrl)
|
||||
fileType = try container.decodeLossyInt(forKey: .fileType) ?? 0
|
||||
fileSize = Int64(try container.decodeLossyInt(forKey: .fileSize) ?? 0)
|
||||
coverUrl = try container.decodeLossyString(forKey: .coverUrl)
|
||||
uploadTime = try container.decodeLossyString(forKey: .uploadTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄响应实体,表示订单多点位历史拍摄媒体。
|
||||
struct MultiTravelShootHistoryResponse: Decodable, Equatable, Hashable {
|
||||
let projectName: String
|
||||
let projectType: Int
|
||||
let projectTypeName: String
|
||||
let photogSpotList: [PhotogSpotItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case projectName = "project_name"
|
||||
case projectType = "project_type"
|
||||
case projectTypeName = "project_type_name"
|
||||
case photogSpotList = "photog_spot_list"
|
||||
}
|
||||
|
||||
/// 宽松解码历史拍摄响应。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
projectType = try container.decodeLossyInt(forKey: .projectType) ?? 0
|
||||
projectTypeName = try container.decodeLossyString(forKey: .projectTypeName)
|
||||
photogSpotList = try container.decodeIfPresent([PhotogSpotItem].self, forKey: .photogSpotList) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 历史拍摄打卡点实体,表示某个摄影师在打卡点上传过的媒体。
|
||||
struct PhotogSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
var id: String { "\(scenicSpotId)-\(photogUid)" }
|
||||
|
||||
let scenicSpotId: Int
|
||||
let photogUid: Int
|
||||
let scenicSpotName: String
|
||||
let photogNickname: String
|
||||
let photogName: String
|
||||
let files: [OrderMediaFile]
|
||||
|
||||
var photographerDisplayName: String {
|
||||
let nickname = photogNickname.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return nickname.isEmpty ? photogName : nickname
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case photogUid = "photog_uid"
|
||||
case scenicSpotName = "scenic_spot_name"
|
||||
case photogNickname = "photog_nickname"
|
||||
case photogName = "photog_name"
|
||||
case files
|
||||
}
|
||||
|
||||
/// 宽松解码历史拍摄打卡点。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
scenicSpotId = try container.decodeLossyInt(forKey: .scenicSpotId) ?? 0
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
scenicSpotName = try container.decodeLossyString(forKey: .scenicSpotName)
|
||||
photogNickname = try container.decodeLossyString(forKey: .photogNickname)
|
||||
photogName = try container.decodeLossyString(forKey: .photogName)
|
||||
files = try container.decodeIfPresent([OrderMediaFile].self, forKey: .files) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍已核销打卡点实体,用于任务上传时选择目标打卡点。
|
||||
struct MultiTravelVerifiedScenicSpotItem: Decodable, Identifiable, Equatable, Hashable {
|
||||
let id: Int
|
||||
let name: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case name
|
||||
}
|
||||
|
||||
/// 宽松解码已核销打卡点字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decodeLossyInt(forKey: .id) ?? 0
|
||||
name = try container.decodeLossyString(forKey: .name)
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍任务素材提交请求实体。
|
||||
struct MultiTravelUploadMaterialRequest: Encodable, Equatable {
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
let cloudFile: [MultiTravelCloudFileItem]
|
||||
let uploadFile: [MultiTravelUploadFileItem]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case scenicSpotId = "scenic_spot_id"
|
||||
case cloudFile = "cloud_file"
|
||||
case uploadFile = "upload_file"
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍云盘素材提交项。
|
||||
struct MultiTravelCloudFileItem: Encodable, Equatable {
|
||||
let fileId: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileId = "file_id"
|
||||
}
|
||||
}
|
||||
|
||||
/// 多点旅拍本地上传素材提交项。
|
||||
struct MultiTravelUploadFileItem: Encodable, Equatable {
|
||||
let fileName: String
|
||||
let fileUrl: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fileName = "file_name"
|
||||
case fileUrl = "file_url"
|
||||
}
|
||||
}
|
||||
|
||||
/// 订单详情展示适配实体,统一列表摘要和详情接口响应的读取方式。
|
||||
struct StoreOrderDetailDisplay: Equatable {
|
||||
let orderNumber: String
|
||||
|
||||
@ -2,15 +2,21 @@
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销和手动核销闭环。
|
||||
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、订单详情、扫码核销、手动核销、押金核销、押金退款、普通订单退款、历史拍摄、任务上传和尾片入口闭环。
|
||||
|
||||
当前仍不迁移退款、历史拍摄、任务上传、视频预告、完整拍摄详情等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。
|
||||
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||
- `OrderDetailViewModel`:管理订单详情接口加载、错误提示和列表摘要兜底展示。
|
||||
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新。
|
||||
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求。
|
||||
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价。
|
||||
- `OrderRefundViewModel`:管理普通订单退款入口判断、全额/部分退款金额校验和提交保护。
|
||||
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示。
|
||||
- `MultiTravelTaskUploadViewModel`:管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交。
|
||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||
- `OrdersRoute`:表示订单模块二级页面路由,包含订单详情和核销订单详情。
|
||||
@ -26,8 +32,18 @@ Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理和核销
|
||||
|
||||
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation,不缓存扫码结果;扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||
|
||||
押金订单入口由首页 `deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片。
|
||||
|
||||
普通订单退款只在 `orderStatus == 18 || orderStatus == 30`、`orderType != 19` 且可退金额大于 0 时展示入口。全额退款使用可退金额,部分退款要求金额大于 0、最多两位小数且不能超过可退金额。押金退款要求填写退款原因,普通退款和押金退款都不保存表单数据,提交成功后刷新对应订单数据。
|
||||
|
||||
历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑。
|
||||
|
||||
任务上传仅对 `orderType == 19` 的多点旅拍订单展示。页面先按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/verified-scenic-spot-list` 获取已核销打卡点,再支持选择云盘素材和本地图片/视频;本地素材先通过 `OSSUploadService.uploadTaskFile` 上传,成功后和云盘文件一起提交到 `/api/yf-handset-app/photog/order/multi-travel/upload-material`。
|
||||
|
||||
视频预告和尾片上传按旧工程现状统一进入 `OrderTrailerView`,该页只做订单尾片流程引导,并打开已迁移的 `AlbumTrailerEntryView` 完成相册预览上传,不新增独立订单尾片接口。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单详情和核销订单详情已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail`、`deposit_order`、`deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由,push 后继续隐藏 TabBar。
|
||||
|
||||
退款、历史拍摄、任务上传、视频预告等尚未迁移的能力仍进入占位页,避免入口崩溃。
|
||||
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。
|
||||
|
||||
@ -43,8 +43,13 @@ final class OrderDetailViewModel {
|
||||
}
|
||||
|
||||
/// 加载门店订单详情,缺少门店 ID 时只保留列表摘要展示。
|
||||
func load(api: OrderServing, fallbackStoreId: Int?) async {
|
||||
guard detail == nil, !loading else { return }
|
||||
func load(api: OrderServing, fallbackStoreId: Int?, forceReload: Bool = false) async {
|
||||
guard !loading else { return }
|
||||
if forceReload {
|
||||
detail = nil
|
||||
} else {
|
||||
guard detail == nil else { return }
|
||||
}
|
||||
|
||||
guard let storeId = item.storeId ?? fallbackStoreId, storeId > 0 else {
|
||||
contextMessage = "缺少门店上下文,当前展示订单摘要。"
|
||||
|
||||
@ -0,0 +1,610 @@
|
||||
//
|
||||
// OrderLongTailViewModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金订单列表 ViewModel,负责分页、核销、退款和操作后刷新。
|
||||
final class DepositOrderListViewModel {
|
||||
private(set) var orders: [DepositOrderListItem] = []
|
||||
private(set) var total = 0
|
||||
private(set) var page = 1
|
||||
private(set) var hasMore = false
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var operatingOrderNumber: String?
|
||||
var errorMessage: String?
|
||||
|
||||
private let pageSize = 10
|
||||
|
||||
/// 加载押金订单列表,缺少景区时清空旧数据且不请求接口。
|
||||
func reload(api: OrderServing, scenicId: Int?, reset: Bool) async {
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
if reset {
|
||||
page = 1
|
||||
total = 0
|
||||
orders = []
|
||||
loading = true
|
||||
} else {
|
||||
guard !loadingMore, hasMore else { return }
|
||||
loadingMore = true
|
||||
}
|
||||
|
||||
defer {
|
||||
loading = false
|
||||
loadingMore = false
|
||||
}
|
||||
|
||||
do {
|
||||
let result = try await api.depositOrderList(scenicId: scenicId, page: page, pageSize: pageSize)
|
||||
total = result.total
|
||||
if page == 1 {
|
||||
orders = result.list
|
||||
} else {
|
||||
orders.append(contentsOf: result.list)
|
||||
}
|
||||
page += 1
|
||||
hasMore = orders.count < result.total && !result.list.isEmpty
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
if reset {
|
||||
orders = []
|
||||
total = 0
|
||||
page = 1
|
||||
hasMore = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销押金订单,成功后刷新第一页。
|
||||
func writeOff(api: OrderServing, scenicId: Int?, orderNumber: String) async -> Bool {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty, operatingOrderNumber == nil else { return false }
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少景区上下文"
|
||||
return false
|
||||
}
|
||||
|
||||
operatingOrderNumber = normalized
|
||||
defer { operatingOrderNumber = nil }
|
||||
|
||||
do {
|
||||
try await api.depositOrderWriteOff(orderNumber: normalized)
|
||||
await reload(api: api, scenicId: scenicId, reset: true)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 申请押金订单退款,原因必填,成功后刷新第一页。
|
||||
func refund(api: OrderServing, scenicId: Int?, orderNumber: String, reason: String) async -> Bool {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedOrderNumber.isEmpty, operatingOrderNumber == nil else { return false }
|
||||
guard !normalizedReason.isEmpty else {
|
||||
errorMessage = "请填写退款原因"
|
||||
return false
|
||||
}
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "缺少景区上下文"
|
||||
return false
|
||||
}
|
||||
|
||||
operatingOrderNumber = normalizedOrderNumber
|
||||
defer { operatingOrderNumber = nil }
|
||||
|
||||
do {
|
||||
try await api.depositOrderRefund(orderNumber: normalizedOrderNumber, refundReason: normalizedReason)
|
||||
await reload(api: api, scenicId: scenicId, reset: true)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func resetState() {
|
||||
orders = []
|
||||
total = 0
|
||||
page = 1
|
||||
hasMore = false
|
||||
loading = false
|
||||
loadingMore = false
|
||||
operatingOrderNumber = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金订单详情 ViewModel,负责按门店和订单号加载详情。
|
||||
final class DepositOrderDetailViewModel {
|
||||
private(set) var detail: StoreOrderDetailResponse?
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载押金订单详情,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let storeId, storeId > 0 else {
|
||||
reset(message: "当前账号缺少门店信息")
|
||||
return
|
||||
}
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
detail = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderDetail(storeId: storeId, orderNumber: normalized)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 押金拍摄信息 ViewModel,负责加载单个打卡点的媒体和评分。
|
||||
final class DepositOrderShootingInfoViewModel {
|
||||
private(set) var detail: StoreOrderShootingDetailResponse?
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载押金订单拍摄信息,缺少门店或订单号时不请求接口。
|
||||
func load(api: OrderServing, storeId: Int?, orderNumber: String, scenicSpotId: Int, photogUid: Int) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let storeId, storeId > 0 else {
|
||||
reset(message: "当前账号缺少门店信息")
|
||||
return
|
||||
}
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
detail = nil
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
detail = try await api.storeOrderShootingDetail(
|
||||
storeId: storeId,
|
||||
orderNumber: normalized,
|
||||
scenicSpotId: scenicSpotId,
|
||||
photogUid: photogUid
|
||||
)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
detail = nil
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
detail = nil
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 普通订单退款 ViewModel,负责退款入口判断、金额校验和提交保护。
|
||||
final class OrderRefundViewModel {
|
||||
var mode: OrderRefundMode = .full
|
||||
var amount = ""
|
||||
var reason = ""
|
||||
private(set) var submitting = false
|
||||
var errorMessage: String?
|
||||
|
||||
/// 判断订单是否允许展示普通退款入口。
|
||||
func canRefund(_ item: OrderEntity) -> Bool {
|
||||
(item.orderStatus == 18 || item.orderStatus == 30) &&
|
||||
item.orderType != 19 &&
|
||||
availableAmount(for: item) > 0
|
||||
}
|
||||
|
||||
/// 打开退款表单时初始化默认值。
|
||||
func begin(item: OrderEntity) {
|
||||
mode = .full
|
||||
amount = availableAmountText(for: item)
|
||||
reason = ""
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 提交普通订单退款。
|
||||
func submit(api: OrderServing, item: OrderEntity) async -> Bool {
|
||||
guard !submitting else { return false }
|
||||
guard canRefund(item) else {
|
||||
errorMessage = "当前订单不可退款"
|
||||
return false
|
||||
}
|
||||
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalizedReason.isEmpty else {
|
||||
errorMessage = "请输入退款原因"
|
||||
return false
|
||||
}
|
||||
|
||||
let refundAmount: String
|
||||
switch mode {
|
||||
case .full:
|
||||
refundAmount = availableAmountText(for: item)
|
||||
case .partial:
|
||||
guard let normalized = Self.normalizedMoney(amount) else {
|
||||
errorMessage = "请输入有效的退款金额"
|
||||
return false
|
||||
}
|
||||
guard (Decimal(string: normalized) ?? 0) <= availableAmount(for: item) else {
|
||||
errorMessage = "退款金额不能大于可退金额"
|
||||
return false
|
||||
}
|
||||
refundAmount = normalized
|
||||
}
|
||||
|
||||
submitting = true
|
||||
errorMessage = nil
|
||||
defer { submitting = false }
|
||||
|
||||
do {
|
||||
try await api.orderRefund(
|
||||
orderNumber: item.orderNumber,
|
||||
refundType: mode,
|
||||
refundAmount: refundAmount,
|
||||
refundReason: normalizedReason
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算普通订单可退金额。
|
||||
func availableAmount(for item: OrderEntity) -> Decimal {
|
||||
let paid = Self.decimalValue(item.actualPayAmount)
|
||||
if paid > 0 { return paid }
|
||||
return Self.decimalValue(item.actualRefundAmount.isEmpty ? item.refundAmount : item.actualRefundAmount)
|
||||
}
|
||||
|
||||
/// 返回可退金额展示和提交文案。
|
||||
func availableAmountText(for item: OrderEntity) -> String {
|
||||
Self.moneyText(availableAmount(for: item))
|
||||
}
|
||||
|
||||
/// 将输入金额规范成两位小数,空值、非法值和超过两位小数返回 nil。
|
||||
static func normalizedMoney(_ rawValue: String) -> String? {
|
||||
let text = rawValue.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")
|
||||
guard !text.isEmpty else { return nil }
|
||||
let pattern = #"^\d+(\.\d{1,2})?$"#
|
||||
guard text.range(of: pattern, options: .regularExpression) != nil,
|
||||
let value = Decimal(string: text),
|
||||
value > 0 else {
|
||||
return nil
|
||||
}
|
||||
return moneyText(value)
|
||||
}
|
||||
|
||||
private static func decimalValue(_ text: String) -> Decimal {
|
||||
Decimal(string: text.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: ",", with: "")) ?? 0
|
||||
}
|
||||
|
||||
private static func moneyText(_ value: Decimal) -> String {
|
||||
let number = NSDecimalNumber(decimal: value)
|
||||
let handler = NSDecimalNumberHandler(
|
||||
roundingMode: .plain,
|
||||
scale: 2,
|
||||
raiseOnExactness: false,
|
||||
raiseOnOverflow: false,
|
||||
raiseOnUnderflow: false,
|
||||
raiseOnDivideByZero: false
|
||||
)
|
||||
return String(format: "%.2f", number.rounding(accordingToBehavior: handler).doubleValue)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 历史拍摄 ViewModel,负责按订单号加载多点位历史拍摄媒体。
|
||||
final class HistoricalShootingInfoViewModel {
|
||||
private(set) var projectName = ""
|
||||
private(set) var projectTypeName = ""
|
||||
private(set) var spots: [PhotogSpotItem] = []
|
||||
private(set) var loading = false
|
||||
var errorMessage: String?
|
||||
|
||||
/// 加载历史拍摄信息,订单号为空时清空旧数据且不请求接口。
|
||||
func load(api: OrderServing, orderNumber: String) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else {
|
||||
reset(message: "订单号不能为空")
|
||||
return
|
||||
}
|
||||
|
||||
loading = true
|
||||
defer { loading = false }
|
||||
|
||||
do {
|
||||
let result = try await api.multiTravelShootHistory(orderNumber: normalized)
|
||||
projectName = result.projectName
|
||||
projectTypeName = result.projectTypeName
|
||||
spots = result.photogSpotList
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
reset(message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func reset(message: String) {
|
||||
projectName = ""
|
||||
projectTypeName = ""
|
||||
spots = []
|
||||
loading = false
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 多点旅拍任务上传 ViewModel,负责订单号、打卡点、云盘附件、本地附件和素材提交。
|
||||
final class MultiTravelTaskUploadViewModel {
|
||||
var orderNumber = ""
|
||||
var selectedSpotId: Int?
|
||||
private(set) var spots: [MultiTravelVerifiedScenicSpotItem] = []
|
||||
var selectedCloudFiles: [TaskCloudSelectionItem] = []
|
||||
var selectedLocalFiles: [TaskLocalUploadItem] = []
|
||||
private(set) var isLoadingSpots = false
|
||||
private(set) var isSubmitting = false
|
||||
private(set) var didSubmitSuccessfully = false
|
||||
var errorMessage: String?
|
||||
|
||||
private var loadedSpotOrderNumber = ""
|
||||
|
||||
/// 当前选中打卡点展示名。
|
||||
var selectedSpotName: String {
|
||||
spots.first(where: { $0.id == selectedSpotId })?.name ?? "请选择打卡点"
|
||||
}
|
||||
|
||||
/// 判断提交按钮是否满足基础启用条件。
|
||||
var canSubmit: Bool {
|
||||
!isSubmitting &&
|
||||
selectedSpotId != nil &&
|
||||
(!selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty) &&
|
||||
!selectedLocalFiles.contains(where: \.isUploading)
|
||||
}
|
||||
|
||||
/// 初始化任务上传表单订单号。
|
||||
init(initialOrderNumber: String = "") {
|
||||
orderNumber = initialOrderNumber
|
||||
}
|
||||
|
||||
/// 加载当前订单已核销打卡点,订单号为空时不请求接口。
|
||||
func loadSpots(api: OrderServing) async {
|
||||
let normalized = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !normalized.isEmpty else {
|
||||
resetSpotSelection()
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingSpots = true
|
||||
defer { isLoadingSpots = false }
|
||||
|
||||
do {
|
||||
let result = try await api.multiTravelVerifiedScenicSpotList(orderNumber: normalized)
|
||||
spots = result
|
||||
loadedSpotOrderNumber = normalized
|
||||
if !spots.contains(where: { $0.id == selectedSpotId }) {
|
||||
selectedSpotId = spots.first?.id
|
||||
}
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
resetSpotSelection()
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换选中的打卡点。
|
||||
func selectSpot(id: Int?) {
|
||||
selectedSpotId = id
|
||||
}
|
||||
|
||||
/// 合并云盘附件,按文件 ID 去重。
|
||||
func mergeCloudFiles(_ files: [TaskCloudSelectionItem]) {
|
||||
var next = selectedCloudFiles
|
||||
for file in files where !next.contains(where: { $0.id == file.id }) {
|
||||
next.append(file)
|
||||
}
|
||||
selectedCloudFiles = next
|
||||
}
|
||||
|
||||
/// 移除指定云盘附件。
|
||||
func removeCloudFile(id: Int) {
|
||||
selectedCloudFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 添加本地图片或视频附件。
|
||||
func addLocalFile(data: Data, fileName: String) {
|
||||
selectedLocalFiles.append(
|
||||
TaskLocalUploadItem(
|
||||
id: UUID(),
|
||||
data: data,
|
||||
fileName: fileName,
|
||||
fileType: Self.fileType(for: fileName),
|
||||
remark: "",
|
||||
uploadedURL: nil,
|
||||
progress: 0,
|
||||
errorMessage: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 移除指定本地附件。
|
||||
func removeLocalFile(id: UUID) {
|
||||
selectedLocalFiles.removeAll { $0.id == id }
|
||||
}
|
||||
|
||||
/// 提交多点旅拍任务素材;本地附件先上传 OSS,再提交最终 URL。
|
||||
@discardableResult
|
||||
func submit(api: OrderServing, uploadService: any OSSUploadServing, scenicId: Int?) async -> Bool {
|
||||
guard !isSubmitting else { return false }
|
||||
guard let requestContext = validateBeforeSubmit(scenicId: scenicId) else { return false }
|
||||
|
||||
isSubmitting = true
|
||||
didSubmitSuccessfully = false
|
||||
errorMessage = nil
|
||||
defer { isSubmitting = false }
|
||||
|
||||
do {
|
||||
let uploadFiles = try await uploadLocalFiles(uploadService: uploadService, scenicId: requestContext.scenicId)
|
||||
try await api.multiTravelUploadMaterial(
|
||||
MultiTravelUploadMaterialRequest(
|
||||
orderNumber: requestContext.orderNumber,
|
||||
scenicSpotId: requestContext.scenicSpotId,
|
||||
cloudFile: selectedCloudFiles.map { MultiTravelCloudFileItem(fileId: $0.id) },
|
||||
uploadFile: uploadFiles
|
||||
)
|
||||
)
|
||||
didSubmitSuccessfully = true
|
||||
return true
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// 清理错误文案。
|
||||
func clearError() {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
private func validateBeforeSubmit(scenicId: Int?) -> SubmitContext? {
|
||||
let normalizedOrderNumber = orderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard let scenicId, scenicId > 0 else {
|
||||
errorMessage = "请先选择景区"
|
||||
return nil
|
||||
}
|
||||
guard !normalizedOrderNumber.isEmpty else {
|
||||
errorMessage = "请先填写订单号"
|
||||
return nil
|
||||
}
|
||||
guard loadedSpotOrderNumber == normalizedOrderNumber else {
|
||||
errorMessage = "请先刷新打卡点"
|
||||
return nil
|
||||
}
|
||||
guard let selectedSpotId else {
|
||||
errorMessage = "请选择打卡点"
|
||||
return nil
|
||||
}
|
||||
guard !selectedCloudFiles.isEmpty || !selectedLocalFiles.isEmpty else {
|
||||
errorMessage = "请至少选择一个素材文件"
|
||||
return nil
|
||||
}
|
||||
guard !selectedLocalFiles.contains(where: \.isUploading) else {
|
||||
errorMessage = "文件上传中,请稍后提交"
|
||||
return nil
|
||||
}
|
||||
return SubmitContext(scenicId: scenicId, orderNumber: normalizedOrderNumber, scenicSpotId: selectedSpotId)
|
||||
}
|
||||
|
||||
private func uploadLocalFiles(uploadService: any OSSUploadServing, scenicId: Int) async throws -> [MultiTravelUploadFileItem] {
|
||||
var uploadFiles: [MultiTravelUploadFileItem] = []
|
||||
for index in selectedLocalFiles.indices {
|
||||
if let uploadedURL = selectedLocalFiles[index].uploadedURL {
|
||||
uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: uploadedURL))
|
||||
continue
|
||||
}
|
||||
|
||||
selectedLocalFiles[index].errorMessage = nil
|
||||
let fileID = selectedLocalFiles[index].id
|
||||
do {
|
||||
let url = try await uploadService.uploadTaskFile(
|
||||
data: selectedLocalFiles[index].data,
|
||||
fileName: selectedLocalFiles[index].fileName,
|
||||
fileType: selectedLocalFiles[index].fileType,
|
||||
scenicId: scenicId,
|
||||
onProgress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.updateLocalFileProgress(id: fileID, progress: progress)
|
||||
}
|
||||
}
|
||||
)
|
||||
selectedLocalFiles[index].uploadedURL = url
|
||||
selectedLocalFiles[index].progress = 100
|
||||
uploadFiles.append(MultiTravelUploadFileItem(fileName: selectedLocalFiles[index].fileName, fileUrl: url))
|
||||
} catch {
|
||||
selectedLocalFiles[index].progress = 0
|
||||
selectedLocalFiles[index].errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return uploadFiles
|
||||
}
|
||||
|
||||
private func updateLocalFileProgress(id: UUID, progress: Int) {
|
||||
guard let index = selectedLocalFiles.firstIndex(where: { $0.id == id }) else { return }
|
||||
selectedLocalFiles[index].progress = progress
|
||||
}
|
||||
|
||||
private func resetSpotSelection() {
|
||||
spots = []
|
||||
selectedSpotId = nil
|
||||
loadedSpotOrderNumber = ""
|
||||
isLoadingSpots = false
|
||||
}
|
||||
|
||||
private static func fileType(for fileName: String) -> Int {
|
||||
let ext = URL(fileURLWithPath: fileName).pathExtension.lowercased()
|
||||
return ["mp4", "mov", "m4v", "avi"].contains(ext) ? 1 : 2
|
||||
}
|
||||
|
||||
private struct SubmitContext {
|
||||
let scenicId: Int
|
||||
let orderNumber: String
|
||||
let scenicSpotId: Int
|
||||
}
|
||||
}
|
||||
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