Add order tail flows and migrate queue, message, settlement, and audit modules.

Wire deposit orders, historical shooting, and multi-travel uploads into Orders, and replace home placeholders for queue management, message center, scenic settlement, and withdrawal audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-25 13:39:02 +08:00
parent fb8430889b
commit 1a0d1c25f4
63 changed files with 9823 additions and 58 deletions

View File

@ -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
)
)
}
}

View File

@ -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

View File

@ -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。
退款、历史拍摄、任务上传、视频预告等尚未迁移的能力仍进入占位页,避免入口崩溃。
未明确接口的订单后续能力继续进入占位页,避免入口崩溃。

View File

@ -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 = "缺少门店上下文,当前展示订单摘要。"

View File

@ -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
}
}

View File

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

View File

@ -17,6 +17,8 @@ struct StoreOrderDetailView: View {
let item: OrderEntity
@State private var viewModel: OrderDetailViewModel
@State private var refundViewModel = OrderRefundViewModel()
@State private var showRefundSheet = false
/// 使 ViewModel
init(item: OrderEntity) {
@ -96,10 +98,36 @@ struct StoreOrderDetailView: View {
}
Section("后续功能") {
placeholderButton("历史拍摄", title: "历史拍摄")
placeholderButton("任务上传", title: "任务上传")
placeholderButton("视频预告", title: "视频预告")
placeholderButton("退款", title: "订单退款")
Button {
router.navigate(to: .orders(.historicalShooting(orderNumber: viewModel.display.orderNumber)))
} label: {
navigationRow("历史拍摄")
}
if item.orderType == 19 {
Button {
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: viewModel.display.orderNumber)))
} label: {
navigationRow("任务上传")
}
}
Button {
router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "视频预告")))
} label: {
navigationRow("视频预告")
}
Button {
router.navigate(to: .orders(.orderTrailer(orderNumber: viewModel.display.orderNumber, title: "尾片上传")))
} label: {
navigationRow("尾片上传")
}
if refundViewModel.canRefund(item) {
Button {
refundViewModel.begin(item: item)
showRefundSheet = true
} label: {
navigationRow("退款")
}
}
}
}
.listStyle(.insetGrouped)
@ -117,6 +145,17 @@ struct StoreOrderDetailView: View {
} message: {
Text(viewModel.errorMessage ?? "")
}
.sheet(isPresented: $showRefundSheet) {
OrderRefundSheet(
item: item,
viewModel: refundViewModel,
onCancel: { showRefundSheet = false },
onSubmit: {
Task { await submitRefund() }
}
)
.presentationDetents([.medium, .large])
}
}
private var displayPayTime: String {
@ -136,20 +175,24 @@ struct StoreOrderDetailView: View {
)
}
///
private func placeholderButton(_ label: String, title: String) -> some View {
Button {
router.navigate(to: .placeholder(title: title))
} label: {
HStack {
Text(label)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
///
private func navigationRow(_ label: String) -> some View {
HStack {
Text(label)
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
.foregroundStyle(AppDesign.textSecondary)
}
.foregroundStyle(AppDesign.textPrimary)
}
/// 退
private func submitRefund() async {
let success = await refundViewModel.submit(api: ordersAPI, item: item)
guard success else { return }
showRefundSheet = false
toastCenter.show("退款申请已提交")
await viewModel.load(api: ordersAPI, fallbackStoreId: accountContext.currentStore?.id, forceReload: true)
}
/// 0
@ -158,6 +201,79 @@ struct StoreOrderDetailView: View {
}
}
/// 退退
private struct OrderRefundSheet: View {
let item: OrderEntity
@Bindable var viewModel: OrderRefundViewModel
let onCancel: () -> Void
let onSubmit: () -> Void
@FocusState private var focusedField: RefundInputField?
var body: some View {
NavigationStack {
Form {
Section("订单") {
OrderDetailRow(title: "订单号", value: item.orderNumber)
OrderDetailRow(title: "可退金额", value: "¥\(viewModel.availableAmountText(for: item))")
}
Section("退款方式") {
Picker("退款方式", selection: $viewModel.mode) {
ForEach(OrderRefundMode.allCases) { mode in
Text(mode.title).tag(mode)
}
}
.pickerStyle(.segmented)
if viewModel.mode == .partial {
TextField("请输入退款金额", text: $viewModel.amount)
.keyboardType(.decimalPad)
.focused($focusedField, equals: .amount)
}
}
Section("退款原因") {
TextEditor(text: $viewModel.reason)
.frame(minHeight: 96)
.focused($focusedField, equals: .reason)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.font(.system(size: AppMetrics.FontSize.footnote))
.foregroundStyle(Color(hex: 0xEF4444))
}
}
}
.navigationTitle("订单退款")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消", action: onCancel)
.disabled(viewModel.submitting)
}
ToolbarItem(placement: .confirmationAction) {
Button(viewModel.submitting ? "提交中..." : "提交", action: onSubmit)
.disabled(viewModel.submitting)
}
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button("完成") {
focusedField = nil
}
}
}
}
}
}
/// 退
private enum RefundInputField: Hashable {
case amount
case reason
}
///
struct WriteOffOrderDetailView: View {
@Environment(AccountContext.self) private var accountContext

View File

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