Sync migrated iOS modules
This commit is contained in:
112
suixinkan/Features/Orders/API/OrdersAPI.swift
Normal file
112
suixinkan/Features/Orders/API/OrdersAPI.swift
Normal file
@ -0,0 +1,112 @@
|
||||
//
|
||||
// OrdersAPI.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
/// 订单服务协议,抽象订单列表、核销列表和核销操作以便测试替换。
|
||||
protocol OrderServing {
|
||||
/// 获取订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int,
|
||||
pageSize: Int,
|
||||
orderStatus: Int?,
|
||||
userPhone: String?,
|
||||
startTime: String?,
|
||||
endTime: String?,
|
||||
isRefined: Int?,
|
||||
isScenicAdmin: Bool
|
||||
) async throws -> ListPayload<OrderEntity>
|
||||
|
||||
/// 获取核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
|
||||
|
||||
/// 核销指定订单号。
|
||||
func writeOff(orderNumber: String) async throws
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单 API,封装订单管理、核销订单和订单核销接口。
|
||||
final class OrdersAPI: OrderServing {
|
||||
@ObservationIgnored private let client: APIClient
|
||||
|
||||
/// 初始化订单 API,并注入共享网络客户端。
|
||||
init(client: APIClient) {
|
||||
self.client = client
|
||||
}
|
||||
|
||||
/// 获取订单管理列表。
|
||||
func orderList(
|
||||
scenicId: Int,
|
||||
page: Int = 1,
|
||||
pageSize: Int = 20,
|
||||
orderStatus: Int? = nil,
|
||||
userPhone: String? = nil,
|
||||
startTime: String? = nil,
|
||||
endTime: String? = nil,
|
||||
isRefined: Int? = nil,
|
||||
isScenicAdmin: Bool = false
|
||||
) async throws -> ListPayload<OrderEntity> {
|
||||
var query = [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
if let orderStatus, orderStatus > 0 {
|
||||
query.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
|
||||
}
|
||||
if let userPhone, !userPhone.isEmpty {
|
||||
query.append(URLQueryItem(name: "user_phone", value: userPhone))
|
||||
}
|
||||
if let startTime, !startTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "start_time", value: startTime))
|
||||
}
|
||||
if let endTime, !endTime.isEmpty {
|
||||
query.append(URLQueryItem(name: "end_time", value: endTime))
|
||||
}
|
||||
if let isRefined, isRefined > 0 {
|
||||
query.append(URLQueryItem(name: "is_refined", value: "\(isRefined)"))
|
||||
}
|
||||
|
||||
return try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: isScenicAdmin ? "/api/app/scenic-admin/order/list" : "/api/yf-handset-app/photog/order/listv2",
|
||||
queryItems: query
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取核销订单列表。
|
||||
func writeOffList(scenicId: Int, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<WriteOffOrderItem> {
|
||||
try await client.send(
|
||||
APIRequest(
|
||||
method: .get,
|
||||
path: "/api/yf-handset-app/photog/order/order-verification-list",
|
||||
queryItems: [
|
||||
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
|
||||
URLQueryItem(name: "page", value: "\(max(page, 1))"),
|
||||
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// 核销指定订单号。
|
||||
func writeOff(orderNumber: String) async throws {
|
||||
let _: EmptyPayload = try await client.send(
|
||||
APIRequest(
|
||||
method: .post,
|
||||
path: "/api/yf-handset-app/photog/order/order-verify",
|
||||
body: WriteOffRequest(orderNumber: orderNumber)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
261
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
261
suixinkan/Features/Orders/Models/OrderModels.swift
Normal file
@ -0,0 +1,261 @@
|
||||
//
|
||||
// OrderModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 订单 Tab 内的子入口,表示订单管理或核销订单。
|
||||
enum OrdersEntry: Hashable {
|
||||
case storeOrders
|
||||
case verificationOrders
|
||||
}
|
||||
|
||||
/// 订单状态筛选实体,表示订单管理列表顶部的状态过滤项。
|
||||
struct OrderStatusFilter: Identifiable, Hashable {
|
||||
let id: Int
|
||||
let title: String
|
||||
}
|
||||
|
||||
/// 订单筛选常量,集中维护旧工程同步过来的订单状态。
|
||||
enum OrderFilters {
|
||||
static let statusFilters: [OrderStatusFilter] = [
|
||||
.init(id: 0, title: "全部"),
|
||||
.init(id: 18, title: "已付定金"),
|
||||
.init(id: 30, title: "已完成"),
|
||||
.init(id: 50, title: "已退款"),
|
||||
.init(id: -1, title: "需要精修"),
|
||||
.init(id: -2, title: "无需精修")
|
||||
]
|
||||
}
|
||||
|
||||
/// 订单管理列表实体,承载订单卡片展示和筛选判断所需字段。
|
||||
struct OrderEntity: Decodable, Identifiable, Equatable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let photogUid: Int
|
||||
let orderNumber: String
|
||||
let remark: String
|
||||
let scenicAreaId: Int
|
||||
let payTime: String
|
||||
let completeTime: String
|
||||
let orderStatus: Int
|
||||
let orderStatusName: String
|
||||
let storeId: Int?
|
||||
let userId: Int
|
||||
let projectId: Int
|
||||
let phone: String
|
||||
let orderAmount: String
|
||||
let actualPayAmount: String
|
||||
let actualRefundAmount: String
|
||||
let refundAmount: String
|
||||
let payTypeName: String
|
||||
let payType: Int
|
||||
let projectName: String
|
||||
let orderType: Int
|
||||
let orderTypeLabel: String
|
||||
let depositPayTime: String
|
||||
let createdAt: String
|
||||
let photoTravel: OrderPhotoTravel?
|
||||
let isNeedEdit: Bool
|
||||
let isRefined: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case photogUid = "photog_uid"
|
||||
case orderNumber = "order_number"
|
||||
case remark
|
||||
case scenicAreaId = "scenic_area_id"
|
||||
case payTime = "pay_time"
|
||||
case completeTime = "complete_time"
|
||||
case orderStatus = "order_status"
|
||||
case orderStatusName = "order_status_name"
|
||||
case storeId = "store_id"
|
||||
case userId = "user_id"
|
||||
case projectId = "project_id"
|
||||
case phone
|
||||
case orderAmount = "order_amount"
|
||||
case actualPayAmount = "actual_pay_amount"
|
||||
case actualRefundAmount = "actual_refund_amount"
|
||||
case refundAmount = "refund_amount"
|
||||
case payTypeName = "pay_type_name"
|
||||
case payType = "pay_type"
|
||||
case projectName = "project_name"
|
||||
case orderType = "order_type"
|
||||
case orderTypeLabel = "order_type_label"
|
||||
case depositPayTime = "deposit_pay_time"
|
||||
case createdAt = "created_at"
|
||||
case photoTravel = "photo_travel"
|
||||
case isNeedEdit
|
||||
case isRefined = "is_refined"
|
||||
}
|
||||
|
||||
/// 宽松解码订单字段,兼容后端数字和字符串混用。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
photogUid = try container.decodeLossyInt(forKey: .photogUid) ?? 0
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
remark = try container.decodeLossyString(forKey: .remark)
|
||||
scenicAreaId = try container.decodeLossyInt(forKey: .scenicAreaId) ?? 0
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
completeTime = try container.decodeLossyString(forKey: .completeTime)
|
||||
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
storeId = try container.decodeLossyInt(forKey: .storeId)
|
||||
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
|
||||
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
|
||||
phone = try container.decodeLossyString(forKey: .phone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
|
||||
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
|
||||
refundAmount = try container.decodeLossyString(forKey: .refundAmount)
|
||||
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
|
||||
payType = try container.decodeLossyInt(forKey: .payType) ?? 0
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
|
||||
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
|
||||
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
|
||||
createdAt = try container.decodeLossyString(forKey: .createdAt)
|
||||
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
|
||||
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
|
||||
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 旅拍订单附加信息实体,表示修图、视频和核销相关数量。
|
||||
struct OrderPhotoTravel: Decodable, Equatable {
|
||||
let orderPhotoNum: Int
|
||||
let orderVideoNum: Int
|
||||
let checkInTime: String
|
||||
let retouchGiftPhotoNum: Int
|
||||
let retouchGiftVideoNum: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderPhotoNum = "order_photo_num"
|
||||
case orderVideoNum = "order_video_num"
|
||||
case checkInTime = "check_in_time"
|
||||
case retouchGiftPhotoNum = "retouch_gift_photo_num"
|
||||
case retouchGiftVideoNum = "retouch_gift_video_num"
|
||||
}
|
||||
|
||||
/// 宽松解码旅拍附加信息。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderPhotoNum = try container.decodeLossyInt(forKey: .orderPhotoNum) ?? 0
|
||||
orderVideoNum = try container.decodeLossyInt(forKey: .orderVideoNum) ?? 0
|
||||
checkInTime = try container.decodeLossyString(forKey: .checkInTime)
|
||||
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0
|
||||
retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单列表实体,表示可核销订单卡片的展示和操作状态。
|
||||
struct WriteOffOrderItem: Decodable, Identifiable, Equatable {
|
||||
var id: String { orderNumber }
|
||||
|
||||
let orderNumber: String
|
||||
let orderVerificationStatus: String
|
||||
let payTime: String
|
||||
let projectName: String
|
||||
let userPhone: String
|
||||
let orderAmount: String
|
||||
let orderStatusName: String
|
||||
let orderVerificationTime: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
case orderVerificationStatus = "order_verification_status"
|
||||
case payTime = "pay_time"
|
||||
case projectName = "project_name"
|
||||
case userPhone = "user_phone"
|
||||
case orderAmount = "order_amount"
|
||||
case orderStatusName = "order_status_name"
|
||||
case orderVerificationTime = "order_verification_time"
|
||||
}
|
||||
|
||||
/// 宽松解码核销订单字段。
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
|
||||
orderVerificationStatus = try container.decodeLossyString(forKey: .orderVerificationStatus)
|
||||
payTime = try container.decodeLossyString(forKey: .payTime)
|
||||
projectName = try container.decodeLossyString(forKey: .projectName)
|
||||
userPhone = try container.decodeLossyString(forKey: .userPhone)
|
||||
orderAmount = try container.decodeLossyString(forKey: .orderAmount)
|
||||
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
|
||||
orderVerificationTime = try container.decodeLossyString(forKey: .orderVerificationTime)
|
||||
}
|
||||
}
|
||||
|
||||
/// 核销订单请求实体,提交手动输入的订单号。
|
||||
struct WriteOffRequest: Encodable {
|
||||
let orderNumber: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case orderNumber = "order_number"
|
||||
}
|
||||
}
|
||||
|
||||
private extension KeyedDecodingContainer {
|
||||
/// 将任意常见 JSON 值宽松解码成字符串。
|
||||
func decodeLossyString(forKey key: Key) throws -> String {
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return String(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? "1" : "0"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
/// 将 Int、Double、Bool 或数字字符串宽松解码成整数。
|
||||
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
let text = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if let intValue = Int(text) {
|
||||
return intValue
|
||||
}
|
||||
if let doubleValue = Double(text) {
|
||||
return Int(doubleValue)
|
||||
}
|
||||
}
|
||||
if let value = try? decodeIfPresent(Double.self, forKey: key) {
|
||||
return Int(value)
|
||||
}
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value ? 1 : 0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 将 Bool、Int 或字符串宽松解码成布尔值。
|
||||
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||
if let value = try? decodeIfPresent(Bool.self, forKey: key) {
|
||||
return value
|
||||
}
|
||||
if let value = try? decodeIfPresent(Int.self, forKey: key) {
|
||||
return value != 0
|
||||
}
|
||||
if let value = try? decodeIfPresent(String.self, forKey: key) {
|
||||
switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
||||
case "1", "true", "yes":
|
||||
return true
|
||||
case "0", "false", "no":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
26
suixinkan/Features/Orders/Orders.md
Normal file
26
suixinkan/Features/Orders/Orders.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Orders 模块业务逻辑
|
||||
|
||||
## 模块职责
|
||||
|
||||
Orders 模块负责登录后的订单 Tab。当前第一轮同步覆盖订单管理和核销订单两个入口,提供列表展示、筛选、分页、刷新和手动核销闭环。
|
||||
|
||||
本轮不迁移扫码核销、退款、完整订单详情、历史拍摄、任务上传、视频预告等长尾流程;这些入口统一进入安全占位页,后续按独立模块逐个迁移。
|
||||
|
||||
## 核心对象
|
||||
|
||||
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext`、`PermissionContext`、`AppRouter` 和 `OrdersAPI`。
|
||||
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态。
|
||||
- `OrdersAPI`:封装订单列表、核销订单列表和订单核销接口。
|
||||
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单。
|
||||
|
||||
## 数据流程
|
||||
|
||||
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID,从 `PermissionContext.currentRole` 读取角色 ID。景区管理员角色 `roleId == 53` 使用景区管理员订单接口,其他角色使用摄影师订单接口。
|
||||
|
||||
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。
|
||||
|
||||
核销订单支持手动输入订单号。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表。
|
||||
|
||||
## 路由边界
|
||||
|
||||
首页 `photographer_orders` 和 `/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单。订单 Tab 内点击详情、扫码、退款等未迁移能力时进入占位页,避免入口崩溃。
|
||||
196
suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
196
suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift
Normal file
@ -0,0 +1,196 @@
|
||||
//
|
||||
// OrdersViewModel.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
/// 订单页面 ViewModel,管理订单列表、核销列表、筛选条件和手动核销流程。
|
||||
final class OrdersViewModel {
|
||||
var selectedEntry: OrdersEntry = .storeOrders
|
||||
var selectedStatus = 0
|
||||
var searchPhone = ""
|
||||
var filterStartDate: Date?
|
||||
var filterEndDate: Date?
|
||||
private(set) var loading = false
|
||||
private(set) var loadingMore = false
|
||||
private(set) var isVerifying = false
|
||||
private(set) var currentVerifyingOrderNumber: String?
|
||||
|
||||
private(set) var storeOrders: [OrderEntity] = []
|
||||
private(set) var storeTotal = 0
|
||||
private(set) var storePage = 1
|
||||
private(set) var storeHasMore = false
|
||||
|
||||
private(set) var writeOffOrders: [WriteOffOrderItem] = []
|
||||
private(set) var writeOffTotal = 0
|
||||
private(set) var writeOffPage = 1
|
||||
private(set) var writeOffHasMore = false
|
||||
|
||||
/// 返回去除首尾空白后的手机号搜索值。
|
||||
var normalizedPhone: String? {
|
||||
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? nil : text
|
||||
}
|
||||
|
||||
/// 根据当前子入口刷新对应列表。
|
||||
func reload(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
switch selectedEntry {
|
||||
case .storeOrders:
|
||||
try await reloadStoreOrders(api: api, scenicId: scenicId, roleId: roleId, showLoading: showLoading)
|
||||
case .verificationOrders:
|
||||
try await reloadWriteOffOrders(api: api, scenicId: scenicId, showLoading: showLoading)
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新订单管理列表,缺少景区时清空旧数据。
|
||||
func reloadStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
storeOrders = []
|
||||
storeTotal = 0
|
||||
storePage = 1
|
||||
storeHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.orderList(
|
||||
scenicId: scenicId,
|
||||
page: 1,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatusQuery,
|
||||
userPhone: normalizedPhone,
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
storeOrders = result.list
|
||||
storeTotal = result.total
|
||||
storePage = 1
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 加载订单管理下一页。
|
||||
func loadMoreStoreOrders(api: OrderServing, scenicId: Int?, roleId: Int?) async throws {
|
||||
guard !loadingMore, storeHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = storePage + 1
|
||||
let result = try await api.orderList(
|
||||
scenicId: scenicId,
|
||||
page: nextPage,
|
||||
pageSize: pageSize,
|
||||
orderStatus: orderStatusQuery,
|
||||
userPhone: normalizedPhone,
|
||||
startTime: startTimeQuery,
|
||||
endTime: endTimeQuery,
|
||||
isRefined: refinedQuery,
|
||||
isScenicAdmin: roleId == 53
|
||||
)
|
||||
storeOrders.append(contentsOf: result.list)
|
||||
storeTotal = result.total
|
||||
storePage = nextPage
|
||||
storeHasMore = storeOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 刷新核销订单列表,缺少景区时清空旧数据。
|
||||
func reloadWriteOffOrders(api: OrderServing, scenicId: Int?, showLoading: Bool = true) async throws {
|
||||
guard let scenicId else {
|
||||
writeOffOrders = []
|
||||
writeOffTotal = 0
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = false
|
||||
return
|
||||
}
|
||||
|
||||
if showLoading { loading = true }
|
||||
defer { if showLoading { loading = false } }
|
||||
|
||||
let result = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = result.list
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 加载核销订单下一页。
|
||||
func loadMoreWriteOffOrders(api: OrderServing, scenicId: Int?) async throws {
|
||||
guard !loadingMore, writeOffHasMore, let scenicId else { return }
|
||||
loadingMore = true
|
||||
defer { loadingMore = false }
|
||||
|
||||
let nextPage = writeOffPage + 1
|
||||
let result = try await api.writeOffList(scenicId: scenicId, page: nextPage, pageSize: pageSize)
|
||||
writeOffOrders.append(contentsOf: result.list)
|
||||
writeOffTotal = result.total
|
||||
writeOffPage = nextPage
|
||||
writeOffHasMore = writeOffOrders.count < result.total && !result.list.isEmpty
|
||||
}
|
||||
|
||||
/// 手动核销订单,并在成功后刷新核销列表。
|
||||
func verify(api: OrderServing, scenicId: Int, orderNumber: String) async throws {
|
||||
guard !isVerifying else { return }
|
||||
|
||||
isVerifying = true
|
||||
currentVerifyingOrderNumber = orderNumber
|
||||
defer {
|
||||
isVerifying = false
|
||||
currentVerifyingOrderNumber = nil
|
||||
}
|
||||
|
||||
try await api.writeOff(orderNumber: orderNumber)
|
||||
let refreshed = try await api.writeOffList(scenicId: scenicId, page: 1, pageSize: pageSize)
|
||||
writeOffOrders = refreshed.list
|
||||
writeOffTotal = refreshed.total
|
||||
writeOffPage = 1
|
||||
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
|
||||
}
|
||||
|
||||
/// 用于测试重复提交场景的内部状态设置。
|
||||
func markVerifyingForTests(orderNumber: String) {
|
||||
isVerifying = true
|
||||
currentVerifyingOrderNumber = orderNumber
|
||||
}
|
||||
|
||||
private let pageSize = 20
|
||||
|
||||
private var orderStatusQuery: Int? {
|
||||
selectedStatus > 0 ? selectedStatus : nil
|
||||
}
|
||||
|
||||
private var startTimeQuery: String? {
|
||||
filterStartDate.map { Self.dayFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
private var endTimeQuery: String? {
|
||||
filterEndDate.map { Self.dayFormatter.string(from: $0) }
|
||||
}
|
||||
|
||||
private var refinedQuery: Int? {
|
||||
switch selectedStatus {
|
||||
case -1:
|
||||
return 1
|
||||
case -2:
|
||||
return 2
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static let dayFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.calendar = Calendar(identifier: .gregorian)
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
622
suixinkan/Features/Orders/Views/OrdersView.swift
Normal file
622
suixinkan/Features/Orders/Views/OrdersView.swift
Normal file
@ -0,0 +1,622 @@
|
||||
//
|
||||
// OrdersView.swift
|
||||
// suixinkan
|
||||
//
|
||||
// Created by Codex on 2026/6/22.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// 订单 Tab 根视图,展示订单管理和核销订单两个业务入口。
|
||||
struct OrdersView: View {
|
||||
@Environment(AccountContext.self) private var accountContext
|
||||
@Environment(PermissionContext.self) private var permissionContext
|
||||
@Environment(AppRouter.self) private var appRouter
|
||||
@Environment(RouterPath.self) private var router
|
||||
@Environment(OrdersAPI.self) private var ordersAPI
|
||||
@Environment(ToastCenter.self) private var toastCenter
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
@State private var viewModel = OrdersViewModel()
|
||||
@State private var manualOrderNumber = ""
|
||||
@State private var showDateFilterSheet = false
|
||||
@State private var showVerifyConfirmation = false
|
||||
@State private var pendingVerifyOrderNumber: String?
|
||||
|
||||
private var currentScenicId: Int? {
|
||||
accountContext.currentScenic?.id
|
||||
}
|
||||
|
||||
private var currentRoleId: Int? {
|
||||
permissionContext.currentRole?.id
|
||||
}
|
||||
|
||||
private var contentMaxWidth: CGFloat {
|
||||
horizontalSizeClass == .regular ? 900 : .infinity
|
||||
}
|
||||
|
||||
private var currentFilterTitle: String {
|
||||
OrderFilters.statusFilters.first(where: { $0.id == viewModel.selectedStatus })?.title ?? "全部"
|
||||
}
|
||||
|
||||
private var timeFilterTitle: String {
|
||||
if let start = viewModel.filterStartDate, let end = viewModel.filterEndDate {
|
||||
return "\(Self.dateFormatter.string(from: start)) 至 \(Self.dateFormatter.string(from: end))"
|
||||
}
|
||||
return "时间筛选"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
entrySection
|
||||
|
||||
if currentScenicId == nil {
|
||||
ContentUnavailableView(
|
||||
"缺少经营上下文",
|
||||
systemImage: "mountain.2",
|
||||
description: Text("请先在首页选择景区后查看订单。")
|
||||
)
|
||||
.frame(minHeight: 360)
|
||||
} else if viewModel.selectedEntry == .storeOrders {
|
||||
storeOrdersSection
|
||||
} else {
|
||||
writeOffSection
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: contentMaxWidth)
|
||||
.padding(.horizontal, AppMetrics.Spacing.pageHorizontal)
|
||||
.padding(.top, AppMetrics.Spacing.medium)
|
||||
.padding(.bottom, AppMetrics.Spacing.pageVertical)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.background(Color(hex: 0xF5F5F5).ignoresSafeArea())
|
||||
.navigationTitle("订单")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.refreshable { await reload(showLoading: false) }
|
||||
.task {
|
||||
viewModel.selectedEntry = appRouter.selectedOrdersEntry
|
||||
await reload()
|
||||
}
|
||||
.onChange(of: appRouter.selectedOrdersEntry) { _, entry in
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
Task { await reload() }
|
||||
}
|
||||
.onChange(of: accountContext.currentScenic?.id) { _, _ in
|
||||
Task { await reload() }
|
||||
}
|
||||
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
|
||||
Button("确认核销") {
|
||||
guard let orderNumber = pendingVerifyOrderNumber else { return }
|
||||
Task { await verify(orderNumber: orderNumber) }
|
||||
}
|
||||
Button("取消", role: .cancel) {
|
||||
pendingVerifyOrderNumber = nil
|
||||
}
|
||||
} message: {
|
||||
Text(pendingVerifyOrderNumber.map { "订单号:\($0)" } ?? "")
|
||||
}
|
||||
.sheet(isPresented: $showDateFilterSheet) {
|
||||
OrdersDateFilterSheet(
|
||||
startDate: $viewModel.filterStartDate,
|
||||
endDate: $viewModel.filterEndDate,
|
||||
onApply: { Task { await reload() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var entrySection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: 0) {
|
||||
entryButton("订单管理", entry: .storeOrders)
|
||||
entryButton("核销订单", entry: .verificationOrders)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.xxxSmall)
|
||||
.background(Color(hex: 0xF3F4F6), in: RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.small) {
|
||||
contextPill(
|
||||
icon: viewModel.selectedEntry == .storeOrders ? "mountain.2" : "qrcode",
|
||||
title: viewModel.selectedEntry == .storeOrders ? "当前景区" : "核销订单",
|
||||
value: viewModel.selectedEntry == .storeOrders ? (accountContext.currentScenic?.name ?? "--") : "\(viewModel.writeOffTotal)"
|
||||
)
|
||||
contextPill(
|
||||
icon: "doc.text",
|
||||
title: viewModel.selectedEntry == .storeOrders ? "订单总数" : "当前景区",
|
||||
value: viewModel.selectedEntry == .storeOrders ? "\(viewModel.storeTotal)" : (accountContext.currentScenic?.name ?? "--")
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var storeOrdersSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
storeFilterSection
|
||||
storeList
|
||||
}
|
||||
}
|
||||
|
||||
private var writeOffSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
writeOffActionSection
|
||||
writeOffList
|
||||
}
|
||||
}
|
||||
|
||||
private var storeFilterSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Menu {
|
||||
ForEach(OrderFilters.statusFilters) { filter in
|
||||
Button(filter.title) {
|
||||
viewModel.selectedStatus = filter.id
|
||||
Task { await reload() }
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
filterLabel(title: currentFilterTitle, icon: "chevron.down")
|
||||
}
|
||||
Button {
|
||||
showDateFilterSheet = true
|
||||
} label: {
|
||||
filterLabel(title: timeFilterTitle, icon: "calendar")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
TextField("输入手机号搜索", text: $viewModel.searchPhone)
|
||||
.keyboardType(.phonePad)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 40)
|
||||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
Button("搜索") {
|
||||
Task { await reload() }
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(height: 40)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var storeList: some View {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
if viewModel.loading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||
} else if viewModel.storeOrders.isEmpty {
|
||||
ContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.storeOrders) { item in
|
||||
storeOrderCard(item)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.storeOrders.last?.id else { return }
|
||||
Task { await loadMoreStoreOrders() }
|
||||
}
|
||||
}
|
||||
if viewModel.loadingMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var writeOffActionSection: some View {
|
||||
VStack(spacing: AppMetrics.Spacing.small) {
|
||||
Button {
|
||||
router.navigate(to: .placeholder(title: "扫码核销"))
|
||||
} label: {
|
||||
Label("扫码核销", systemImage: "qrcode.viewfinder")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 42)
|
||||
.background(Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("orders.scan")
|
||||
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
TextField("手动输入订单号", text: $manualOrderNumber)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 42)
|
||||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 8))
|
||||
.accessibilityIdentifier("orders.manualOrderNumber")
|
||||
|
||||
Button(viewModel.isVerifying ? "核销中" : "核销") {
|
||||
triggerManualVerify()
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.medium)
|
||||
.frame(height: 42)
|
||||
.background(AppDesign.primary, in: RoundedRectangle(cornerRadius: 8))
|
||||
.disabled(viewModel.isVerifying)
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var writeOffList: some View {
|
||||
LazyVStack(spacing: AppMetrics.Spacing.small) {
|
||||
if viewModel.loading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxLarge)
|
||||
} else if viewModel.writeOffOrders.isEmpty {
|
||||
ContentUnavailableView("暂无核销订单", systemImage: "tray", description: Text("可下拉刷新或切换景区查看。"))
|
||||
.frame(minHeight: 260)
|
||||
} else {
|
||||
ForEach(viewModel.writeOffOrders) { item in
|
||||
writeOffOrderCard(item)
|
||||
.onAppear {
|
||||
guard item.id == viewModel.writeOffOrders.last?.id else { return }
|
||||
Task { await loadMoreWriteOffOrders() }
|
||||
}
|
||||
}
|
||||
if viewModel.loadingMore {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, AppMetrics.Spacing.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func entryButton(_ title: String, entry: OrdersEntry) -> some View {
|
||||
let selected = viewModel.selectedEntry == entry
|
||||
return Button {
|
||||
switchEntry(entry)
|
||||
} label: {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: selected ? .semibold : .medium))
|
||||
.foregroundStyle(selected ? AppDesign.primary : AppDesign.textSecondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.background(selected ? .white : .clear, in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func contextPill(icon: String, title: String, value: String) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xxxSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.foregroundStyle(AppDesign.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.xSmall)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color(hex: 0xF8FAFC), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func filterLabel(title: String, icon: String) -> some View {
|
||||
HStack(spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.75)
|
||||
Spacer(minLength: AppMetrics.Spacing.xSmall)
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
}
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 38)
|
||||
.background(Color(hex: 0xF4F4F4), in: RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
|
||||
private func storeOrderCard(_ item: OrderEntity) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("订单号:\(item.orderNumber)")
|
||||
.font(.system(size: AppMetrics.FontSize.title3))
|
||||
.foregroundStyle(.black)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.72)
|
||||
Spacer()
|
||||
statusBadge(item.orderStatusName, color: statusColor(for: item.orderStatus))
|
||||
}
|
||||
|
||||
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
|
||||
if !item.orderTypeLabel.isEmpty {
|
||||
Text(item.orderTypeLabel)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
|
||||
orderInfoRow("付款金额", "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
|
||||
orderInfoRow("付款时间", item.payTime.isEmpty ? "--" : item.payTime)
|
||||
orderInfoRow("完成时间", item.completeTime.isEmpty ? "--" : item.completeTime)
|
||||
orderInfoRow("付款方式", item.payTypeName.isEmpty ? "--" : item.payTypeName)
|
||||
orderInfoRow("手机号", maskPhoneNumber(item.phone))
|
||||
orderInfoRow("关联项目", item.projectName.isEmpty ? "--" : item.projectName)
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
router.navigate(to: .placeholder(title: "订单详情"))
|
||||
}
|
||||
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
|
||||
}
|
||||
|
||||
private func writeOffOrderCard(_ item: WriteOffOrderItem) -> some View {
|
||||
VStack(alignment: .leading, spacing: AppMetrics.Spacing.xSmall) {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text("订单号:\(item.orderNumber)")
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .semibold))
|
||||
.foregroundStyle(.black)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
statusBadge(displayVerificationStatus(for: item), color: writeOffStatusColor(for: item))
|
||||
}
|
||||
|
||||
Text(item.payTime.isEmpty ? "--" : item.payTime)
|
||||
.font(.system(size: AppMetrics.FontSize.caption))
|
||||
.foregroundStyle(AppDesign.textSecondary)
|
||||
orderInfoRow("项目名称", item.projectName.isEmpty ? "--" : item.projectName)
|
||||
orderInfoRow("手机号", item.userPhone.isEmpty ? "--" : item.userPhone)
|
||||
orderInfoRow("订单状态", item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
|
||||
|
||||
HStack {
|
||||
Text("¥\(item.orderAmount)")
|
||||
.font(.system(size: AppMetrics.FontSize.title3, weight: .bold))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
Spacer()
|
||||
Button("详情") {
|
||||
router.navigate(to: .placeholder(title: "核销订单详情"))
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(AppDesign.primary)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 32)
|
||||
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 8))
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityIdentifier("orders.writeoff.detail.\(item.orderNumber)")
|
||||
|
||||
Button(verifyButtonTitle(for: item)) {
|
||||
pendingVerifyOrderNumber = item.orderNumber
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
.font(.system(size: AppMetrics.FontSize.footnote, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, AppMetrics.Spacing.small)
|
||||
.frame(height: 32)
|
||||
.background(canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF), in: RoundedRectangle(cornerRadius: 8))
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!canVerify(item) || viewModel.isVerifying)
|
||||
.accessibilityIdentifier("orders.writeoff.verify.\(item.orderNumber)")
|
||||
}
|
||||
}
|
||||
.padding(AppMetrics.Spacing.medium)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.white, in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func orderInfoRow(_ title: String, _ value: String, valueColor: Color = .black) -> some View {
|
||||
HStack(alignment: .top, spacing: AppMetrics.Spacing.xSmall) {
|
||||
Text(title)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline))
|
||||
.foregroundStyle(Color(hex: 0x4B5563))
|
||||
.frame(width: 72, alignment: .leading)
|
||||
Spacer(minLength: AppMetrics.Spacing.xSmall)
|
||||
Text(value)
|
||||
.font(.system(size: AppMetrics.FontSize.subheadline, weight: .medium))
|
||||
.foregroundStyle(valueColor)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.lineLimit(2)
|
||||
.minimumScaleFactor(0.75)
|
||||
}
|
||||
}
|
||||
|
||||
private func statusBadge(_ text: String, color: Color) -> some View {
|
||||
Text(text.isEmpty ? "--" : text)
|
||||
.font(.system(size: AppMetrics.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, AppMetrics.Spacing.xSmall)
|
||||
.padding(.vertical, AppMetrics.Spacing.xxSmall)
|
||||
.background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
|
||||
private func switchEntry(_ entry: OrdersEntry) {
|
||||
guard viewModel.selectedEntry != entry else { return }
|
||||
viewModel.selectedEntry = entry
|
||||
appRouter.selectOrders(entry: entry)
|
||||
Task { await reload() }
|
||||
}
|
||||
|
||||
private func reload(showLoading: Bool = true) async {
|
||||
do {
|
||||
try await viewModel.reload(
|
||||
api: ordersAPI,
|
||||
scenicId: currentScenicId,
|
||||
roleId: currentRoleId,
|
||||
showLoading: showLoading
|
||||
)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMoreStoreOrders() async {
|
||||
do {
|
||||
try await viewModel.loadMoreStoreOrders(api: ordersAPI, scenicId: currentScenicId, roleId: currentRoleId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadMoreWriteOffOrders() async {
|
||||
do {
|
||||
try await viewModel.loadMoreWriteOffOrders(api: ordersAPI, scenicId: currentScenicId)
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerManualVerify() {
|
||||
let orderNumber = manualOrderNumber.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !orderNumber.isEmpty else {
|
||||
toastCenter.show("请输入订单号")
|
||||
return
|
||||
}
|
||||
pendingVerifyOrderNumber = orderNumber
|
||||
showVerifyConfirmation = true
|
||||
}
|
||||
|
||||
private func verify(orderNumber: String) async {
|
||||
guard let scenicId = currentScenicId else { return }
|
||||
do {
|
||||
try await viewModel.verify(api: ordersAPI, scenicId: scenicId, orderNumber: orderNumber)
|
||||
manualOrderNumber = ""
|
||||
pendingVerifyOrderNumber = nil
|
||||
toastCenter.show("核销成功")
|
||||
} catch {
|
||||
toastCenter.show(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func canVerify(_ item: WriteOffOrderItem) -> Bool {
|
||||
let status = displayVerificationStatus(for: item)
|
||||
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
|
||||
}
|
||||
|
||||
private func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
|
||||
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return text.isEmpty ? "待核销" : text
|
||||
}
|
||||
|
||||
private func verifyButtonTitle(for item: WriteOffOrderItem) -> String {
|
||||
canVerify(item) ? "核销" : "已处理"
|
||||
}
|
||||
|
||||
private func writeOffStatusColor(for item: WriteOffOrderItem) -> Color {
|
||||
canVerify(item) ? AppDesign.primary : Color(hex: 0x22C55E)
|
||||
}
|
||||
|
||||
private func statusColor(for status: Int) -> Color {
|
||||
switch status {
|
||||
case 18:
|
||||
return AppDesign.primary
|
||||
case 30:
|
||||
return Color(hex: 0x22C55E)
|
||||
case 50:
|
||||
return Color(hex: 0xEF4444)
|
||||
default:
|
||||
return Color(hex: 0x6B7280)
|
||||
}
|
||||
}
|
||||
|
||||
private func maskPhoneNumber(_ phone: String) -> String {
|
||||
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed }
|
||||
let prefix = trimmed.prefix(3)
|
||||
let suffix = trimmed.suffix(4)
|
||||
return "\(prefix)****\(suffix)"
|
||||
}
|
||||
|
||||
private static let dateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter
|
||||
}()
|
||||
}
|
||||
|
||||
/// 订单日期筛选 Sheet,负责选择开始和结束日期。
|
||||
private struct OrdersDateFilterSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Binding var startDate: Date?
|
||||
@Binding var endDate: Date?
|
||||
let onApply: () -> Void
|
||||
|
||||
@State private var draftStartDate = Date()
|
||||
@State private var draftEndDate = Date()
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date)
|
||||
DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date)
|
||||
}
|
||||
.navigationTitle("时间筛选")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("清空") {
|
||||
startDate = nil
|
||||
endDate = nil
|
||||
onApply()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("确定") {
|
||||
startDate = min(draftStartDate, draftEndDate)
|
||||
endDate = max(draftStartDate, draftEndDate)
|
||||
onApply()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
draftStartDate = startDate ?? Date()
|
||||
draftEndDate = endDate ?? Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
OrdersView()
|
||||
.environment(AccountContext())
|
||||
.environment(PermissionContext())
|
||||
.environment(AppRouter())
|
||||
.environment(RouterPath())
|
||||
.environment(OrdersAPI(client: APIClient()))
|
||||
.environment(ToastCenter())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user