新增门店订单与核销管理列表,对齐 Android 订单能力并完善账号级缓存。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 14:46:10 +08:00
parent c4de3d17d0
commit 08492df6ce
51 changed files with 3668 additions and 102 deletions

View File

@ -243,11 +243,12 @@
};
};
buildConfigurationList = 939AC7912FE3F832004B22E4 /* Build configuration list for PBXProject "suixinkan" */;
developmentRegion = en;
developmentRegion = "zh-Hans";
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
"zh-Hans",
);
mainGroup = 939AC78D2FE3F832004B22E4;
minimizedProjectReferenceProxies = 1;

View File

@ -15,7 +15,7 @@ App 模块负责应用入口、根视图切换、全局状态注入、主导航
- `suixinkanApp`SwiftUI 应用入口,挂载 `RootView`
- `RootView`:创建 `AppSession``AccountContext``PermissionContext``ScenicSpotContext``AppRouter``ToastCenter``APIClient` 和业务 API并注入 SwiftUI Environment。
- `AppSession`:保存认证阶段和正式 token只负责登录态不承载业务资料。
- `AccountContext`:保存当前账号资料、景区作用域和门店作用域。
- `AccountContext`:保存当前账号资料、账号类型、景区作用域和门店作用域,并提供账号级缓存前缀
- `PermissionContext`:保存角色权限、当前角色和扁平化权限 URI。
- `ScenicSpotContext`:保存当前景区下的景点/打卡点列表和加载状态。
- `AppRouter`:保存当前 Tab 和每个 Tab 自己的导航路径。

View File

@ -46,6 +46,9 @@ enum AppUITestRouteDriver {
appRouter.select(tab)
case .orders(let entry):
appRouter.selectOrders(entry: entry)
case .orderPush(let route):
appRouter.select(.orders)
appRouter.router(for: .orders).navigate(to: .orders(route))
case .destination(let homeRoute):
appRouter.select(.home)
appRouter.router(for: .home).navigate(to: .home(homeRoute))

View File

@ -51,6 +51,8 @@ extension OrdersRoute {
MultiTravelTaskUploadView(initialOrderNumber: orderNumber)
case .orderTrailer(let orderNumber, let title):
OrderTrailerView(orderNumber: orderNumber, title: title)
case .writeOffList:
WriteOffListView()
}
}
}
@ -107,10 +109,17 @@ final class AppRouter: ObservableObject {
selectedTab = .orders
}
///
///
func routeToOrderVerification(scannedCode: String) {
pendingOrderScanCode = scannedCode
selectOrders(entry: .verificationOrders)
selectedTab = .orders
router(for: .orders).navigate(to: .orders(.writeOffList))
}
///
func openWriteOffList() {
selectedTab = .orders
router(for: .orders).navigate(to: .orders(.writeOffList))
}
///

View File

@ -6,6 +6,7 @@
//
import Combine
import Foundation
///
struct AccountProfile: Codable, Equatable {
@ -61,14 +62,24 @@ struct BusinessScope: Codable, Equatable, Identifiable {
/// /
final class AccountContext: ObservableObject {
@Published private(set) var profile: AccountProfile?
@Published private(set) var accountType: String?
@Published private(set) var scenicScopes: [BusinessScope] = []
@Published private(set) var storeScopes: [BusinessScope] = []
@Published var currentScenic: BusinessScope?
@Published var currentStore: BusinessScope?
/// Android
var accountCachePrefix: String? {
let userId = profile?.userId.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !userId.isEmpty else { return nil }
let type = accountType?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return type.isEmpty ? userId : "\(type)_\(userId)"
}
///
func applyLogin(profile: AccountProfile? = nil) {
func applyLogin(profile: AccountProfile? = nil, accountType: String? = nil) {
self.profile = profile
self.accountType = accountType?.trimmingCharacters(in: .whitespacesAndNewlines)
}
///
@ -122,6 +133,7 @@ final class AccountContext: ObservableObject {
/// 退
func reset() {
profile = nil
accountType = nil
scenicScopes = []
storeScopes = []
currentScenic = nil

View File

@ -63,7 +63,7 @@ final class AuthSessionCoordinator {
let scenicScopes = response.scenicScopes
let storeScopes = response.storeScopes
let identity = response.currentAccountIdentity
accountContext.applyLogin(profile: profile)
accountContext.applyLogin(profile: profile, accountType: identity?.accountType)
accountContext.replaceScopes(scenic: scenicScopes, stores: storeScopes)
appSession.beginRestoring(token: token)
@ -89,7 +89,7 @@ final class AuthSessionCoordinator {
appSession.markLoggedIn(token: token)
saveSnapshot(
profile: accountContext.profile ?? profile,
accountType: identity?.accountType,
accountType: accountContext.accountType ?? identity?.accountType,
businessUserId: identity?.businessUserId,
permissionContext: permissionContext,
accountContext: accountContext
@ -99,7 +99,7 @@ final class AuthSessionCoordinator {
saveSnapshot(
profile: accountContext.profile ?? profile,
accountType: identity?.accountType,
accountType: accountContext.accountType ?? identity?.accountType,
businessUserId: identity?.businessUserId,
permissionContext: permissionContext,
accountContext: accountContext
@ -113,7 +113,7 @@ final class AuthSessionCoordinator {
accountContext.replaceProfile(profile)
saveSnapshot(
profile: profile,
accountType: snapshotStore.load()?.accountType,
accountType: accountContext.accountType ?? snapshotStore.load()?.accountType,
businessUserId: snapshotStore.load()?.businessUserId,
currentRoleCode: snapshotStore.load()?.currentRoleCode,
accountContext: accountContext

View File

@ -83,7 +83,7 @@ final class SessionBootstrapper {
///
private func restoreCachedSnapshot(to accountContext: AccountContext) -> AccountSnapshot? {
guard let snapshot = snapshotStore.load() else { return nil }
accountContext.applyLogin(profile: snapshot.profile)
accountContext.applyLogin(profile: snapshot.profile, accountType: snapshot.accountType)
accountContext.replaceScopes(
scenic: snapshot.scenicScopes,
stores: snapshot.storeScopes,
@ -99,7 +99,7 @@ final class SessionBootstrapper {
snapshotStore.save(
AccountSnapshot(
profile: accountContext.profile,
accountType: existing?.accountType,
accountType: accountContext.accountType ?? existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleCode: permissionContext.currentAppRole?.rawValue ?? existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes,

View File

@ -1,6 +1,7 @@
{
"images" : [
{
"filename" : "icon_1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@ -12,6 +13,7 @@
"value" : "dark"
}
],
"filename" : "icon_1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
@ -23,6 +25,7 @@
"value" : "tinted"
}
],
"filename" : "icon_1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@ -16,6 +16,23 @@ enum AppDesign {
static let placeholder = Color(hex: 0xA8B2C1)
static let success = Color(hex: 0x14964A)
static let warning = Color(hex: 0xFF7B00)
// MARK: - Android OrderListScreen
static let orderPageBackground = Color(hex: 0xF5F5F5)
static let orderInputBackground = Color(hex: 0xF4F4F4)
static let orderLabelColor = Color(hex: 0x4B5563)
static let orderBorderLight = Color(hex: 0xEEEEEE)
static let orderRefundText = Color(hex: 0xEF4444)
static let orderRefundBackground = Color(hex: 0xFFE7E7)
static let orderStatusPaidText = Color(hex: 0x0073FF)
static let orderStatusPaidBackground = Color(hex: 0xEFF6FF)
static let orderStatusCompletedText = Color(hex: 0x10B981)
static let orderStatusCompletedBackground = Color(hex: 0xF0FDF4)
static let orderStatusRefundedText = Color(hex: 0xEF4444)
static let orderStatusRefundedBackground = Color(hex: 0xFFE7E7)
static let orderStatusOtherText = Color(hex: 0xFF7B00)
static let orderStatusOtherBackground = Color(hex: 0xFFF0E2)
}
extension Color {

View File

@ -132,7 +132,7 @@ final class PushNotificationManager: NSObject, UNUserNotificationCenterDelegate
case .order:
router.selectOrders(entry: .storeOrders)
case .verificationOrder:
router.selectOrders(entry: .verificationOrders)
router.openWriteOffList()
case .task:
navigateHomeRoute(.taskManagement)
case .queue:

View File

@ -115,7 +115,7 @@ final class AccountSnapshotStore {
save(
AccountSnapshot(
profile: accountContext.profile ?? existing?.profile,
accountType: existing?.accountType,
accountType: accountContext.accountType ?? existing?.accountType,
businessUserId: existing?.businessUserId,
currentRoleCode: currentRoleCode ?? existing?.currentRoleCode,
scenicScopes: accountContext.scenicScopes,

View File

@ -18,8 +18,10 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作
## 常用应用
`HomeCommonMenuStore` 使用 UserDefaults 按角色保存常用应用 URIkey`home.common.menu.uris.role.{roleCode}`
`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI
- 当前版本使用账号 + 角色作用域保存常用应用 URI避免不同业务账号的同一角色互相覆盖旧版角色 key 会在首次读取时迁移。
- 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。
- 首页常用应用按 Android `Constants.menuList` 白名单过滤iOS 已接入但 Android 首页未登记的 URI 不进入首页常用应用。
- 读取时按当前角色权限过滤不可用 URI。
- 添加和移除时按同义 URI 去重。
- 不同角色的常用应用互不影响。

View File

@ -72,6 +72,7 @@ enum HomeRoute: Hashable {
enum HomeMenuResolvedRoute: Equatable {
case tab(AppTab)
case orders(OrdersEntry)
case orderPush(OrdersRoute)
case destination(HomeRoute)
case unsupported(uri: String, title: String, reason: String)
case placeholder(uri: String, title: String)

View File

@ -73,7 +73,7 @@ enum HomeMenuRouter {
case "photographer_orders", "/scenic-order-manage":
return .orders(.storeOrders)
case "verification_order":
return .orders(.verificationOrders)
return .orderPush(.writeOffList)
case "photographer_stats":
return .tab(.statistics)
case "space_settings", "basic_info":
@ -330,7 +330,7 @@ enum HomePermissionRouteAuditor {
return HomePermissionRouteAudit(
routable: entries.filter { entry in
switch entry.route {
case .tab, .orders, .destination:
case .tab, .orders, .orderPush, .destination:
return true
case .unsupported, .placeholder:
return false

View File

@ -10,6 +10,37 @@ import Foundation
/// URI
struct HomeCommonMenuStore {
static let storageKeyPrefix = "home.common.menu.uris.role."
static let accountScopedStorageKeyPrefix = "home.common.menu.uris.account."
/// Android `Constants.menuList` URI
static let androidHomeMenuURIs: Set<String> = [
"space_settings",
"wallet",
"cloud_management",
"asset_management",
"task_management",
"schedule_management",
"system_settings",
"message_center",
"checkin_points",
"sample_management",
"live_stream_management",
"verification_order",
"live_album",
"pm",
"location_report",
"registration_invitation",
"store",
"fly",
"pilot_cert",
"task_management_editor",
"pm_manager",
"pilot_controller",
"/scenic-queue",
"operating-area",
"/scenic-order-manage",
"cooperation_order"
]
private let defaults: UserDefaults
@ -27,7 +58,7 @@ struct HomeCommonMenuStore {
let trimmed = uri.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let canonical = HomeMenuRouter.canonicalURI(for: trimmed, availableURIs: availableURIs)
return availableURIs.contains(canonical) ? canonical : nil
return Self.isAndroidHomeMenuURI(canonical) && availableURIs.contains(canonical) ? canonical : nil
}
let deduplicated = unique(resolved)
if deduplicated.count > 4 {
@ -40,23 +71,24 @@ struct HomeCommonMenuStore {
func load(
menuItems: [HomeMenuItem],
roleCode: String?,
accountScope: String? = nil,
legacyRoleId: Int? = nil,
topLevelPermissionURIs: [String]
) -> [String] {
guard let roleCode, !roleCode.isEmpty else { return [] }
migrateLegacyStorageIfNeeded(roleCode: roleCode, legacyRoleId: legacyRoleId)
migrateLegacyStorageIfNeeded(roleCode: roleCode, accountScope: accountScope, legacyRoleId: legacyRoleId)
let availableURIs = Set(menuItems.map(\.uri))
let key = storageKey(for: roleCode)
let key = Self.storageKey(roleCode: roleCode, accountScope: accountScope)
let saved = defaults.stringArray(forKey: key)
if let saved, !saved.isEmpty {
let filtered = Self.unique(saved.compactMap { uri -> String? in
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
return availableURIs.contains(resolved) ? resolved : nil
return Self.isAndroidHomeMenuURI(resolved) && availableURIs.contains(resolved) ? resolved : nil
})
if !filtered.isEmpty {
save(filtered, roleCode: roleCode)
save(filtered, roleCode: roleCode, accountScope: accountScope)
return filtered
}
}
@ -65,54 +97,63 @@ struct HomeCommonMenuStore {
orderedTopLevelURIs: topLevelPermissionURIs,
availableURIs: availableURIs
)
save(defaultURIs, roleCode: roleCode)
save(defaultURIs, roleCode: roleCode, accountScope: accountScope)
return defaultURIs
}
/// URI
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?) -> [String] {
func add(_ uri: String, current: [String], menuItems: [HomeMenuItem], roleCode: String?, accountScope: String? = nil) -> [String] {
guard let roleCode, !roleCode.isEmpty else { return current }
let availableURIs = Set(menuItems.map(\.uri))
let resolved = HomeMenuRouter.canonicalURI(for: uri, availableURIs: availableURIs)
guard availableURIs.contains(resolved) else { return current }
guard Self.isAndroidHomeMenuURI(resolved), availableURIs.contains(resolved) else { return current }
guard !current.contains(where: { HomeMenuRouter.menuAliasKey(for: $0) == HomeMenuRouter.menuAliasKey(for: resolved) }) else {
return current
}
let next = current + [resolved]
save(next, roleCode: roleCode)
save(next, roleCode: roleCode, accountScope: accountScope)
return next
}
/// URI URI
func remove(_ uri: String, current: [String], roleCode: String?) -> [String] {
func remove(_ uri: String, current: [String], roleCode: String?, accountScope: String? = nil) -> [String] {
guard let roleCode, !roleCode.isEmpty else { return current }
let aliasKey = HomeMenuRouter.menuAliasKey(for: uri)
let next = current.filter { HomeMenuRouter.menuAliasKey(for: $0) != aliasKey }
save(next, roleCode: roleCode)
save(next, roleCode: roleCode, accountScope: accountScope)
return next
}
/// URI
func save(_ uris: [String], roleCode: String) {
defaults.set(Self.unique(uris), forKey: storageKey(for: roleCode))
func save(_ uris: [String], roleCode: String, accountScope: String? = nil) {
defaults.set(Self.unique(uris), forKey: Self.storageKey(roleCode: roleCode, accountScope: accountScope))
}
/// key legacy role id key
func migrateLegacyStorageIfNeeded(roleCode: String, legacyRoleId: Int?) {
let newKey = storageKey(for: roleCode)
guard defaults.stringArray(forKey: newKey) == nil,
let legacyRoleId,
let legacyData = defaults.stringArray(forKey: legacyStorageKey(for: legacyRoleId)),
!legacyData.isEmpty else {
/// key roleCode legacy role id key
func migrateLegacyStorageIfNeeded(roleCode: String, accountScope: String? = nil, legacyRoleId: Int?) {
let newKey = Self.storageKey(roleCode: roleCode, accountScope: accountScope)
guard defaults.stringArray(forKey: newKey) == nil else { return }
let legacyKeys = [
legacyStorageKey(for: roleCode),
legacyRoleId.map { legacyStorageKey(for: $0) }
].compactMap { $0 }
for legacyKey in legacyKeys {
guard let legacyData = defaults.stringArray(forKey: legacyKey), !legacyData.isEmpty else { continue }
defaults.set(legacyData, forKey: newKey)
defaults.removeObject(forKey: legacyKey)
return
}
defaults.set(legacyData, forKey: newKey)
defaults.removeObject(forKey: legacyStorageKey(for: legacyRoleId))
}
/// role_code UserDefaults key
private func storageKey(for roleCode: String) -> String {
Self.storageKeyPrefix + roleCode
/// role_code UserDefaults key
static func storageKey(roleCode: String, accountScope: String?) -> String {
let normalizedScope = accountScope?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if normalizedScope.isEmpty {
return storageKeyPrefix + roleCode
}
return accountScopedStorageKeyPrefix + normalizedScope + ".role." + roleCode
}
/// numeric role id key
@ -120,6 +161,16 @@ struct HomeCommonMenuStore {
Self.storageKeyPrefix + String(roleId)
}
/// role_code key
private func legacyStorageKey(for roleCode: String) -> String {
Self.storageKeyPrefix + roleCode
}
/// URI Android
private static func isAndroidHomeMenuURI(_ uri: String) -> Bool {
androidHomeMenuURIs.contains(uri)
}
/// URI URI
private static func unique(_ uris: [String]) -> [String] {
var seen = Set<String>()

View File

@ -9,6 +9,7 @@ import SwiftUI
///
struct HomeMoreFunctionsView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var appRouter: AppRouter
@EnvironmentObject private var router: RouterPath
@ -124,13 +125,19 @@ struct HomeMoreFunctionsView: View {
Button {
let roleCode = permissionContext.currentAppRole?.rawValue
if isCommon {
commonUris = commonMenuStore.remove(item.uri, current: commonUris, roleCode: roleCode)
commonUris = commonMenuStore.remove(
item.uri,
current: commonUris,
roleCode: roleCode,
accountScope: accountContext.accountCachePrefix
)
} else {
commonUris = commonMenuStore.add(
item.uri,
current: commonUris,
menuItems: viewModel.menuItems,
roleCode: roleCode
roleCode: roleCode,
accountScope: accountContext.accountCachePrefix
)
}
} label: {
@ -187,6 +194,9 @@ struct HomeMoreFunctionsView: View {
appRouter.select(tab)
case .orders(let entry):
appRouter.selectOrders(entry: entry)
case .orderPush(let route):
appRouter.select(.orders)
appRouter.router(for: .orders).navigate(to: .orders(route))
case .destination(let route):
if route == .moreFunctions {
return
@ -210,6 +220,7 @@ struct HomeMoreFunctionsView: View {
commonUris = commonMenuStore.load(
menuItems: viewModel.menuItems,
roleCode: roleCode,
accountScope: accountContext.accountCachePrefix,
legacyRoleId: legacyRoleId,
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
)

View File

@ -395,6 +395,9 @@ struct HomeView: View {
appRouter.select(tab)
case .orders(let entry):
appRouter.selectOrders(entry: entry)
case .orderPush(let route):
appRouter.select(.orders)
appRouter.router(for: .orders).navigate(to: .orders(route))
case .destination(let route):
router.navigate(to: .home(route))
case .unsupported(let uri, let title, _):
@ -415,6 +418,7 @@ struct HomeView: View {
commonUris = commonMenuStore.load(
menuItems: viewModel.menuItems,
roleCode: roleCode,
accountScope: accountContext.accountCachePrefix,
legacyRoleId: legacyRoleId,
topLevelPermissionURIs: permissionContext.topLevelPermissionURIs(for: roleCode)
)

View File

@ -20,7 +20,7 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页
- `statistics`:数据
- `profile`:我的
`HomeRootView` 已接入真实的 `HomeView``OrdersRootView`接入真实的 `OrdersView``StatisticsRootView` 已接入真实的 `StatisticsView``ProfileRootView` 已接入真实的 `ProfileView`
`HomeRootView` 已接入真实的 `HomeView``OrdersRootView`按角色接入 `StoreOrderListView``StoreAdminOrderListView``StatisticsRootView` 已接入真实的 `StatisticsView``ProfileRootView` 已接入真实的 `ProfileView`
## 导航流程

View File

@ -21,7 +21,17 @@ final class MainTabBadgeViewModel: ObservableObject {
}
do {
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: 1)
let result = try await api.writeOffList(
scenicId: scenicId,
storeId: storeId,
page: 1,
pageSize: 1,
orderStatus: nil,
orderVerifyStatus: nil,
startTime: nil,
endTime: nil,
userPhone: nil
)
pendingWriteOffCount = min(result.total, 99)
} catch {
pendingWriteOffCount = nil

View File

@ -14,10 +14,16 @@ struct HomeRootView: View {
}
}
///
///
struct OrdersRootView: View {
@EnvironmentObject private var permissionContext: PermissionContext
var body: some View {
OrdersView()
if permissionContext.currentAppRole?.isStoreAdmin == true {
StoreAdminOrderListView()
} else {
StoreOrderListView()
}
}
}

View File

@ -25,11 +25,51 @@ protocol OrderServing {
) async throws -> ListPayload<OrderEntity>
///
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem>
func writeOffList(
scenicId: Int,
storeId: Int?,
page: Int,
pageSize: Int,
orderStatus: Int?,
orderVerifyStatus: Int?,
startTime: String?,
endTime: String?,
userPhone: String?
) async throws -> ListPayload<WriteOffOrderItem>
///
func writeOff(orderNumber: String) async throws
///
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws
///
func cancelOrder(orderNumber: String) async throws
/// /
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws
///
func storeOrderList(
storeId: Int?,
page: Int,
pageSize: Int,
userPhone: String?,
orderStatus: Int?,
orderType: Int?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<StoreOrderListItem>
/// 退
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws
///
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse
@ -111,7 +151,17 @@ final class OrdersAPI: OrderServing {
}
///
func writeOffList(scenicId: Int, storeId: Int? = nil, page: Int = 1, pageSize: Int = 20) async throws -> ListPayload<WriteOffOrderItem> {
func writeOffList(
scenicId: Int,
storeId: Int? = nil,
page: Int = 1,
pageSize: Int = 20,
orderStatus: Int? = nil,
orderVerifyStatus: Int? = nil,
startTime: String? = nil,
endTime: String? = nil,
userPhone: String? = nil
) async throws -> ListPayload<WriteOffOrderItem> {
var queryItems = [
URLQueryItem(name: "scenic_id", value: "\(scenicId)"),
URLQueryItem(name: "page", value: "\(max(page, 1))"),
@ -120,6 +170,21 @@ final class OrdersAPI: OrderServing {
if let storeId, storeId > 0 {
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
}
if let orderStatus, orderStatus > 0 {
queryItems.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
}
if let orderVerifyStatus, orderVerifyStatus > 0 {
queryItems.append(URLQueryItem(name: "order_verify_status", value: "\(orderVerifyStatus)"))
}
if let startTime, !startTime.isEmpty {
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
}
if let endTime, !endTime.isEmpty {
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
}
if let userPhone, !userPhone.isEmpty {
queryItems.append(URLQueryItem(name: "user_phone", value: userPhone))
}
return try await client.send(
APIRequest(
@ -256,4 +321,106 @@ final class OrdersAPI: OrderServing {
)
)
}
///
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/order/refined",
queryItems: [
URLQueryItem(name: "order_number", value: orderNumber),
URLQueryItem(name: "is_refined", value: "\(isRefined)")
]
)
)
}
///
func cancelOrder(orderNumber: String) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/order/cancel",
body: CancelOrderRequest(orderNumber: orderNumber)
)
)
}
/// /
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/yf-handset-app/photog/order/gift-retouch",
body: SendGiftRequest(orderNumber: orderNumber, photoNum: photoNum, videoNum: videoNum)
)
)
}
///
func storeOrderList(
storeId: Int? = nil,
page: Int = 1,
pageSize: Int = 20,
userPhone: String? = nil,
orderStatus: Int? = nil,
orderType: Int? = nil,
startTime: String? = nil,
endTime: String? = nil
) async throws -> ListPayload<StoreOrderListItem> {
var queryItems = [
URLQueryItem(name: "page", value: "\(max(page, 1))"),
URLQueryItem(name: "page_size", value: "\(max(pageSize, 1))")
]
if let storeId, storeId > 0 {
queryItems.append(URLQueryItem(name: "store_id", value: "\(storeId)"))
}
if let userPhone, !userPhone.isEmpty {
queryItems.append(URLQueryItem(name: "user_phone", value: userPhone))
}
if let orderStatus, orderStatus > 0 {
queryItems.append(URLQueryItem(name: "order_status", value: "\(orderStatus)"))
}
if let orderType {
queryItems.append(URLQueryItem(name: "order_type", value: "\(orderType)"))
}
if let startTime, !startTime.isEmpty {
queryItems.append(URLQueryItem(name: "start_time", value: startTime))
}
if let endTime, !endTime.isEmpty {
queryItems.append(URLQueryItem(name: "end_time", value: endTime))
}
return try await client.send(
APIRequest(
method: .get,
path: "/api/app/store/order/list",
queryItems: queryItems
)
)
}
/// 退
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws {
let _: EmptyPayload = try await client.send(
APIRequest(
method: .post,
path: "/api/app/store/order/refund",
body: StoreOrderRefundRequest(
storeId: storeId,
orderNumber: orderNumber,
refundType: refundType.rawValue,
refundAmount: refundAmount,
refundReason: refundReason
)
)
)
}
}

View File

@ -29,6 +29,29 @@ enum OrderFilters {
.init(id: -1, title: "需要精修"),
.init(id: -2, title: "无需精修")
]
/// Tab
static let storeAdminStatusFilters: [OrderStatusFilter] = [
.init(id: 0, title: "全部"),
.init(id: 18, title: "已付定金"),
.init(id: 30, title: "已完成"),
.init(id: 50, title: "已退款")
]
/// Tab
static let writeOffStatusFilters: [OrderStatusFilter] = [
.init(id: 0, title: "全部"),
.init(id: 18, title: "已支付"),
.init(id: 30, title: "已完成"),
.init(id: 1, title: "已核销"),
.init(id: 2, title: "未核销")
]
}
/// Android StoreOrderItem
enum StoreOrderType {
static let offlineProduct = 4
static let multiTravelShoot = 19
}
///
@ -59,8 +82,9 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
let depositPayTime: String
let createdAt: String
let photoTravel: OrderPhotoTravel?
let multiTravel: OrderListMultiTravel?
let isNeedEdit: Bool
let isRefined: Int
var isRefined: Int
let canBindReferral: Int
let showOrderSource: Int
let referralOrderNo: String
@ -92,6 +116,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
case depositPayTime = "deposit_pay_time"
case createdAt = "created_at"
case photoTravel = "photo_travel"
case multiTravel = "multi_travel"
case isNeedEdit
case isRefined = "is_refined"
case canBindReferral = "can_bind_referral"
@ -128,6 +153,7 @@ struct OrderEntity: Decodable, Identifiable, Equatable, Hashable {
depositPayTime = try container.decodeLossyString(forKey: .depositPayTime)
createdAt = try container.decodeLossyString(forKey: .createdAt)
photoTravel = try container.decodeIfPresent(OrderPhotoTravel.self, forKey: .photoTravel)
multiTravel = try container.decodeIfPresent(OrderListMultiTravel.self, forKey: .multiTravel)
isNeedEdit = try container.decodeLossyBool(forKey: .isNeedEdit) ?? true
isRefined = try container.decodeLossyInt(forKey: .isRefined) ?? 0
canBindReferral = try container.decodeLossyInt(forKey: .canBindReferral) ?? 0
@ -145,6 +171,10 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
let checkInTime: String
let retouchGiftPhotoNum: Int
let retouchGiftVideoNum: Int
let canCancelOrder: Bool
let needCheckIn: Bool
let canGiftRetouch: Bool
let retouchGiftTime: String
enum CodingKeys: String, CodingKey {
case orderPhotoNum = "order_photo_num"
@ -152,6 +182,10 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
case checkInTime = "check_in_time"
case retouchGiftPhotoNum = "retouch_gift_photo_num"
case retouchGiftVideoNum = "retouch_gift_video_num"
case canCancelOrder = "can_cancel_order"
case needCheckIn = "need_check_in"
case canGiftRetouch = "can_gift_retouch"
case retouchGiftTime = "retouch_gift_time"
}
///
@ -162,6 +196,87 @@ struct OrderPhotoTravel: Decodable, Equatable, Hashable {
checkInTime = try container.decodeLossyString(forKey: .checkInTime)
retouchGiftPhotoNum = try container.decodeLossyInt(forKey: .retouchGiftPhotoNum) ?? 0
retouchGiftVideoNum = try container.decodeLossyInt(forKey: .retouchGiftVideoNum) ?? 0
canCancelOrder = try container.decodeLossyBool(forKey: .canCancelOrder) ?? false
needCheckIn = try container.decodeLossyBool(forKey: .needCheckIn) ?? false
canGiftRetouch = try container.decodeLossyBool(forKey: .canGiftRetouch) ?? false
retouchGiftTime = try container.decodeLossyString(forKey: .retouchGiftTime)
}
}
///
struct OrderListMultiTravel: Decodable, Equatable, Hashable {
let materialList: [OrderMediaFile]
enum CodingKeys: String, CodingKey {
case materialList = "material_list"
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
materialList = try container.decodeIfPresent([OrderMediaFile].self, forKey: .materialList) ?? []
}
}
/// Tab Android StoreOrderItem
struct StoreOrderListItem: Decodable, Identifiable, Equatable, Hashable {
var id: String { orderNumber }
let orderNumber: String
let orderType: Int
let orderTypeLabel: String
let createdAt: String
let orderStatus: Int
let orderStatusName: String
let actualPayAmount: String
let actualRefundAmount: String
let payTypeName: String
let payTime: String
let completeTime: String
let userId: Int
let phone: String
let projectId: Int
let projectName: String
let remark: String
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case orderType = "order_type"
case orderTypeLabel = "order_type_label"
case createdAt = "created_at"
case orderStatus = "order_status"
case orderStatusName = "order_status_name"
case actualPayAmount = "actual_pay_amount"
case actualRefundAmount = "actual_refund_amount"
case payTypeName = "pay_type_name"
case payTime = "pay_time"
case completeTime = "complete_time"
case userId = "user_id"
case phone
case projectId = "project_id"
case projectName = "project_name"
case remark
}
///
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
orderNumber = try container.decodeLossyString(forKey: .orderNumber)
orderType = try container.decodeLossyInt(forKey: .orderType) ?? 0
orderTypeLabel = try container.decodeLossyString(forKey: .orderTypeLabel)
createdAt = try container.decodeLossyString(forKey: .createdAt)
orderStatus = try container.decodeLossyInt(forKey: .orderStatus) ?? 0
orderStatusName = try container.decodeLossyString(forKey: .orderStatusName)
actualPayAmount = try container.decodeLossyString(forKey: .actualPayAmount)
actualRefundAmount = try container.decodeLossyString(forKey: .actualRefundAmount)
payTypeName = try container.decodeLossyString(forKey: .payTypeName)
payTime = try container.decodeLossyString(forKey: .payTime)
completeTime = try container.decodeLossyString(forKey: .completeTime)
userId = try container.decodeLossyInt(forKey: .userId) ?? 0
phone = try container.decodeLossyString(forKey: .phone)
projectId = try container.decodeLossyInt(forKey: .projectId) ?? 0
projectName = try container.decodeLossyString(forKey: .projectName)
remark = try container.decodeLossyString(forKey: .remark)
}
}
@ -283,6 +398,56 @@ enum OrderRefundMode: Int, CaseIterable, Identifiable, Hashable {
}
}
///
struct CancelOrderRequest: Encodable, Equatable {
let orderNumber: String
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
}
}
/// /
struct SendGiftRequest: Encodable, Equatable {
let orderNumber: String
let photoNum: Int
let videoNum: Int
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case photoNum = "photo_num"
case videoNum = "video_num "
}
}
///
struct UpdateRefinedRequest: Encodable, Equatable {
let orderNumber: String
let isRefined: Int
enum CodingKeys: String, CodingKey {
case orderNumber = "order_number"
case isRefined = "is_refined"
}
}
/// 退
struct StoreOrderRefundRequest: Encodable, Equatable {
let storeId: Int
let orderNumber: String
let refundType: Int
let refundAmount: String
let refundReason: String
enum CodingKeys: String, CodingKey {
case storeId = "store_id"
case orderNumber = "order_number"
case refundType = "refund_type"
case refundAmount = "refund_amount"
case refundReason = "refund_reason"
}
}
/// 退
struct OrderRefundRequest: Encodable, Equatable {
let orderNumber: String
@ -305,6 +470,7 @@ enum OrdersRoute: Hashable {
case historicalShooting(orderNumber: String)
case multiTravelTaskUpload(orderNumber: String)
case orderTrailer(orderNumber: String, title: String)
case writeOffList
}
///

View File

@ -2,49 +2,89 @@
## 模块职责
Orders 模块负责登录后的订单 Tab。当前已覆盖订单管理、核销订单和押金订单相关入口,提供列表展示、筛选、分页、刷新、扫码核销、手动核销、押金核销、押金退款、历史拍摄、任务上传、尾片入口,以及 orderType 11/14 订单来源(带客单绑定)闭环。
Orders 模块负责登录后的订单 Tab 与订单相关二级页面。当前已对齐 Android `zhiflyfollow` 订单列表能力:主订单列表(摄影师/非店长)、店长押金订单 Tab、独立核销管理页以及押金订单独立入口、历史拍摄、任务上传、尾片入口 orderType 11/14 订单来源(带客单绑定)闭环。
订单 Tab 内的门店订单详情页与核销订单详情页暂未接入,列表卡片仅展示摘要信息
订单 Tab 内不再展示「订单管理 / 核销订单」分段核销管理通过首页菜单、TabBar 扫码或路由 push 进入独立页面
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;这些能力后续按明确接口再补
门店订单详情页与核销订单详情页暂未接入,列表卡片仅展示摘要信息与卡片内操作
当前仍不迁移独立视频播放器增强、尾片审核进度、上传后自动关联订单尾片等后端未体现的能力;多点位核销的定位 + 打卡点 Sheet 若 iOS 尚无完整能力,列表内仅预留扫码入口。
## 页面结构
| 场景 | 视图 | 说明 |
|------|------|------|
| 订单 Tab · 非店长 | `StoreOrderListView` | 对齐 Android `OrderListScreen` |
| 订单 Tab · 店长 | `StoreAdminOrderListView` | 对齐 Android `DepositOrderListScreen` |
| 核销管理 | `WriteOffListView` | 对齐 Android `WriteOffListScreen`,独立 push 页 |
| 押金订单入口 | `DepositOrderEntryView` | 首页 `deposit_order*` 仍保留独立入口 |
`OrdersRootView``AppRoleCode.storeAdmin` 分流店长 Tab 与主订单 Tab。
## 核心对象
- `OrdersView`:订单 Tab 根视图,读取 `AccountContext``PermissionContext``AppRouter` `OrdersAPI`
- `OrdersViewModel`:管理订单子入口、筛选条件、分页状态、加载状态和手动核销状态
- `DepositOrderListViewModel`:管理押金订单分页、核销、退款和操作后刷新
- `DepositOrderDetailViewModel`:管理押金订单详情加载,缺少门店时不发起请求
- `DepositOrderShootingInfoViewModel`:管理押金订单单个打卡点的底片、成片和评价
- `OrderRefundViewModel`:管理普通订单退款金额校验和提交保护(详情页接入前保留逻辑层)
- `HistoricalShootingInfoViewModel`:管理多点位订单历史拍摄媒体展示
- `MultiTravelTaskUploadViewModel`管理多点旅拍任务上传的订单号、已核销打卡点、云盘附件、本地附件、OSS 上传和提交
- `OrdersAPI`封装订单列表、核销订单列表和订单核销接口
- `OrdersEntry`:表示订单 Tab 内部入口,包含订单管理和核销订单
- `OrdersRoute`:表示订单模块二级页面路由,包含押金详情、历史拍摄、任务上传和尾片入口。
- `OrderNumberParser`:负责从扫码内容中解析订单号,支持 URL query、键值文本和纯订单号
- `StoreOrderListView`订单 Tab 根视图,组合 `OrderListHeaderView``OrderListCardView` `OrdersViewModel`
- `StoreAdminOrderListView`:店长订单 Tab组合共享头部与店长卡片使用 `StoreAdminOrderListViewModel`
- `WriteOffListView`:核销管理页,使用 `WriteOffListViewModel`,含横向状态 pill、日期筛选与扫码/手动核销
- `OrderListHeaderView`:白底状态/日期筛选头部;手机号搜索使用系统 `.searchable`
- `OrderListCardView`:主订单卡片,对齐 Android `OrderView` 展示顺序与操作区
- `OrderListComponents`:状态 Chip、信息行、操作按钮、精修单选等共享组件
- `OrderDialogViews`:退款、赠送修图/视频、店长退款、核销确认弹窗
- `OrdersViewModel`:主订单列表分页、筛选、卡片操作(退款/核销/精修/取消/赠送)与订单来源
- `StoreAdminOrderListViewModel`店长订单列表`storeOrderList` / `storeOrderRefund`
- `WriteOffListViewModel`:核销列表筛选、分页与核销提交
- `DepositOrderListViewModel`:押金订单分页、核销、退款(独立押金入口
- `OrderRefundViewModel`:普通订单退款金额校验与提交
- `OrdersAPI`:订单列表、核销列表、精修更新、取消、赠送、店长列表/退款等接口。
- `OrdersEntry`:订单 Tab 内部入口枚举,保留 `verificationOrders` 供测试/推送兼容。
- `OrdersRoute`:二级路由,含 `writeOffList`、押金详情、历史拍摄、任务上传、尾片入口。
- `OrderNumberParser`:从扫码内容解析订单号。
## 数据流程
订单页面从 `AccountContext.currentScenic` 读取当前景区 ID`PermissionContext.currentAppRole` 读取当前角色。景区管理员角色(`AppRoleCode.scenicAdmin`)使用景区管理员订单接口,其他角色使用摄影师订单接口。
### 主订单列表
订单管理支持状态筛选、手机号搜索、开始/结束日期筛选。筛选变更后从第一页重新加载,滚动到列表底部时加载下一页。列表卡片展示订单摘要,暂不支持点击进入详情页
`AccountContext.currentScenic` 读取景区 ID`PermissionContext.currentAppRole` 读取角色。景区管理员(`scenicAdmin`)使用景区管理员订单接口且卡片只读(隐藏退款/核销/拨号/精修/赠送/取消)
核销订单支持扫码和手动输入订单号。扫码使用 AVFoundation不缓存扫码结果扫码成功后先用 `OrderNumberParser` 提取订单号,命中当前核销列表时滚动定位并高亮卡片,未命中时填入订单号并允许继续核销。核销前统一弹确认框,确认后调用核销接口。核销成功后重新拉取第一页核销订单,并重置分页状态;核销失败只清理提交状态,不刷新列表
支持状态筛选(含精修筛选项)、手机号搜索、日期范围 Sheet 筛选。筛选变更后从第一页重新加载,滚动到底加载下一页
押金订单入口由首页 `deposit_order_detail``deposit_order``deposit_order_shooting_info` 进入。没有订单上下文时先展示押金订单页,用户可以手输订单号进入详情,也可以从列表进入详情。押金详情复用门店订单详情接口,按当前门店 ID 和订单号加载;拍摄点列表进入拍摄信息页,展示评价、底片和成片
卡片展示订单号、状态、创建时间、类型、订单来源、操作行、上传任务条orderType 19、精修单选、信息行、条件行、退款信息、备注、历史拍摄缩略图与取消订单按钮。操作受 `hideOrderWriteActions` 控制
普通订单退款逻辑已保留在 `OrderRefundViewModel`,待详情页接入后再开放入口。
### 店长订单 Tab
历史拍摄按订单号请求 `/api/yf-handset-app/photog/order/multi-travel/shoot-history`,仅展示项目、拍摄点和已有媒体,不做上传、下载、删除或编辑
`AccountContext.currentStore` 读取门店 ID调用 `GET /api/app/store/order/list`。筛选为全部/已支付/已完成/已退款。卡片展示项目、金额、时间、UID、手机号与退款/部分退款;线下产品订单额外展示核销按钮。仅 `orderType == 19` 可进详情
任务上传仅对 `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` 完成相册预览上传,不新增独立订单尾片接口
独立页面,调用 `writeOffList` 并支持 `order_status``order_verify_status``start_time``end_time``user_phone` 查询参数。横向 pill全部/已支付/已完成/已核销/未核销
订单来源(带客单绑定)仅对 `orderType == 11`(摄影师跟拍)或 `14`(线下扫码)展示,景区管理员只读不可操作。订单卡片嵌入 `OrderSourcePicker` Sheet选择合作获客员 → 加载其可绑定带客单 → 确认绑定。全页流程使用 `ReferralOrderSelectView`。绑定接口为 `POST .../sale-user/bind-referral-order`;普通订单退款成功后若存在 `referralOrder.id`,异步调用 `POST .../sale-user/referral-orders/{id}/status`status=3。获客员与带客单数据由 `CooperationOrderAPI` 提供
扫码使用 `OrderCodeScannerView`命中列表时滚动高亮核销前弹确认框成功后刷新第一页。TabBar 全局扫码通过 `pendingOrderScanCode` 交给核销页消费
### 押金订单(独立入口)
首页 `deposit_order_detail``deposit_order``deposit_order_shooting_info` 进入 `DepositOrderEntryView`,与 Tab 内店长列表 UI 分离但共用部分 API。
### 订单来源(带客单绑定)
仅对 `orderType == 11``14` 展示,景区管理员只读。绑定接口 `POST .../sale-user/bind-referral-order`;退款成功后若存在带客单则异步更新状态。
### 历史拍摄与任务上传
历史拍摄:`GET .../photog/order/multi-travel/shoot-history`。任务上传:仅 orderType 19先拉已核销打卡点再上传素材。
## 路由边界
首页 `photographer_orders``/scenic-order-manage` 会进入订单管理,`verification_order` 会进入核销订单,`deposit_order_detail``deposit_order``deposit_order_shooting_info` 会进入押金订单页。订单详情、核销订单详情、押金详情、押金拍摄信息、历史拍摄、任务上传和订单尾片已接入 `AppRoute.orders` 真实路由push 后继续隐藏 TabBar。
| 入口 | 目标 |
|------|------|
| `photographer_orders``/scenic-order-manage` | 订单 Tab · 主列表或店长列表 |
| `verification_order` | push `OrdersRoute.writeOffList`(核销管理) |
| `deposit_order*` | push 押金订单页 |
| TabBar 扫码 | 核销管理页消费扫码结果 |
未明确接口的订单后续能力继续进入占位页,避免入口崩溃
`AppRouter.routeToOrderVerification` 切换订单 Tab 并 push 核销页,不再设置 `selectedOrdersEntry`
未明确接口的订单能力继续占位,避免入口崩溃。
## 设计常量
订单列表专用色值与间距定义在 `AppDesign`(页面背景 `#F5F5F5`、输入背景 `#F4F4F4`、状态 Chip 色等与 Android 对齐)。

View File

@ -36,8 +36,21 @@ final class OrdersViewModel: ObservableObject {
@Published var orderSourceSelections: [String: OrderSourceSelection] = [:]
@Published var loadingOrderSourceLeadsFor: String?
@Published var refundingOrder: OrderEntity?
@Published var sendGiftContext: SendGiftContext?
private var loadingOrderSourcePeople = false
/// /
struct SendGiftContext: Equatable, Identifiable {
var id: String { orderNumber }
let orderNumber: String
let nowPhotoNum: Int
let nowVideoNum: Int
var sendPhotoNum: Int
var sendVideoNum: Int
}
static let orderTypePhotographerFollow = 11
static let orderTypePhotographerFollowOfflineScan = 14
@ -125,7 +138,17 @@ final class OrdersViewModel: ObservableObject {
if showLoading { loading = true }
defer { if showLoading { loading = false } }
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
let result = try await api.writeOffList(
scenicId: scenicId,
storeId: storeId,
page: 1,
pageSize: pageSize,
orderStatus: nil,
orderVerifyStatus: nil,
startTime: nil,
endTime: nil,
userPhone: nil
)
writeOffOrders = result.list
writeOffTotal = result.total
writeOffPage = 1
@ -139,7 +162,17 @@ final class OrdersViewModel: ObservableObject {
defer { loadingMore = false }
let nextPage = writeOffPage + 1
let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: nextPage, pageSize: pageSize)
let result = try await api.writeOffList(
scenicId: scenicId,
storeId: storeId,
page: nextPage,
pageSize: pageSize,
orderStatus: nil,
orderVerifyStatus: nil,
startTime: nil,
endTime: nil,
userPhone: nil
)
writeOffOrders.append(contentsOf: result.list)
writeOffTotal = result.total
writeOffPage = nextPage
@ -158,13 +191,82 @@ final class OrdersViewModel: ObservableObject {
}
try await api.writeOff(orderNumber: orderNumber)
let refreshed = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: pageSize)
let refreshed = try await api.writeOffList(
scenicId: scenicId,
storeId: storeId,
page: 1,
pageSize: pageSize,
orderStatus: nil,
orderVerifyStatus: nil,
startTime: nil,
endTime: nil,
userPhone: nil
)
writeOffOrders = refreshed.list
writeOffTotal = refreshed.total
writeOffPage = 1
writeOffHasMore = writeOffOrders.count < refreshed.total && !refreshed.list.isEmpty
}
/// 退
func shouldShowRefundButton(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
return (order.orderStatus == 18 || order.orderStatus == 30) && order.orderType != 19
}
///
func shouldShowVerifyButton(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }
if order.orderType == 19 { return true }
guard let photoTravel = order.photoTravel else { return false }
return photoTravel.needCheckIn && order.orderStatus != 30
}
///
func updateRefinedChoice(api: OrderServing, orderNumber: String, isRefined: Int) async throws {
try await api.updateRefinedChoice(orderNumber: orderNumber, isRefined: isRefined)
if let index = storeOrders.firstIndex(where: { $0.orderNumber == orderNumber }) {
storeOrders[index].isRefined = isRefined
}
}
///
func cancelOrder(api: OrderServing, scenicId: Int?, appRole: AppRoleCode?, orderNumber: String) async throws {
try await api.cancelOrder(orderNumber: orderNumber)
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, showLoading: false)
}
/// /
func prepareSendGift(for order: OrderEntity) {
let photoTravel = order.photoTravel
sendGiftContext = SendGiftContext(
orderNumber: order.orderNumber,
nowPhotoNum: photoTravel?.orderPhotoNum ?? 0,
nowVideoNum: photoTravel?.orderVideoNum ?? 0,
sendPhotoNum: photoTravel?.retouchGiftPhotoNum ?? 0,
sendVideoNum: photoTravel?.retouchGiftVideoNum ?? 0
)
}
/// /
func submitSendGift(
api: OrderServing,
scenicId: Int?,
appRole: AppRoleCode?,
photoNum: Int,
videoNum: Int
) async throws {
guard let context = sendGiftContext else { return }
try await api.sendGift(orderNumber: context.orderNumber, photoNum: photoNum, videoNum: videoNum)
sendGiftContext = nil
try await reloadStoreOrders(api: api, scenicId: scenicId, appRole: appRole, showLoading: false)
}
///
var currentFilterTitle: String {
OrderFilters.statusFilters.first(where: { $0.id == selectedStatus })?.title ?? "全部"
}
///
func shouldShowOrderSource(order: OrderEntity, appRole: AppRoleCode?) -> Bool {
guard !Self.isScenicAdminReadOnly(appRole: appRole) else { return false }

View File

@ -0,0 +1,112 @@
//
// StoreAdminOrderListViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
@MainActor
/// Tab ViewModel Android DepositOrderListViewModel
final class StoreAdminOrderListViewModel: ObservableObject {
@Published var searchPhone = ""
@Published var selectedStatus = 0
@Published var filterStartDate: Date?
@Published var filterEndDate: Date?
@Published private(set) var orders: [StoreOrderListItem] = []
@Published private(set) var loading = false
@Published private(set) var loadingMore = false
@Published private(set) var total = 0
@Published private(set) var page = 1
@Published private(set) var hasMore = false
private let pageSize = 20
///
var normalizedPhone: String? {
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
///
var currentFilterTitle: String {
OrderFilters.storeAdminStatusFilters.first(where: { $0.id == selectedStatus })?.title ?? "全部"
}
///
func reload(api: OrderServing, storeId: Int?, showLoading: Bool = true) async throws {
guard let storeId, storeId > 0 else {
orders = []
total = 0
page = 1
hasMore = false
return
}
if showLoading { loading = true }
defer { if showLoading { loading = false } }
let result = try await api.storeOrderList(
storeId: storeId,
page: 1,
pageSize: pageSize,
userPhone: normalizedPhone,
orderStatus: orderStatusQuery,
orderType: nil,
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate)
)
orders = result.list
total = result.total
page = 1
hasMore = orders.count < result.total && !result.list.isEmpty
}
///
func loadMore(api: OrderServing, storeId: Int?) async throws {
guard !loadingMore, hasMore, let storeId, storeId > 0 else { return }
loadingMore = true
defer { loadingMore = false }
let nextPage = page + 1
let result = try await api.storeOrderList(
storeId: storeId,
page: nextPage,
pageSize: pageSize,
userPhone: normalizedPhone,
orderStatus: orderStatusQuery,
orderType: nil,
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate)
)
orders.append(contentsOf: result.list)
total = result.total
page = nextPage
hasMore = orders.count < result.total && !result.list.isEmpty
}
/// 退
func refund(
api: OrderServing,
storeId: Int?,
orderNumber: String,
mode: OrderRefundMode,
amount: String,
reason: String
) async throws {
guard let storeId, storeId > 0 else { return }
try await api.storeOrderRefund(
storeId: storeId,
orderNumber: orderNumber,
refundType: mode,
refundAmount: amount,
refundReason: reason
)
}
private var orderStatusQuery: Int? {
selectedStatus > 0 ? selectedStatus : nil
}
}

View File

@ -0,0 +1,141 @@
//
// WriteOffListViewModel.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import Foundation
import Combine
@MainActor
/// ViewModel Android WriteOffListViewModel
final class WriteOffListViewModel: ObservableObject {
@Published var searchPhone = ""
@Published var selectedStatus = 0
@Published var filterStartDate: Date?
@Published var filterEndDate: Date?
@Published private(set) var orders: [WriteOffOrderItem] = []
@Published private(set) var loading = false
@Published private(set) var loadingMore = false
@Published private(set) var isVerifying = false
@Published private(set) var total = 0
@Published private(set) var page = 1
@Published private(set) var hasMore = false
private let pageSize = 20
///
var normalizedPhone: String? {
let text = searchPhone.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? nil : text
}
///
func reload(api: OrderServing, scenicId: Int?, storeId: Int? = nil, showLoading: Bool = true) async throws {
guard let scenicId else {
orders = []
total = 0
page = 1
hasMore = false
return
}
if showLoading { loading = true }
defer { if showLoading { loading = false } }
let result = try await api.writeOffList(
scenicId: scenicId,
storeId: storeId,
page: 1,
pageSize: pageSize,
orderStatus: orderStatusQuery,
orderVerifyStatus: verifyStatusQuery,
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate),
userPhone: normalizedPhone
)
orders = result.list
total = result.total
page = 1
hasMore = orders.count < result.total && !result.list.isEmpty
}
///
func loadMore(api: OrderServing, scenicId: Int?, storeId: Int? = nil) async throws {
guard !loadingMore, hasMore, let scenicId else { return }
loadingMore = true
defer { loadingMore = false }
let nextPage = page + 1
let result = try await api.writeOffList(
scenicId: scenicId,
storeId: storeId,
page: nextPage,
pageSize: pageSize,
orderStatus: orderStatusQuery,
orderVerifyStatus: verifyStatusQuery,
startTime: OrderDateRangeFormatter.queryValue(from: filterStartDate),
endTime: OrderDateRangeFormatter.queryValue(from: filterEndDate),
userPhone: normalizedPhone
)
orders.append(contentsOf: result.list)
total = result.total
page = nextPage
hasMore = orders.count < result.total && !result.list.isEmpty
}
///
func verify(api: OrderServing, scenicId: Int, storeId: Int? = nil, orderNumber: String) async throws {
guard !isVerifying else { return }
isVerifying = true
defer { isVerifying = false }
try await api.writeOff(orderNumber: orderNumber)
try await reload(api: api, scenicId: scenicId, storeId: storeId, showLoading: false)
}
///
func matchedWriteOffOrder(for rawCode: String) -> (orderNumber: String, matched: WriteOffOrderItem?)? {
guard let orderNumber = OrderNumberParser.parse(rawCode) else { return nil }
let matched = orders.first {
$0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame
}
return (matched?.orderNumber ?? orderNumber, matched)
}
///
func writeOffOrder(matching orderNumber: String) -> WriteOffOrderItem? {
orders.first { $0.orderNumber.caseInsensitiveCompare(orderNumber) == .orderedSame }
}
///
func canVerify(_ item: WriteOffOrderItem) -> Bool {
let status = displayVerificationStatus(for: item)
return !status.contains("已核销") && !status.contains("已退款") && !status.contains("已完成")
}
///
func displayVerificationStatus(for item: WriteOffOrderItem) -> String {
let text = item.orderVerificationStatus.trimmingCharacters(in: .whitespacesAndNewlines)
return text.isEmpty ? "待核销" : text
}
private var orderStatusQuery: Int? {
switch selectedStatus {
case 18, 30:
return selectedStatus
default:
return nil
}
}
private var verifyStatusQuery: Int? {
switch selectedStatus {
case 1, 2:
return selectedStatus
default:
return nil
}
}
}

View File

@ -0,0 +1,101 @@
//
// DateRangePickerSheet.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// Sheet Android DateRangePickerDialog
struct DateRangePickerSheet: View {
@Environment(\.dismiss) private var dismiss
let initialStartDate: Date?
let initialEndDate: Date?
let onConfirm: (Date?, Date?) -> Void
@State private var draftStartDate = Date()
@State private var draftEndDate = Date()
@State private var hasStartDate = false
@State private var hasEndDate = false
var body: some View {
NavigationStack {
Form {
Toggle("设置开始日期", isOn: $hasStartDate)
if hasStartDate {
DatePicker("开始日期", selection: $draftStartDate, displayedComponents: .date)
}
Toggle("设置结束日期", isOn: $hasEndDate)
if hasEndDate {
DatePicker("结束日期", selection: $draftEndDate, displayedComponents: .date)
}
}
.navigationTitle("时间筛选")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("清空") {
onConfirm(nil, nil)
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("确定") {
let start = hasStartDate ? draftStartDate : nil
let end = hasEndDate ? draftEndDate : nil
if let start, let end {
onConfirm(min(start, end), max(start, end))
} else {
onConfirm(start, end)
}
dismiss()
}
}
}
.onAppear {
if let initialStartDate {
draftStartDate = initialStartDate
hasStartDate = true
}
if let initialEndDate {
draftEndDate = initialEndDate
hasEndDate = true
}
}
}
}
}
///
enum OrderDateRangeFormatter {
private static let formatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
///
static func displayText(start: Date?, end: Date?, placeholder: String = "时间筛选") -> String {
let startText = start.map { formatter.string(from: $0) } ?? ""
let endText = end.map { formatter.string(from: $0) } ?? ""
switch (startText.isEmpty, endText.isEmpty) {
case (true, true):
return placeholder
case (false, false):
return "\(startText) - \(endText)"
case (false, true):
return "\(startText) - "
case (true, false):
return " - \(endText)"
}
}
/// API
static func queryValue(from date: Date?) -> String? {
guard let date else { return nil }
return formatter.string(from: date)
}
}

View File

@ -0,0 +1,207 @@
//
// OrderDialogViews.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// 退 Android OrderRefundDialog
struct OrderRefundDialogView: View {
@ObservedObject var viewModel: OrderRefundViewModel
let order: OrderEntity
let onDismiss: () -> Void
let onSuccess: () -> Void
@Environment(\.ordersAPI) private var ordersAPI
var body: some View {
NavigationStack {
Form {
Section("订单号") {
Text(order.orderNumber)
}
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)
} else {
Text("可退金额:¥\(viewModel.availableAmountText(for: order))")
.foregroundStyle(AppDesign.textSecondary)
}
}
Section("退款原因") {
TextField("请输入退款原因", text: $viewModel.reason, axis: .vertical)
.lineLimit(3...6)
}
if let errorMessage = viewModel.errorMessage {
Section {
Text(errorMessage)
.foregroundStyle(.red)
}
}
}
.navigationTitle("订单退款")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消", action: onDismiss)
}
ToolbarItem(placement: .confirmationAction) {
Button(viewModel.submitting ? "提交中" : "确认") {
Task {
let success = await viewModel.submit(api: ordersAPI, item: order)
if success {
onSuccess()
}
}
}
.disabled(viewModel.submitting)
}
}
}
}
}
/// / Android SendGiftDialog
struct SendGiftDialogView: View {
@State private var photoNum: Int
@State private var videoNum: Int
let context: OrdersViewModel.SendGiftContext
let onDismiss: () -> Void
let onConfirm: (Int, Int) -> Void
init(context: OrdersViewModel.SendGiftContext, onDismiss: @escaping () -> Void, onConfirm: @escaping (Int, Int) -> Void) {
self.context = context
self.onDismiss = onDismiss
self.onConfirm = onConfirm
_photoNum = State(initialValue: context.sendPhotoNum)
_videoNum = State(initialValue: context.sendVideoNum)
}
var body: some View {
NavigationStack {
Form {
Section {
stepperRow(
title: "修图张数(已有\(context.nowPhotoNum)张,最多赠20张)",
value: $photoNum,
range: 0...20
)
stepperRow(
title: "视频剪辑(已有\(context.nowVideoNum)条,最多赠5条)",
value: $videoNum,
range: 0...5
)
}
}
.navigationTitle("赠送修图/视频")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消", action: onDismiss)
}
ToolbarItem(placement: .confirmationAction) {
Button("确认") {
onConfirm(photoNum, videoNum)
}
}
}
}
}
private func stepperRow(title: String, value: Binding<Int>, range: ClosedRange<Int>) -> some View {
HStack {
Text(title)
.font(.system(size: 13))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer()
Button("-") { value.wrappedValue = max(range.lowerBound, value.wrappedValue - 1) }
.buttonStyle(.plain)
Text("\(value.wrappedValue)")
.frame(minWidth: 30)
Button("+") { value.wrappedValue = min(range.upperBound, value.wrappedValue + 1) }
.buttonStyle(.plain)
}
}
}
/// 退/退
struct StoreOrderRefundDialogView: View {
let orderNumber: String
let actualPayAmount: String
let initialMode: OrderRefundMode
let onDismiss: () -> Void
let onConfirm: (OrderRefundMode, String, String) -> Void
@State private var mode: OrderRefundMode
@State private var amount = ""
@State private var reason = ""
init(
orderNumber: String,
actualPayAmount: String,
initialMode: OrderRefundMode,
onDismiss: @escaping () -> Void,
onConfirm: @escaping (OrderRefundMode, String, String) -> Void
) {
self.orderNumber = orderNumber
self.actualPayAmount = actualPayAmount
self.initialMode = initialMode
self.onDismiss = onDismiss
self.onConfirm = onConfirm
_mode = State(initialValue: initialMode)
_amount = State(initialValue: actualPayAmount)
}
var body: some View {
NavigationStack {
Form {
Section("订单号") {
Text(orderNumber)
}
Section("退款方式") {
Picker("退款方式", selection: $mode) {
ForEach(OrderRefundMode.allCases) { item in
Text(item.title).tag(item)
}
}
.pickerStyle(.segmented)
if mode == .partial {
TextField("退款金额", text: $amount)
.keyboardType(.decimalPad)
}
}
Section("退款原因") {
TextField("请输入退款原因", text: $reason, axis: .vertical)
.lineLimit(3...6)
}
}
.navigationTitle("申请退款")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消", action: onDismiss)
}
ToolbarItem(placement: .confirmationAction) {
Button("提交") {
let refundAmount = mode == .full ? actualPayAmount : amount
onConfirm(mode, refundAmount, reason)
}
.disabled(reason.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}

View File

@ -0,0 +1,237 @@
//
// OrderListCardView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// Android OrderView
struct OrderListCardView: View {
let item: OrderEntity
let hideOrderWriteActions: Bool
let showOrderSource: Bool
let sourceSelection: OrderSourceSelection?
let orderSourcePeople: [OrderSourcePerson]
let leadsForPerson: (OrderSourcePerson) -> [OrderSourceLead]
let leadsLoading: Bool
let onRefund: () -> Void
let onVerify: () -> Void
let onUploadTask: () -> Void
let onRefinedChange: (Int) -> Void
let onSendGift: () -> Void
let onCancelOrder: () -> Void
let onHistoricalPhoto: () -> Void
let onCallPhone: () -> Void
let onOrderSourceSheetOpen: () -> Void
let onOrderSourcePersonClick: (OrderSourcePerson) -> Void
let onOrderSourceSelectionChange: (OrderSourceSelection) -> Void
let callSourcePhone: (String) -> Void
private var showRefundButton: Bool {
!hideOrderWriteActions &&
(item.orderStatus == 18 || item.orderStatus == 30) &&
item.orderType != 19
}
private var showVerifyButton: Bool {
!hideOrderWriteActions && (
(item.photoTravel?.needCheckIn == true && item.orderStatus != 30) ||
item.orderType == 19
)
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .center) {
Text("订单号:\(item.orderNumber)")
.font(.system(size: 14))
.foregroundStyle(.black)
.lineLimit(1)
.minimumScaleFactor(0.72)
Spacer()
OrderStatusChip(text: item.orderStatusName, status: item.orderStatus)
}
Text(item.createdAt.isEmpty ? "--" : item.createdAt)
.font(.system(size: 12))
.foregroundStyle(AppDesign.textSecondary)
.padding(.top, 4)
if !item.orderTypeLabel.isEmpty {
Text(item.orderTypeLabel)
.font(.system(size: 12))
.foregroundStyle(AppDesign.primary)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 4))
.padding(.top, 4)
}
if showOrderSource {
OrderSourcePicker(
selection: sourceSelection,
people: orderSourcePeople,
leadsForPerson: leadsForPerson,
leadsLoading: leadsLoading,
onSheetOpen: onOrderSourceSheetOpen,
onPersonClick: onOrderSourcePersonClick,
onSelectionChange: onOrderSourceSelectionChange,
callPhone: callSourcePhone
)
.padding(.top, 10)
}
if showRefundButton || showVerifyButton {
HStack(spacing: 12) {
if showRefundButton {
OrderActionButton(
title: "退款",
textColor: AppDesign.orderRefundText,
backgroundColor: AppDesign.orderRefundBackground,
maxWidth: showVerifyButton ? nil : .infinity,
action: onRefund
)
.frame(maxWidth: showVerifyButton ? .infinity : nil)
}
if showVerifyButton {
OrderActionButton(title: "核销订单", action: onVerify)
}
}
.padding(.top, 12)
}
if !hideOrderWriteActions && item.orderType == 19 {
Button(action: onUploadTask) {
Text("上传任务")
.font(.system(size: 16))
.foregroundStyle(AppDesign.primary)
.frame(maxWidth: .infinity)
.frame(height: 48)
.background(AppDesign.primarySoft, in: RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.padding(.top, 12)
}
if item.orderStatus == 18 || item.orderStatus == 30 {
OrderRefinedChoiceSection(
currentSelection: item.isRefined,
readOnly: hideOrderWriteActions,
onSelectionChange: onRefinedChange
)
.padding(.top, 12)
}
if item.orderStatus == 50,
!item.actualRefundAmount.isEmpty,
item.actualRefundAmount != "0" {
OrderInfoRow(title: "退款金额", value: "¥\(item.actualRefundAmount)", valueColor: AppDesign.primary)
.padding(.top, 8)
}
VStack(spacing: 8) {
OrderInfoRow(title: "付款金额", value: "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
OrderInfoRow(
title: "付款时间",
value: OrderListUI.displayPayTime(payTime: item.payTime, depositPayTime: item.depositPayTime)
)
OrderInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime)
OrderInfoRow(title: "付款方式", value: item.payTypeName.isEmpty ? "--" : item.payTypeName)
OrderInfoRow(title: "用户 UID", value: "\(item.userId)")
OrderInfoRow(title: "手机号", value: OrderListUI.maskPhone(item.phone)) {
HStack(spacing: 7) {
Text(OrderListUI.maskPhone(item.phone))
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.black)
if !hideOrderWriteActions {
OrderPhoneCallButton(action: onCallPhone)
}
}
}
OrderInfoRow(title: "关联项目", value: item.projectName.isEmpty ? "--" : item.projectName)
if let photoTravel = item.photoTravel, !photoTravel.checkInTime.isEmpty {
OrderInfoRow(title: "核销时间", value: photoTravel.checkInTime)
}
if let photoTravel = item.photoTravel,
item.orderType != 19,
item.orderType != 14 {
OrderGiftStepperRow(
title: "修图张数",
valueText: "\(photoTravel.orderPhotoNum + photoTravel.retouchGiftPhotoNum)",
editable: photoTravel.canGiftRetouch && !hideOrderWriteActions,
onTap: onSendGift
)
OrderGiftStepperRow(
title: "视频剪辑",
valueText: "\(photoTravel.orderVideoNum + photoTravel.retouchGiftVideoNum)",
editable: photoTravel.canGiftRetouch && !hideOrderWriteActions,
onTap: onSendGift
)
}
}
.padding(.top, 13)
if !item.remark.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Divider().overlay(AppDesign.orderInputBackground).padding(.top, 14)
Text("备注信息:")
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
.padding(.top, 10)
Text(item.remark)
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
.padding(.top, 6)
}
if let materials = item.multiTravel?.materialList, !materials.isEmpty {
Divider().overlay(AppDesign.orderInputBackground).padding(.top, 12)
Text("历史拍摄信息:")
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
.padding(.top, 12)
Button(action: onHistoricalPhoto) {
HStack(spacing: 8) {
ForEach(Array(materials.prefix(3).enumerated()), id: \.offset) { index, material in
ZStack {
RemoteImage(
urlString: material.previewURLString,
contentMode: .fill
) {
Color(hex: 0xF4F4F4)
}
.aspectRatio(1.59, contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 8))
if materials.count > 3 && index == 2 {
RoundedRectangle(cornerRadius: 8)
.fill(Color.black.opacity(0.5))
Text("查看更多")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white)
}
}
.frame(maxWidth: .infinity)
}
}
}
.buttonStyle(.plain)
.padding(.top, 8)
}
if item.photoTravel?.canCancelOrder == true && !hideOrderWriteActions {
OrderActionButton(title: "取消订单", action: onCancelOrder)
.padding(.top, 12)
}
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
.accessibilityIdentifier("orders.store.card.\(item.orderNumber)")
}
}

View File

@ -0,0 +1,276 @@
//
// OrderListComponents.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// UI
enum OrderListUI {
///
static func statusColors(for status: Int) -> (text: Color, background: Color) {
switch status {
case 18:
return (AppDesign.orderStatusPaidText, AppDesign.orderStatusPaidBackground)
case 30:
return (AppDesign.orderStatusCompletedText, AppDesign.orderStatusCompletedBackground)
case 50:
return (AppDesign.orderStatusRefundedText, AppDesign.orderStatusRefundedBackground)
default:
return (AppDesign.orderStatusOtherText, AppDesign.orderStatusOtherBackground)
}
}
///
static func maskPhone(_ phone: String) -> String {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard trimmed.count >= 7 else { return trimmed.isEmpty ? "--" : trimmed }
return "\(trimmed.prefix(3))****\(trimmed.suffix(4))"
}
/// depositPayTime
static func displayPayTime(payTime: String, depositPayTime: String) -> String {
if payTime.isEmpty {
return depositPayTime.isEmpty ? "--" : depositPayTime
}
if payTime.hasPrefix("1970") || payTime == "0" {
return depositPayTime.isEmpty ? payTime : depositPayTime
}
return payTime
}
}
/// Chip Android OrderView
struct OrderStatusChip: View {
let text: String
let status: Int
var body: some View {
let colors = OrderListUI.statusColors(for: status)
Text(text.isEmpty ? "--" : text)
.font(.system(size: 14))
.foregroundStyle(colors.text)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(colors.background, in: RoundedRectangle(cornerRadius: 4))
}
}
///
struct OrderInfoRow: View {
let title: String
let value: String
var valueColor: Color = .black
var trailing: AnyView?
init(title: String, value: String, valueColor: Color = .black, @ViewBuilder trailing: () -> some View = { EmptyView() }) {
self.title = title
self.value = value
self.valueColor = valueColor
let built = trailing()
self.trailing = built is EmptyView ? nil : AnyView(built)
}
var body: some View {
HStack(alignment: .center, spacing: 8) {
Text(title)
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer(minLength: 8)
if let trailing {
trailing
} else {
Text(value)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(valueColor)
.multilineTextAlignment(.trailing)
.lineLimit(2)
.minimumScaleFactor(0.75)
}
}
}
}
/// Android AppButton
struct OrderActionButton: View {
let title: String
var textColor: Color = .white
var backgroundColor: Color = AppDesign.primary
var maxWidth: CGFloat? = .infinity
let action: () -> Void
var body: some View {
Button(action: action) {
Text(title)
.font(.system(size: 16))
.foregroundStyle(textColor)
.frame(maxWidth: maxWidth)
.frame(height: 48)
.background(backgroundColor, in: RoundedRectangle(cornerRadius: 8))
}
.buttonStyle(.plain)
}
}
///
struct OrderPhoneCallButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
Image(systemName: "phone.fill")
.font(.system(size: 12))
.foregroundStyle(AppDesign.primary)
.frame(width: 24, height: 24)
.background(AppDesign.primarySoft, in: Circle())
}
.buttonStyle(.plain)
}
}
/// Android ExclusiveChoiceButtons
struct OrderRefinedChoiceSection: View {
let currentSelection: Int
let readOnly: Bool
let onSelectionChange: (Int) -> Void
var body: some View {
HStack {
Text("需要剪辑修图")
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer()
if readOnly {
Text(refinedDisplayText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.black)
} else {
HStack(spacing: 16) {
refinedChoiceButton(title: "", value: 1)
refinedChoiceButton(title: "", value: 2)
}
}
}
}
private var refinedDisplayText: String {
switch currentSelection {
case 1: return ""
case 2: return ""
default: return "--"
}
}
private func refinedChoiceButton(title: String, value: Int) -> some View {
let selected = currentSelection == value
return Button {
onSelectionChange(value)
} label: {
HStack(spacing: 4) {
ZStack {
Circle()
.stroke(AppDesign.primary, lineWidth: selected ? 0 : 1.5)
.background(selected ? Circle().fill(AppDesign.primary) : nil)
.frame(width: 20, height: 20)
if selected {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundStyle(.white)
}
}
Text(title)
.font(.system(size: 14))
.foregroundStyle(.black)
}
}
.buttonStyle(.plain)
}
}
/// /
struct OrderGiftStepperRow: View {
let title: String
let valueText: String
let editable: Bool
let onTap: () -> Void
var body: some View {
HStack {
Text(title)
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
Spacer()
if editable {
HStack(spacing: 8) {
giftStepButton(symbol: "-", action: onTap)
Text(valueText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.black)
giftStepButton(symbol: "+", action: onTap)
}
} else {
Text(valueText)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.black)
}
}
}
private func giftStepButton(symbol: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(symbol)
.font(.system(size: 16, weight: .bold))
.foregroundStyle(Color(hex: 0xE5E7EB))
.frame(width: 24, height: 24)
.overlay(Circle().stroke(Color(hex: 0xE5E7EB), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
/// pill Tab
struct WriteOffStatusTabBar: View {
let filters: [OrderStatusFilter]
let selectedStatus: Int
let onSelect: (Int) -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(filters) { filter in
let selected = selectedStatus == filter.id
Button {
onSelect(filter.id)
} label: {
Text(filter.title)
.font(.system(size: 14))
.foregroundStyle(selected ? .white : AppDesign.orderLabelColor)
.padding(.horizontal, 12)
.frame(height: 32)
.background(
selected ? AppDesign.primary : Color(hex: 0xF3F4F6),
in: RoundedRectangle(cornerRadius: 16)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 16)
}
}
}
/// `.searchable`
enum OrderListSearchBehavior {
///
static func normalized(_ text: String) -> String {
text.trimmingCharacters(in: .whitespacesAndNewlines)
}
///
static func shouldReloadAfterClear(newText: String, appliedQuery: String) -> Bool {
normalized(newText).isEmpty && !appliedQuery.isEmpty
}
}

View File

@ -0,0 +1,71 @@
//
// OrderListHeaderView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// `.searchable`
struct OrderListHeaderView: View {
let filterTitle: String
let timeFilterTitle: String
let statusFilters: [OrderStatusFilter]
let selectedStatus: Int
let onStatusSelected: (Int) -> Void
let onTimeFilterTap: () -> Void
var body: some View {
HStack(spacing: 8) {
Menu {
ForEach(statusFilters) { filter in
Button(filter.title) {
onStatusSelected(filter.id)
}
}
} label: {
OrderListFilterPill(title: filterTitle, showsChevron: true)
}
Button(action: onTimeFilterTap) {
OrderListFilterPill(title: timeFilterTitle, showsCalendar: true)
}
.buttonStyle(.plain)
}
.padding(16)
.background(.white)
}
}
/// pill
private struct OrderListFilterPill: View {
let title: String
var showsChevron = false
var showsCalendar = false
var body: some View {
HStack(spacing: 4) {
Text(title)
.font(.system(size: 14))
.foregroundStyle(AppDesign.orderLabelColor)
.lineLimit(1)
.minimumScaleFactor(0.75)
Spacer(minLength: 4)
if showsChevron {
Image(systemName: "chevron.down")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(AppDesign.orderLabelColor)
}
if showsCalendar {
Image(systemName: "calendar")
.font(.system(size: 15))
.foregroundStyle(AppDesign.orderLabelColor)
}
}
.padding(.horizontal, 12)
.frame(maxWidth: .infinity)
.frame(height: 35)
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
}
}

View File

@ -0,0 +1,262 @@
//
// StoreAdminOrderListView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// Tab Android DepositOrderListScreen
struct StoreAdminOrderListView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var router: RouterPath
@Environment(\.ordersAPI) private var ordersAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = StoreAdminOrderListViewModel()
@State private var showDateFilterSheet = false
@State private var refundOrder: StoreOrderListItem?
@State private var refundMode: OrderRefundMode = .full
@State private var showScanner = false
@State private var appliedSearchQuery = ""
private var currentStoreId: Int? { accountContext.currentStore?.id }
var body: some View {
Group {
if currentStoreId == nil {
AppContentUnavailableView(
"缺少门店上下文",
systemImage: "storefront",
description: Text("请先选择门店后查看订单。")
)
} else {
listContent
}
}
.background(AppDesign.orderPageBackground.ignoresSafeArea())
.navigationTitle("订单")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchPhone, prompt: "输入游客手机号")
.onSubmit(of: .search) {
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
Task { await reload() }
}
.onChange(of: viewModel.searchPhone) { newValue in
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
appliedSearchQuery = ""
Task { await reload() }
}
.task { await reload() }
.onChange(of: accountContext.currentStore?.id) { _ in
Task { await reload() }
}
.sheet(isPresented: $showDateFilterSheet) {
DateRangePickerSheet(
initialStartDate: viewModel.filterStartDate,
initialEndDate: viewModel.filterEndDate
) { start, end in
viewModel.filterStartDate = start
viewModel.filterEndDate = end
Task { await reload() }
}
}
.sheet(item: $refundOrder) { order in
StoreOrderRefundDialogView(
orderNumber: order.orderNumber,
actualPayAmount: order.actualPayAmount,
initialMode: refundMode,
onDismiss: { refundOrder = nil },
onConfirm: { mode, amount, reason in
Task {
do {
try await viewModel.refund(
api: ordersAPI,
storeId: currentStoreId,
orderNumber: order.orderNumber,
mode: mode,
amount: amount,
reason: reason
)
refundOrder = nil
toastCenter.show("退款申请已提交")
await reload(showLoading: false)
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
)
}
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
onSuccess: { _ in
showScanner = false
toastCenter.show("核销成功")
Task { await reload(showLoading: false) }
},
onFailure: { error in
showScanner = false
toastCenter.show(error.localizedDescription)
}
)
}
}
private var listContent: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
LazyVStack(spacing: 12) {
if viewModel.loading && viewModel.orders.isEmpty {
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
} else if viewModel.orders.isEmpty {
AppContentUnavailableView("暂无订单", systemImage: "tray")
.frame(minHeight: 260)
} else {
ForEach(viewModel.orders) { item in
StoreAdminOrderCardView(
item: item,
onTap: { handleOrderTap(item) },
onRefund: {
refundMode = .full
refundOrder = item
},
onPartialRefund: {
refundMode = .partial
refundOrder = item
},
onVerify: { showScanner = true },
onCallPhone: { dialPhone(item.phone) }
)
.onAppear {
guard item.id == viewModel.orders.last?.id else { return }
Task { await loadMore() }
}
}
if viewModel.loadingMore {
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 8)
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
} header: {
OrderListHeaderView(
filterTitle: viewModel.currentFilterTitle,
timeFilterTitle: OrderDateRangeFormatter.displayText(
start: viewModel.filterStartDate,
end: viewModel.filterEndDate
),
statusFilters: OrderFilters.storeAdminStatusFilters,
selectedStatus: viewModel.selectedStatus,
onStatusSelected: { status in
viewModel.selectedStatus = status
Task { await reload() }
},
onTimeFilterTap: { showDateFilterSheet = true }
)
}
}
}
.refreshable { await reload(showLoading: false) }
}
private func handleOrderTap(_ item: StoreOrderListItem) {
if item.orderType == StoreOrderType.multiTravelShoot {
router.navigate(to: .orders(.depositDetail(orderNumber: item.orderNumber)))
} else {
toastCenter.show("该订单不可查看详情")
}
}
private func reload(showLoading: Bool = true) async {
do {
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
try await viewModel.reload(api: ordersAPI, storeId: currentStoreId, showLoading: showLoading)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func loadMore() async {
do {
try await viewModel.loadMore(api: ordersAPI, storeId: currentStoreId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
}
/// Android DepositOrderCard
private struct StoreAdminOrderCardView: View {
let item: StoreOrderListItem
let onTap: () -> Void
let onRefund: () -> Void
let onPartialRefund: () -> Void
let onVerify: () -> Void
let onCallPhone: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Button(action: onTap) {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("订单号:\(item.orderNumber)")
.font(.system(size: 14))
.foregroundStyle(.black)
Spacer()
OrderStatusChip(text: item.orderStatusName, status: item.orderStatus)
}
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
OrderInfoRow(title: "订单金额", value: "¥\(item.actualPayAmount)", valueColor: AppDesign.primary)
OrderInfoRow(title: "付款时间", value: item.payTime.isEmpty ? "--" : item.payTime)
OrderInfoRow(title: "完成时间", value: item.completeTime.isEmpty ? "--" : item.completeTime)
OrderInfoRow(title: "用户 UID", value: "\(item.userId)")
OrderInfoRow(title: "手机号", value: OrderListUI.maskPhone(item.phone)) {
HStack(spacing: 7) {
Text(OrderListUI.maskPhone(item.phone))
.font(.system(size: 14, weight: .medium))
OrderPhoneCallButton(action: onCallPhone)
}
}
}
}
.buttonStyle(.plain)
HStack(spacing: 12) {
OrderActionButton(
title: "退款",
textColor: AppDesign.orderRefundText,
backgroundColor: AppDesign.orderRefundBackground,
action: onRefund
)
OrderActionButton(
title: "部分退款",
textColor: AppDesign.orderLabelColor,
backgroundColor: .white,
action: onPartialRefund
)
.overlay(RoundedRectangle(cornerRadius: 8).stroke(AppDesign.orderBorderLight, lineWidth: 1))
}
if item.orderType == StoreOrderType.offlineProduct {
OrderActionButton(title: "核销", action: onVerify)
}
}
.padding(16)
.background(.white, in: RoundedRectangle(cornerRadius: 12))
}
}

View File

@ -0,0 +1,382 @@
//
// StoreOrderListView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// / Tab Android OrderListScreen
struct StoreOrderListView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var permissionContext: PermissionContext
@EnvironmentObject private var appRouter: AppRouter
@EnvironmentObject private var router: RouterPath
@Environment(\.ordersAPI) private var ordersAPI
@Environment(\.cooperationOrderAPI) private var cooperationOrderAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.openURL) private var openURL
@StateObject private var viewModel = OrdersViewModel()
@StateObject private var refundViewModel = OrderRefundViewModel()
@State private var showDateFilterSheet = false
@State private var showScanner = false
@State private var pendingVerifyOrderNumber: String?
@State private var showVerifyConfirmation = false
@State private var bindAcquirerSaleUserId: Int?
@State private var referralBindOrderNumber: String?
@State private var appliedSearchQuery = ""
private var currentScenicId: Int? { accountContext.currentScenic?.id }
private var currentStoreId: Int? { accountContext.currentStore?.id }
private var currentAppRole: AppRoleCode? { permissionContext.currentAppRole }
private var hideOrderWriteActions: Bool { OrdersViewModel.isScenicAdminReadOnly(appRole: currentAppRole) }
var body: some View {
Group {
if currentScenicId == nil {
AppContentUnavailableView(
"缺少经营上下文",
systemImage: "mountain.2",
description: Text("请先在首页选择景区后查看订单。")
)
} else {
orderListContent
}
}
.background(AppDesign.orderPageBackground.ignoresSafeArea())
.navigationTitle("订单")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchPhone, prompt: "输入手机号搜索")
.onSubmit(of: .search) {
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
Task { await reload() }
}
.onChange(of: viewModel.searchPhone) { newValue in
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
appliedSearchQuery = ""
Task { await reload() }
}
.task { await reload() }
.onChange(of: accountContext.currentScenic?.id) { _ in
Task { await reload() }
}
.sheet(isPresented: $showDateFilterSheet) {
DateRangePickerSheet(
initialStartDate: viewModel.filterStartDate,
initialEndDate: viewModel.filterEndDate
) { start, end in
viewModel.filterStartDate = start
viewModel.filterEndDate = end
Task { await reload() }
}
}
.sheet(item: refundOrderBinding) { order in
OrderRefundDialogView(
viewModel: refundViewModel,
order: order,
onDismiss: { viewModel.refundingOrder = nil },
onSuccess: {
viewModel.refundingOrder = nil
toastCenter.show("退款申请已提交")
Task { await reload(showLoading: false) }
}
)
}
.sheet(item: sendGiftBinding) { context in
SendGiftDialogView(
context: context,
onDismiss: { viewModel.sendGiftContext = nil },
onConfirm: { photo, video in
Task {
do {
try await viewModel.submitSendGift(
api: ordersAPI,
scenicId: currentScenicId,
appRole: currentAppRole,
photoNum: photo,
videoNum: video
)
toastCenter.show("赠送成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
)
}
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
onSuccess: { rawCode in
showScanner = false
handleScanResult(rawCode)
},
onFailure: { error in
showScanner = false
toastCenter.show(error.localizedDescription)
}
)
}
.confirmationDialog("确认取消该订单?", isPresented: $showCancelConfirmation, titleVisibility: .visible) {
Button("确认取消", role: .destructive) {
guard let orderNumber = pendingCancelOrderNumber else { return }
Task {
do {
try await viewModel.cancelOrder(
api: ordersAPI,
scenicId: currentScenicId,
appRole: currentAppRole,
orderNumber: orderNumber
)
pendingCancelOrderNumber = nil
toastCenter.show("订单已取消")
} catch {
toastCenter.show(error.localizedDescription)
}
}
}
Button("取消", role: .cancel) { pendingCancelOrderNumber = nil }
}
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
Button("确认核销") {
guard let orderNumber = pendingVerifyOrderNumber else { return }
Task { await verify(orderNumber: orderNumber) }
}
Button("取消", role: .cancel) { pendingVerifyOrderNumber = nil }
}
.fullScreenCover(item: bindAcquirerBinding) { item in
NavigationStack {
BindAcquirerView(saleUserId: item.saleUserId, onSuccess: {
bindAcquirerSaleUserId = nil
})
}
}
.appNavigationDestination(item: referralBindBinding) { item in
ReferralOrderSelectView(orderNumber: item.orderNumber) {
Task { await reload(showLoading: false) }
}
}
}
@State private var pendingCancelOrderNumber: String?
@State private var showCancelConfirmation = false
private var orderListContent: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
Section {
LazyVStack(spacing: 12) {
if viewModel.loading && viewModel.storeOrders.isEmpty {
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
} else if viewModel.storeOrders.isEmpty {
AppContentUnavailableView("暂无订单", systemImage: "tray", description: Text("可切换筛选条件或下拉刷新。"))
.frame(minHeight: 260)
} else {
ForEach(viewModel.storeOrders) { item in
OrderListCardView(
item: item,
hideOrderWriteActions: hideOrderWriteActions,
showOrderSource: viewModel.shouldShowOrderSource(order: item, appRole: currentAppRole),
sourceSelection: viewModel.getOrderSourceSelection(for: item),
orderSourcePeople: viewModel.orderSourcePeople,
leadsForPerson: { person in
viewModel.orderSourceLeads[person.key] ?? []
},
leadsLoading: viewModel.loadingOrderSourceLeadsFor != nil,
onRefund: {
refundViewModel.begin(item: item)
viewModel.refundingOrder = item
},
onVerify: {
pendingVerifyOrderNumber = item.orderNumber
showScanner = true
},
onUploadTask: {
router.navigate(to: .orders(.multiTravelTaskUpload(orderNumber: item.orderNumber)))
},
onRefinedChange: { value in
Task {
do {
try await viewModel.updateRefinedChoice(
api: ordersAPI,
orderNumber: item.orderNumber,
isRefined: value
)
} catch {
toastCenter.show(error.localizedDescription)
}
}
},
onSendGift: { viewModel.prepareSendGift(for: item) },
onCancelOrder: {
pendingCancelOrderNumber = item.orderNumber
showCancelConfirmation = true
},
onHistoricalPhoto: {
router.navigate(to: .orders(.historicalShooting(orderNumber: item.orderNumber)))
},
onCallPhone: { dialPhone(item.phone) },
onOrderSourceSheetOpen: {
Task {
await viewModel.prepareOrderSourceSheet(
api: cooperationOrderAPI,
storeId: currentStoreId
)
}
},
onOrderSourcePersonClick: { person in
Task {
await viewModel.loadOrderSourceLeads(api: cooperationOrderAPI, person: person)
}
},
onOrderSourceSelectionChange: { selection in
Task {
do {
try await viewModel.bindOrderSource(
api: cooperationOrderAPI,
orderNumber: item.orderNumber,
selection: selection
)
toastCenter.show("绑定成功")
await reload(showLoading: false)
} catch {
toastCenter.show(error.localizedDescription)
}
}
},
callSourcePhone: dialPhone
)
.onAppear {
guard item.id == viewModel.storeOrders.last?.id else { return }
Task { await loadMore() }
}
}
if viewModel.loadingMore {
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 8)
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 16)
} header: {
OrderListHeaderView(
filterTitle: viewModel.currentFilterTitle,
timeFilterTitle: OrderDateRangeFormatter.displayText(
start: viewModel.filterStartDate,
end: viewModel.filterEndDate
),
statusFilters: OrderFilters.statusFilters,
selectedStatus: viewModel.selectedStatus,
onStatusSelected: { status in
viewModel.selectedStatus = status
Task { await reload() }
},
onTimeFilterTap: { showDateFilterSheet = true }
)
}
}
}
.refreshable { await reload(showLoading: false) }
}
private var refundOrderBinding: Binding<OrderEntity?> {
Binding(
get: { viewModel.refundingOrder },
set: { viewModel.refundingOrder = $0 }
)
}
private var sendGiftBinding: Binding<OrdersViewModel.SendGiftContext?> {
Binding(
get: { viewModel.sendGiftContext },
set: { viewModel.sendGiftContext = $0 }
)
}
private var bindAcquirerBinding: Binding<BindAcquirerSheetItem?> {
Binding(
get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) },
set: { bindAcquirerSaleUserId = $0?.saleUserId }
)
}
private var referralBindBinding: Binding<OrderNumberSheetItem?> {
Binding(
get: { referralBindOrderNumber.map(OrderNumberSheetItem.init(orderNumber:)) },
set: { referralBindOrderNumber = $0?.orderNumber }
)
}
private func reload(showLoading: Bool = true) async {
do {
try await globalLoading.withOptionalLoading(showLoading && viewModel.storeOrders.isEmpty, message: "加载中...") {
try await viewModel.reloadStoreOrders(
api: ordersAPI,
scenicId: currentScenicId,
appRole: currentAppRole,
showLoading: showLoading
)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func loadMore() async {
do {
try await viewModel.loadMoreStoreOrders(
api: ordersAPI,
scenicId: currentScenicId,
appRole: currentAppRole
)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func verify(orderNumber: String) async {
guard let scenicId = currentScenicId else { return }
do {
try await globalLoading.withLoading(message: "核销中...") {
try await viewModel.verify(
api: ordersAPI,
scenicId: scenicId,
storeId: currentStoreId,
orderNumber: orderNumber
)
}
pendingVerifyOrderNumber = nil
toastCenter.show("核销成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func handleScanResult(_ rawCode: String) {
if SaleUserQrParser.isSaleUserBindQR(rawCode) {
guard let saleUserId = SaleUserQrParser.parseSaleUserId(rawCode), saleUserId > 0 else {
toastCenter.show("请扫描正确的获客员二维码")
return
}
bindAcquirerSaleUserId = saleUserId
return
}
guard let orderNumber = OrderNumberParser.parse(rawCode) else {
toastCenter.show("扫码成功,但未识别到有效订单号。")
return
}
pendingVerifyOrderNumber = orderNumber
showVerifyConfirmation = true
}
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
}

View File

@ -0,0 +1,309 @@
//
// WriteOffListView.swift
// suixinkan
//
// Created by Codex on 2026/6/29.
//
import SwiftUI
/// Android WriteOffListScreen
struct WriteOffListView: View {
@EnvironmentObject private var accountContext: AccountContext
@EnvironmentObject private var appRouter: AppRouter
@Environment(\.ordersAPI) private var ordersAPI
@EnvironmentObject private var toastCenter: ToastCenter
@Environment(\.globalLoading) private var globalLoading
@Environment(\.openURL) private var openURL
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel = WriteOffListViewModel()
@State private var manualOrderNumber = ""
@State private var showScanner = false
@State private var showVerifyConfirmation = false
@State private var pendingVerifyOrderNumber: String?
@State private var matchedScannedOrderNumber: String?
@State private var showStartDateSheet = false
@State private var showEndDateSheet = false
@State private var appliedSearchQuery = ""
private var currentScenicId: Int? { accountContext.currentScenic?.id }
private var currentStoreId: Int? { accountContext.currentStore?.id }
var body: some View {
Group {
if currentScenicId == nil {
AppContentUnavailableView("缺少经营上下文", systemImage: "mountain.2")
} else {
listContent
}
}
.background(AppDesign.orderPageBackground.ignoresSafeArea())
.navigationTitle("核销管理")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $viewModel.searchPhone, prompt: "搜索手机号")
.onSubmit(of: .search) {
appliedSearchQuery = OrderListSearchBehavior.normalized(viewModel.searchPhone)
Task { await reload() }
}
.onChange(of: viewModel.searchPhone) { newValue in
guard OrderListSearchBehavior.shouldReloadAfterClear(newText: newValue, appliedQuery: appliedSearchQuery) else { return }
appliedSearchQuery = ""
Task { await reload() }
}
.task {
await reload()
await consumePendingScanCodeIfNeeded()
}
.onChange(of: appRouter.pendingOrderScanCode) { _ in
Task { await consumePendingScanCodeIfNeeded() }
}
.fullScreenCover(isPresented: $showScanner) {
OrderScannerPage(
onClose: { showScanner = false },
onSuccess: { rawCode in
showScanner = false
handleScanResult(rawCode)
},
onFailure: { error in
showScanner = false
toastCenter.show(error.localizedDescription)
}
)
}
.confirmationDialog("确认核销该订单?", isPresented: $showVerifyConfirmation, titleVisibility: .visible) {
Button("确认核销") {
guard let orderNumber = pendingVerifyOrderNumber else { return }
Task { await verify(orderNumber: orderNumber) }
}
Button("取消", role: .cancel) {
pendingVerifyOrderNumber = nil
}
}
.sheet(isPresented: $showStartDateSheet) {
singleDateSheet(title: "开始时间", date: viewModel.filterStartDate) { date in
viewModel.filterStartDate = date
Task { await reload() }
}
}
.sheet(isPresented: $showEndDateSheet) {
singleDateSheet(title: "结束时间", date: viewModel.filterEndDate) { date in
viewModel.filterEndDate = date
Task { await reload() }
}
}
}
private var listContent: some View {
ScrollViewReader { scrollProxy in
ScrollView {
VStack(spacing: 12) {
WriteOffStatusTabBar(
filters: OrderFilters.writeOffStatusFilters,
selectedStatus: viewModel.selectedStatus,
onSelect: { status in
viewModel.selectedStatus = status
Task { await reload() }
}
)
dateFilterSection
if viewModel.loading && viewModel.orders.isEmpty {
ProgressView().frame(maxWidth: .infinity).padding(.vertical, 40)
} else if viewModel.orders.isEmpty {
AppContentUnavailableView("暂无核销订单", systemImage: "tray")
.frame(minHeight: 220)
} else {
ForEach(viewModel.orders) { item in
writeOffCard(item)
.id(item.orderNumber)
.onAppear {
guard item.id == viewModel.orders.last?.id else { return }
Task { await loadMore() }
}
}
if viewModel.loadingMore {
ProgressView().frame(maxWidth: .infinity)
}
}
}
.padding(.bottom, 16)
}
.onChange(of: matchedScannedOrderNumber) { orderNumber in
guard let orderNumber else { return }
withAnimation(.easeInOut(duration: 0.25)) {
scrollProxy.scrollTo(orderNumber, anchor: .center)
}
}
}
.refreshable { await reload(showLoading: false) }
}
private var dateFilterSection: some View {
HStack(spacing: 8) {
dateFilterButton(title: "开始时间", date: viewModel.filterStartDate) {
showStartDateSheet = true
}
dateFilterButton(title: "结束时间", date: viewModel.filterEndDate) {
showEndDateSheet = true
}
}
.padding(.horizontal, 16)
}
private func dateFilterButton(title: String, date: Date?, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack {
Image(systemName: "calendar")
Text(date.map { OrderDateRangeFormatter.displayText(start: $0, end: nil, placeholder: title) } ?? title)
.font(.system(size: 13))
.lineLimit(1)
}
.foregroundStyle(AppDesign.orderLabelColor)
.frame(maxWidth: .infinity)
.frame(height: 35)
.background(AppDesign.orderInputBackground, in: RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(.plain)
}
private func writeOffCard(_ item: WriteOffOrderItem) -> some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("订单号:\(item.orderNumber)")
.font(.system(size: 14, weight: .semibold))
Spacer()
OrderStatusChip(
text: viewModel.displayVerificationStatus(for: item),
status: viewModel.canVerify(item) ? 18 : 30
)
}
OrderInfoRow(title: "项目名称", value: item.projectName.isEmpty ? "--" : item.projectName)
OrderInfoRow(title: "手机号", value: item.userPhone.isEmpty ? "--" : item.userPhone) {
HStack(spacing: 7) {
Text(item.userPhone)
.font(.system(size: 14, weight: .medium))
OrderPhoneCallButton {
dialPhone(item.userPhone)
}
}
}
OrderInfoRow(title: "订单状态", value: item.orderStatusName.isEmpty ? "--" : item.orderStatusName)
HStack {
Text("¥\(item.orderAmount)")
.font(.system(size: 18, weight: .bold))
.foregroundStyle(AppDesign.primary)
Spacer()
Button(viewModel.canVerify(item) ? "核销" : "已处理") {
pendingVerifyOrderNumber = item.orderNumber
showVerifyConfirmation = true
}
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.frame(height: 32)
.background(
viewModel.canVerify(item) ? AppDesign.primary : Color(hex: 0x9CA3AF),
in: RoundedRectangle(cornerRadius: 8)
)
.disabled(!viewModel.canVerify(item) || viewModel.isVerifying)
}
}
.padding(15)
.background(
matchedScannedOrderNumber == item.orderNumber ? Color(hex: 0xEEF6FF) : .white,
in: RoundedRectangle(cornerRadius: 12)
)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(matchedScannedOrderNumber == item.orderNumber ? AppDesign.primary : .clear, lineWidth: 2)
}
.padding(.horizontal, 16)
}
private func singleDateSheet(title: String, date: Date?, onConfirm: @escaping (Date?) -> Void) -> some View {
NavigationStack {
Form {
DatePicker(title, selection: Binding(
get: { date ?? Date() },
set: { onConfirm($0) }
), displayedComponents: .date)
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("清空") { onConfirm(nil) }
}
}
}
}
private func reload(showLoading: Bool = true) async {
do {
try await globalLoading.withOptionalLoading(showLoading && viewModel.orders.isEmpty, message: "加载中...") {
try await viewModel.reload(
api: ordersAPI,
scenicId: currentScenicId,
storeId: currentStoreId,
showLoading: showLoading
)
}
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func loadMore() async {
do {
try await viewModel.loadMore(api: ordersAPI, scenicId: currentScenicId, storeId: currentStoreId)
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func verify(orderNumber: String) async {
guard let scenicId = currentScenicId else { return }
do {
try await globalLoading.withLoading(message: "核销中...") {
try await viewModel.verify(
api: ordersAPI,
scenicId: scenicId,
storeId: currentStoreId,
orderNumber: orderNumber
)
}
manualOrderNumber = ""
pendingVerifyOrderNumber = nil
matchedScannedOrderNumber = nil
toastCenter.show("核销成功")
} catch {
toastCenter.show(error.localizedDescription)
}
}
private func consumePendingScanCodeIfNeeded() async {
guard let rawCode = appRouter.consumePendingOrderScanCode() else { return }
handleScanResult(rawCode)
}
private func handleScanResult(_ rawCode: String) {
guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else {
toastCenter.show("扫码成功,但未识别到有效订单号。")
return
}
manualOrderNumber = result.orderNumber
pendingVerifyOrderNumber = result.orderNumber
if let matched = result.matched {
matchedScannedOrderNumber = matched.orderNumber
}
showVerifyConfirmation = true
}
private func dialPhone(_ phone: String) {
let trimmed = phone.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: "tel:\(trimmed)") else { return }
openURL(url)
}
}

View File

@ -37,8 +37,8 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
## 加载流程
1. `ProfileView.task` 进入页面时调用 `reloadProfile(showToast: false)`
2. 下拉刷新时调用 `reloadProfile(showToast: true)`
1. `ProfileView.task` 进入页面时调用 `reloadProfile()`
2. 下拉刷新时同样调用 `reloadProfile()`,成功后不展示 Toast
3. `ProfileViewModel.reload` 并发请求:
- `ProfileAPI.userInfo`
- `ProfileAPI.realNameInfo`
@ -111,6 +111,7 @@ DEBUG 构建下,“我的”列表会额外展示“首页调试”入口,
## 状态展示规则
- 进入个人信息页时,先用 `AccountContext` 中已恢复的本地缓存(`AccountSnapshot`)预填充昵称、手机号和头像;接口返回后再替换为最新数据。
- 昵称为空时展示“未设置昵称”。
- 真实姓名、手机号和账号状态为空时展示 `--`
- 实名认证状态:

View File

@ -20,9 +20,13 @@ final class ProfileViewModel: ObservableObject {
@Published private(set) var pendingAvatarData: Data?
@Published private(set) var pendingAvatarFileName: String?
@Published private(set) var avatarUploadProgress: Int?
/// 使 AccountContext / AccountSnapshot
private var cachedProfile: AccountProfile?
var displayNickname: String {
nonEmpty(userInfo?.nickname) ?? "未设置昵称"
nonEmpty(userInfo?.nickname)
?? nonEmpty(cachedProfile?.displayName)
?? "未设置昵称"
}
var displayRealName: String {
@ -30,11 +34,11 @@ final class ProfileViewModel: ObservableObject {
}
var displayPhone: String {
nonEmpty(userInfo?.phone) ?? "--"
nonEmpty(userInfo?.phone) ?? nonEmpty(cachedProfile?.phone) ?? "--"
}
var displayAvatarURL: String {
nonEmpty(userInfo?.avatar) ?? ""
nonEmpty(userInfo?.avatar) ?? nonEmpty(cachedProfile?.avatarURL) ?? ""
}
var accountStatusText: String {
@ -53,6 +57,12 @@ final class ProfileViewModel: ObservableObject {
}
}
///
func applyCachedProfile(_ profile: AccountProfile?) {
guard userInfo == nil else { return }
cachedProfile = profile
}
///
func reload(api: ProfileAPI) async throws {
guard !isLoading else { return }

View File

@ -46,6 +46,9 @@ struct DebugHomeMenuPreviewView: View {
appRouter.select(tab)
case .orders(let entry):
appRouter.selectOrders(entry: entry)
case .orderPush(let route):
appRouter.select(.orders)
appRouter.router(for: .orders).navigate(to: .orders(route))
case .destination(let homeRoute):
router.navigate(to: .home(homeRoute))
case .unsupported(_, let title, let reason):
@ -102,6 +105,8 @@ private struct DebugHomeMenuRow: View {
return "Tab"
case .orders:
return "订单"
case .orderPush:
return "订单页"
case .destination(let homeRoute):
if case .modulePlaceholder = homeRoute {
return "占位"
@ -119,7 +124,7 @@ private struct DebugHomeMenuRow: View {
switch route {
case .tab:
return "rectangle.grid.1x2"
case .orders:
case .orders, .orderPush:
return "doc.text"
case .destination(let homeRoute):
if case .modulePlaceholder = homeRoute {
@ -136,7 +141,7 @@ private struct DebugHomeMenuRow: View {
///
private var iconColor: Color {
switch route {
case .tab, .orders, .destination:
case .tab, .orders, .orderPush, .destination:
AppDesign.primary
case .unsupported:
.red

View File

@ -51,10 +51,11 @@ struct ProfileView: View {
.navigationTitle("个人信息")
.navigationBarTitleDisplayMode(.inline)
.task {
await reloadProfile(showToast: false)
viewModel.applyCachedProfile(accountContext.profile)
await reloadProfile()
}
.refreshable {
await reloadProfile(showToast: true)
await reloadProfile()
}
.sheet(isPresented: $showsPasswordSheet) {
PasswordUpdateSheet { password in
@ -517,9 +518,9 @@ struct ProfileView: View {
}
///
private func reloadProfile(showToast: Bool) async {
private func reloadProfile() async {
do {
try await globalLoading.withOptionalLoading(!showToast && viewModel.userInfo == nil, message: "加载中...") {
try await globalLoading.withOptionalLoading(viewModel.userInfo == nil, message: "加载中...") {
try await viewModel.reload(api: profileAPI)
}
if let userInfo = viewModel.userInfo {
@ -527,9 +528,6 @@ struct ProfileView: View {
} else {
accountContext.replaceProfile(viewModel.accountProfileFallback(accountContext.profile))
}
if showToast {
toastCenter.show("个人信息已刷新")
}
} catch is CancellationError {
return
} catch {

View File

@ -11,6 +11,18 @@ import XCTest
@MainActor
///
final class AccountContextTests: XCTestCase {
/// Android
func testAccountCachePrefixUsesAccountTypeAndUserId() {
let context = AccountContext()
context.applyLogin(
profile: AccountProfile(userId: "101", displayName: "测试账号"),
accountType: "store_user"
)
XCTAssertEqual(context.accountCachePrefix, "store_user_101")
}
/// replaceScopes ID
func testReplaceScopesRestoresCurrentSelectionsById() {
let context = AccountContext()

View File

@ -0,0 +1,45 @@
{
"code": 100000,
"msg": "success",
"data": {
"total": "2",
"list": [
{
"order_number": "STORE001",
"order_type": "19",
"order_type_label": "多点旅拍",
"created_at": "2026-05-23 09:00:00",
"order_status": "18",
"order_status_name": "已付定金",
"actual_pay_amount": "199.00",
"actual_refund_amount": "0",
"pay_type_name": "微信支付",
"pay_time": "2026-05-23 09:01:00",
"complete_time": "",
"user_id": "8001",
"phone": "13800000000",
"project_id": "6001",
"project_name": "店长套餐",
"remark": ""
},
{
"order_number": "STORE002",
"order_type": 4,
"order_type_label": "线下产品",
"created_at": "2026-05-22 18:30:00",
"order_status": 30,
"order_status_name": "已完成",
"actual_pay_amount": "88.00",
"actual_refund_amount": "0",
"pay_type_name": "余额支付",
"pay_time": "2026-05-22 18:31:00",
"complete_time": "2026-05-22 19:00:00",
"user_id": 8002,
"phone": "13900000000",
"project_id": 6002,
"project_name": "线下体验",
"remark": ""
}
]
}
}

View File

@ -76,7 +76,7 @@ final class HomeCommonMenuStoreTests: XCTestCase {
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management"])
}
/// URI URI
/// URI Android URI
func testLoadFiltersSavedUrisByCurrentPermissions() {
let defaults = makeIsolatedDefaults()
defaults.set(["task_management", "unknown", "pm"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
@ -92,7 +92,41 @@ final class HomeCommonMenuStoreTests: XCTestCase {
topLevelPermissionURIs: ["task_management_editor", "project_edit"]
)
XCTAssertEqual(uris, ["task_management_editor", "project_edit"])
XCTAssertEqual(uris, ["task_management_editor"])
}
/// 使
func testLoadUsesAccountScopedStorageKeys() {
let defaults = makeIsolatedDefaults()
defaults.set(
["wallet"],
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")
)
defaults.set(
["cloud_management"],
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "store_user_101")
)
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("wallet"),
menu("cloud_management")
]
let scenic = store.load(
menuItems: menus,
roleCode: "photographer",
accountScope: "scenic_user_101",
topLevelPermissionURIs: ["wallet", "cloud_management"]
)
let storeAccount = store.load(
menuItems: menus,
roleCode: "photographer",
accountScope: "store_user_101",
topLevelPermissionURIs: ["wallet", "cloud_management"]
)
XCTAssertEqual(scenic, ["wallet"])
XCTAssertEqual(storeAccount, ["cloud_management"])
}
/// 使 key
@ -183,6 +217,94 @@ final class HomeCommonMenuStoreTests: XCTestCase {
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "46"))
}
/// roleCode key key
func testLoadMigratesLegacyRoleCodeStorageKeyToAccountScopedKey() {
let defaults = makeIsolatedDefaults()
defaults.set(["wallet", "cloud_management"], forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer")
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [menu("wallet"), menu("cloud_management")]
let uris = store.load(
menuItems: menus,
roleCode: "photographer",
accountScope: "scenic_user_101",
topLevelPermissionURIs: ["wallet", "cloud_management"]
)
XCTAssertEqual(uris, ["wallet", "cloud_management"])
XCTAssertEqual(
defaults.stringArray(forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")),
["wallet", "cloud_management"]
)
XCTAssertNil(defaults.stringArray(forKey: HomeCommonMenuStore.storageKeyPrefix + "photographer"))
}
/// Android URI
func testDefaultCommonURIsExcludeMenusMissingFromAndroidCatalog() {
let defaults = makeIsolatedDefaults()
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("wallet"),
menu("album_list"),
menu("cloud_storage_transit"),
menu("message_center")
]
let uris = store.load(
menuItems: menus,
roleCode: "photographer",
accountScope: "scenic_user_101",
topLevelPermissionURIs: ["wallet", "album_list", "cloud_storage_transit", "message_center"]
)
XCTAssertEqual(uris, ["wallet", "message_center"])
}
/// 4
func testSavedCommonURIsAreNotTruncatedToFour() {
let defaults = makeIsolatedDefaults()
defaults.set(
["space_settings", "wallet", "cloud_management", "asset_management", "message_center"],
forKey: HomeCommonMenuStore.storageKey(roleCode: "photographer", accountScope: "scenic_user_101")
)
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("space_settings"),
menu("wallet"),
menu("cloud_management"),
menu("asset_management"),
menu("message_center")
]
let uris = store.load(
menuItems: menus,
roleCode: "photographer",
accountScope: "scenic_user_101",
topLevelPermissionURIs: menus.map(\.uri)
)
XCTAssertEqual(uris, ["space_settings", "wallet", "cloud_management", "asset_management", "message_center"])
}
///
func testDefaultCommonURIsUseOnlyTopLevelPermissions() {
let defaults = makeIsolatedDefaults()
let store = HomeCommonMenuStore(defaults: defaults)
let menus = [
menu("task_management"),
menu("message_center")
]
let uris = store.load(
menuItems: menus,
roleCode: "photographer",
accountScope: "scenic_user_101",
topLevelPermissionURIs: ["task_management"]
)
XCTAssertEqual(uris, ["task_management"])
}
///
private func menu(_ uri: String) -> HomeMenuItem {
HomeMenuItem(title: HomeMenuRouter.title(for: uri), uri: uri, iconSrc: nil)

View File

@ -73,7 +73,7 @@ final class HomeMenuRouterTests: XCTestCase {
func testTabRoutesStayCentralized() {
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_orders", title: ""), .orders(.storeOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "/scenic-order-manage", title: ""), .orders(.storeOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orders(.verificationOrders))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "verification_order", title: ""), .orderPush(.writeOffList))
XCTAssertEqual(HomeMenuRouter.resolve(uri: "photographer_stats", title: ""), .tab(.statistics))
}

View File

@ -88,7 +88,17 @@ private final class MockMainTabOrderService: OrderServing {
}
///
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
func writeOffList(
scenicId: Int,
storeId: Int?,
page: Int,
pageSize: Int,
orderStatus: Int?,
orderVerifyStatus: Int?,
startTime: String?,
endTime: String?,
userPhone: String?
) async throws -> ListPayload<WriteOffOrderItem> {
writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
if let writeOffError {
throw writeOffError
@ -96,6 +106,29 @@ private final class MockMainTabOrderService: OrderServing {
return writeOffResponse
}
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
func cancelOrder(orderNumber: String) async throws {}
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
func storeOrderList(
storeId: Int?,
page: Int,
pageSize: Int,
userPhone: String?,
orderStatus: Int?,
orderType: Int?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<StoreOrderListItem> {
ListPayload(total: 0, list: [])
}
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws {}
///
func writeOff(orderNumber: String) async throws {}

View File

@ -30,14 +30,14 @@ final class NavigationRouterTests: XCTestCase {
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
}
///
///
func testRouteToOrderVerificationStoresPendingScanCode() {
let router = AppRouter()
router.routeToOrderVerification(scannedCode: "order_number=VERIFY001")
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
XCTAssertEqual(router.router(for: .orders).path.last, .orders(.writeOffList))
XCTAssertEqual(router.pendingOrderScanCode, "order_number=VERIFY001")
}

View File

@ -447,10 +447,43 @@ private final class LongTailMockOrderService: OrderServing {
}
/// 使
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
func writeOffList(
scenicId: Int,
storeId: Int?,
page: Int,
pageSize: Int,
orderStatus: Int?,
orderVerifyStatus: Int?,
startTime: String?,
endTime: String?,
userPhone: String?
) async throws -> ListPayload<WriteOffOrderItem> {
ListPayload(total: 0, list: [])
}
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
func cancelOrder(orderNumber: String) async throws {}
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
func storeOrderList(
storeId: Int?,
page: Int,
pageSize: Int,
userPhone: String?,
orderStatus: Int?,
orderType: Int?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<StoreOrderListItem> {
ListPayload(total: 0, list: [])
}
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws {}
/// 使
func writeOff(orderNumber: String) async throws {}

View File

@ -223,6 +223,93 @@ final class OrdersViewModelTests: XCTestCase {
XCTAssertEqual(query["end_time"], "2026-05-31")
}
///
func testUpdateRefinedChoiceUpdatesLocalOrder() async throws {
let api = MockOrderService()
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
try await viewModel.updateRefinedChoice(api: api, orderNumber: "ORDV2001", isRefined: 0)
XCTAssertEqual(api.refinedUpdates.count, 1)
XCTAssertEqual(api.refinedUpdates[0].0, "ORDV2001")
XCTAssertEqual(api.refinedUpdates[0].1, 0)
let order = try XCTUnwrap(viewModel.storeOrders.first { $0.orderNumber == "ORDV2001" })
XCTAssertEqual(order.isRefined, 0)
}
///
func testCancelOrderRefreshesStoreOrders() async throws {
let api = MockOrderService()
api.storeResponses = [
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success"),
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
try await viewModel.cancelOrder(api: api, scenicId: 88, appRole: nil, orderNumber: "ORDV2001")
XCTAssertEqual(api.cancelledOrders, ["ORDV2001"])
XCTAssertEqual(api.orderListCalls.count, 2)
}
///
func testSendGiftFlowPreparesAndSubmits() async throws {
let api = MockOrderService()
api.storeResponses = [
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success"),
try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")
]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
let order = try XCTUnwrap(viewModel.storeOrders.first)
viewModel.prepareSendGift(for: order)
XCTAssertEqual(viewModel.sendGiftContext?.orderNumber, order.orderNumber)
XCTAssertEqual(viewModel.sendGiftContext?.sendPhotoNum, 2)
try await viewModel.submitSendGift(api: api, scenicId: 88, appRole: nil, photoNum: 3, videoNum: 1)
XCTAssertEqual(api.sendGiftCalls.count, 1)
XCTAssertEqual(api.sendGiftCalls[0].0, order.orderNumber)
XCTAssertEqual(api.sendGiftCalls[0].1, 3)
XCTAssertEqual(api.sendGiftCalls[0].2, 1)
XCTAssertNil(viewModel.sendGiftContext)
XCTAssertEqual(api.orderListCalls.count, 2)
}
/// 退
func testShouldShowRefundButtonConditions() async throws {
let api = MockOrderService()
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
let multiTravel = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 19 })
let deposit = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 4 })
XCTAssertFalse(viewModel.shouldShowRefundButton(order: multiTravel, appRole: nil))
XCTAssertTrue(viewModel.shouldShowRefundButton(order: deposit, appRole: nil))
XCTAssertFalse(viewModel.shouldShowRefundButton(order: deposit, appRole: .scenicAdmin))
}
///
func testShouldShowVerifyButtonConditions() async throws {
let api = MockOrderService()
api.storeResponses = [try TestFixture.payload(ListPayload<OrderEntity>.self, named: "order_v2_success")]
let viewModel = OrdersViewModel()
try await viewModel.reloadStoreOrders(api: api, scenicId: 88, appRole: nil)
let multiTravel = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 19 })
let deposit = try XCTUnwrap(viewModel.storeOrders.first { $0.orderType == 4 })
XCTAssertTrue(viewModel.shouldShowVerifyButton(order: multiTravel, appRole: nil))
XCTAssertFalse(viewModel.shouldShowVerifyButton(order: deposit, appRole: nil))
XCTAssertFalse(viewModel.shouldShowVerifyButton(order: multiTravel, appRole: .scenicAdmin))
}
private static func date(_ text: String) -> Date {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
@ -261,6 +348,9 @@ private final class MockOrderService: OrderServing {
private(set) var writeOffListCalls: [WriteOffListCall] = []
private(set) var detailCalls: [(storeId: Int, orderNumber: String)] = []
private(set) var verifiedOrderNumbers: [String] = []
private(set) var refinedUpdates: [(String, Int)] = []
private(set) var cancelledOrders: [String] = []
private(set) var sendGiftCalls: [(String, Int, Int)] = []
///
func orderList(
@ -291,11 +381,50 @@ private final class MockOrderService: OrderServing {
}
///
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
func writeOffList(
scenicId: Int,
storeId: Int?,
page: Int,
pageSize: Int,
orderStatus: Int?,
orderVerifyStatus: Int?,
startTime: String?,
endTime: String?,
userPhone: String?
) async throws -> ListPayload<WriteOffOrderItem> {
writeOffListCalls.append(WriteOffListCall(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize))
return writeOffResponses.isEmpty ? ListPayload(total: 0, list: []) : writeOffResponses.removeFirst()
}
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {
refinedUpdates.append((orderNumber, isRefined))
}
func cancelOrder(orderNumber: String) async throws {
cancelledOrders.append(orderNumber)
}
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {
sendGiftCalls.append((orderNumber, photoNum, videoNum))
}
func storeOrderList(
storeId: Int?,
page: Int,
pageSize: Int,
userPhone: String?,
orderStatus: Int?,
orderType: Int?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<StoreOrderListItem> {
ListPayload(total: 0, list: [])
}
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws {}
///
func writeOff(orderNumber: String) async throws {
verifiedOrderNumbers.append(orderNumber)

View File

@ -171,6 +171,35 @@ final class ProfileSecondaryPagesTests: XCTestCase {
XCTAssertFalse(RealNameAuthViewModel.isValidMainlandIDCardNumber("123"))
}
/// 使
func testProfileDisplayFieldsUseCachedProfileBeforeUserInfoLoads() {
let viewModel = ProfileViewModel()
viewModel.applyCachedProfile(
AccountProfile(
userId: "101",
displayName: "缓存昵称",
phone: "13800000000",
avatarURL: "https://cdn.example.com/avatar.jpg"
)
)
XCTAssertEqual(viewModel.displayNickname, "缓存昵称")
XCTAssertEqual(viewModel.displayPhone, "13800000000")
XCTAssertEqual(viewModel.displayAvatarURL, "https://cdn.example.com/avatar.jpg")
}
///
func testApplyCachedProfileIgnoredAfterUserInfoLoaded() {
let viewModel = ProfileViewModel()
viewModel.userInfo = UserInfoResponse(nickname: "接口昵称", phone: "13900000000")
viewModel.applyCachedProfile(
AccountProfile(userId: "101", displayName: "缓存昵称", phone: "13800000000")
)
XCTAssertEqual(viewModel.displayNickname, "接口昵称")
XCTAssertEqual(viewModel.displayPhone, "13900000000")
}
/// OSS URL
func testProfileAvatarUploadUpdatesAvatarURL() async throws {
let session = ProfileSecondaryURLSession(responses: [

View File

@ -94,7 +94,7 @@ final class PushNotificationTests: XCTestCase {
manager.handleRemoteNotification(PushPayload(userInfo: ["route": "verification_order"]))
XCTAssertEqual(router.selectedTab, .orders)
XCTAssertEqual(router.selectedOrdersEntry, .verificationOrders)
XCTAssertEqual(router.router(for: .orders).path.last, .orders(.writeOffList))
}
private func pushManager(router: AppRouter) -> PushNotificationManager {

View File

@ -0,0 +1,224 @@
//
// StoreAdminOrderListViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// Tab ViewModel 退
final class StoreAdminOrderListViewModelTests: XCTestCase {
///
func testReloadClearsDataWhenStoreIsMissing() async throws {
let api = StoreAdminMockOrderService()
api.responses = [try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")]
let viewModel = StoreAdminOrderListViewModel()
try await viewModel.reload(api: api, storeId: 66)
XCTAssertFalse(viewModel.orders.isEmpty)
try await viewModel.reload(api: api, storeId: nil)
XCTAssertTrue(viewModel.orders.isEmpty)
XCTAssertEqual(viewModel.total, 0)
XCTAssertEqual(viewModel.page, 1)
XCTAssertFalse(viewModel.hasMore)
XCTAssertEqual(api.listCalls.count, 1)
}
///
func testReloadUsesFilterQueries() async throws {
let api = StoreAdminMockOrderService()
api.responses = [
try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success"),
try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")
]
let viewModel = StoreAdminOrderListViewModel()
viewModel.searchPhone = " 13800000000 "
viewModel.selectedStatus = 30
viewModel.filterStartDate = Self.date("2026-05-01")
viewModel.filterEndDate = Self.date("2026-05-31")
try await viewModel.reload(api: api, storeId: 66)
XCTAssertEqual(api.listCalls.count, 1)
XCTAssertEqual(api.listCalls[0].storeId, 66)
XCTAssertEqual(api.listCalls[0].userPhone, "13800000000")
XCTAssertEqual(api.listCalls[0].orderStatus, 30)
XCTAssertEqual(api.listCalls[0].startTime, "2026-05-01")
XCTAssertEqual(api.listCalls[0].endTime, "2026-05-31")
viewModel.selectedStatus = 0
viewModel.searchPhone = ""
try await viewModel.reload(api: api, storeId: 66)
XCTAssertEqual(api.listCalls[1].orderStatus, nil)
XCTAssertNil(api.listCalls[1].userPhone)
}
///
func testLoadMoreStopsAfterLastPage() async throws {
let api = StoreAdminMockOrderService()
api.responses = [try TestFixture.payload(ListPayload<StoreOrderListItem>.self, named: "store_order_list_success")]
let viewModel = StoreAdminOrderListViewModel()
try await viewModel.reload(api: api, storeId: 66)
try await viewModel.loadMore(api: api, storeId: 66)
XCTAssertEqual(viewModel.orders.count, 2)
XCTAssertEqual(api.listCalls.count, 1)
XCTAssertFalse(viewModel.hasMore)
}
/// 退
func testRefundUsesStoreParameters() async throws {
let api = StoreAdminMockOrderService()
let viewModel = StoreAdminOrderListViewModel()
try await viewModel.refund(
api: api,
storeId: 66,
orderNumber: "STORE001",
mode: .partial,
amount: "50.00",
reason: "部分退款"
)
XCTAssertEqual(api.refundCalls.count, 1)
XCTAssertEqual(api.refundCalls[0].storeId, 66)
XCTAssertEqual(api.refundCalls[0].orderNumber, "STORE001")
XCTAssertEqual(api.refundCalls[0].mode, .partial)
XCTAssertEqual(api.refundCalls[0].amount, "50.00")
XCTAssertEqual(api.refundCalls[0].reason, "部分退款")
}
private static func date(_ text: String) -> Date {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: text)!
}
}
@MainActor
/// storeOrderList 退
private final class StoreAdminMockOrderService: OrderServing {
struct ListCall: Equatable {
let storeId: Int?
let page: Int
let userPhone: String?
let orderStatus: Int?
let startTime: String?
let endTime: String?
}
struct RefundCall: Equatable {
let storeId: Int
let orderNumber: String
let mode: OrderRefundMode
let amount: String
let reason: String
}
var responses: [ListPayload<StoreOrderListItem>] = []
private(set) var listCalls: [ListCall] = []
private(set) var refundCalls: [RefundCall] = []
func orderList(
scenicId: Int,
page: Int,
pageSize: Int,
orderStatus: Int?,
userPhone: String?,
startTime: String?,
endTime: String?,
isRefined: Int?,
isScenicAdmin: Bool
) async throws -> ListPayload<OrderEntity> {
ListPayload(total: 0, list: [])
}
func writeOffList(
scenicId: Int,
storeId: Int?,
page: Int,
pageSize: Int,
orderStatus: Int?,
orderVerifyStatus: Int?,
startTime: String?,
endTime: String?,
userPhone: String?
) async throws -> ListPayload<WriteOffOrderItem> {
ListPayload(total: 0, list: [])
}
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
func cancelOrder(orderNumber: String) async throws {}
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
func storeOrderList(
storeId: Int?,
page: Int,
pageSize: Int,
userPhone: String?,
orderStatus: Int?,
orderType: Int?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<StoreOrderListItem> {
listCalls.append(
ListCall(
storeId: storeId,
page: page,
userPhone: userPhone,
orderStatus: orderStatus,
startTime: startTime,
endTime: endTime
)
)
return responses.isEmpty ? ListPayload(total: 0, list: []) : responses.removeFirst()
}
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws {
refundCalls.append(
RefundCall(
storeId: storeId,
orderNumber: orderNumber,
mode: refundType,
amount: refundAmount,
reason: refundReason
)
)
}
func writeOff(orderNumber: String) async throws {}
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
}
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
ListPayload(total: 0, list: [])
}
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 {
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
}
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
}
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { [] }
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
}

View File

@ -0,0 +1,222 @@
//
// WriteOffListViewModelTests.swift
// suixinkanTests
//
// Created by Codex on 2026/6/29.
//
import XCTest
@testable import suixinkan
@MainActor
/// ViewModel
final class WriteOffListViewModelTests: XCTestCase {
///
func testReloadClearsDataWhenScenicIsMissing() async throws {
let api = WriteOffMockOrderService()
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
let viewModel = WriteOffListViewModel()
try await viewModel.reload(api: api, scenicId: 88, storeId: 66)
XCTAssertFalse(viewModel.orders.isEmpty)
try await viewModel.reload(api: api, scenicId: nil)
XCTAssertTrue(viewModel.orders.isEmpty)
XCTAssertEqual(viewModel.total, 0)
XCTAssertEqual(api.listCalls.count, 1)
}
/// Tab
func testReloadUsesStatusAndPhoneFilters() async throws {
let api = WriteOffMockOrderService()
api.responses = [
try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success"),
try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")
]
let viewModel = WriteOffListViewModel()
viewModel.searchPhone = "13800000000"
viewModel.selectedStatus = 18
viewModel.filterStartDate = Self.date("2026-05-01")
viewModel.filterEndDate = Self.date("2026-05-31")
try await viewModel.reload(api: api, scenicId: 88, storeId: 66)
XCTAssertEqual(api.listCalls[0].orderStatus, 18)
XCTAssertNil(api.listCalls[0].orderVerifyStatus)
XCTAssertEqual(api.listCalls[0].userPhone, "13800000000")
XCTAssertEqual(api.listCalls[0].startTime, "2026-05-01")
XCTAssertEqual(api.listCalls[0].endTime, "2026-05-31")
viewModel.selectedStatus = 1
try await viewModel.reload(api: api, scenicId: 88)
XCTAssertNil(api.listCalls[1].orderStatus)
XCTAssertEqual(api.listCalls[1].orderVerifyStatus, 1)
}
///
func testVerifyRefreshesListAfterSuccess() async throws {
let api = WriteOffMockOrderService()
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
let viewModel = WriteOffListViewModel()
try await viewModel.verify(api: api, scenicId: 88, storeId: 66, orderNumber: "VERIFY001")
XCTAssertEqual(api.verifiedOrderNumbers, ["VERIFY001"])
XCTAssertEqual(api.listCalls.count, 1)
XCTAssertEqual(viewModel.orders.count, 1)
XCTAssertFalse(viewModel.isVerifying)
}
///
func testMatchedWriteOffOrderFindsCurrentListItem() async throws {
let api = WriteOffMockOrderService()
api.responses = [try TestFixture.payload(ListPayload<WriteOffOrderItem>.self, named: "writeoff_list_success")]
let viewModel = WriteOffListViewModel()
try await viewModel.reload(api: api, scenicId: 88)
let result = try XCTUnwrap(viewModel.matchedWriteOffOrder(for: "order_number=VERIFY001"))
XCTAssertEqual(result.orderNumber, "VERIFY001")
XCTAssertEqual(result.matched?.orderNumber, "VERIFY001")
}
/// canVerify
func testCanVerifyExcludesVerifiedStatus() throws {
let viewModel = WriteOffListViewModel()
let verified = try JSONDecoder().decode(
WriteOffOrderItem.self,
from: Data(
"""
{
"order_number": "V2",
"order_verification_status": "",
"pay_time": "",
"project_name": "",
"user_phone": "",
"order_amount": "",
"order_status_name": "",
"order_verification_time": ""
}
""".utf8
)
)
XCTAssertFalse(viewModel.canVerify(verified))
}
private static func date(_ text: String) -> Date {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter.date(from: text)!
}
}
@MainActor
/// writeOffList
private final class WriteOffMockOrderService: OrderServing {
struct ListCall: Equatable {
let scenicId: Int
let storeId: Int?
let page: Int
let orderStatus: Int?
let orderVerifyStatus: Int?
let startTime: String?
let endTime: String?
let userPhone: String?
}
var responses: [ListPayload<WriteOffOrderItem>] = []
private(set) var listCalls: [ListCall] = []
private(set) var verifiedOrderNumbers: [String] = []
func orderList(
scenicId: Int,
page: Int,
pageSize: Int,
orderStatus: Int?,
userPhone: String?,
startTime: String?,
endTime: String?,
isRefined: Int?,
isScenicAdmin: Bool
) async throws -> ListPayload<OrderEntity> {
ListPayload(total: 0, list: [])
}
func writeOffList(
scenicId: Int,
storeId: Int?,
page: Int,
pageSize: Int,
orderStatus: Int?,
orderVerifyStatus: Int?,
startTime: String?,
endTime: String?,
userPhone: String?
) async throws -> ListPayload<WriteOffOrderItem> {
listCalls.append(
ListCall(
scenicId: scenicId,
storeId: storeId,
page: page,
orderStatus: orderStatus,
orderVerifyStatus: orderVerifyStatus,
startTime: startTime,
endTime: endTime,
userPhone: userPhone
)
)
return responses.isEmpty ? ListPayload(total: 0, list: []) : responses.removeFirst()
}
func updateRefinedChoice(orderNumber: String, isRefined: Int) async throws {}
func cancelOrder(orderNumber: String) async throws {}
func sendGift(orderNumber: String, photoNum: Int, videoNum: Int) async throws {}
func storeOrderList(
storeId: Int?,
page: Int,
pageSize: Int,
userPhone: String?,
orderStatus: Int?,
orderType: Int?,
startTime: String?,
endTime: String?
) async throws -> ListPayload<StoreOrderListItem> {
ListPayload(total: 0, list: [])
}
func storeOrderRefund(
storeId: Int,
orderNumber: String,
refundType: OrderRefundMode,
refundAmount: String,
refundReason: String
) async throws {}
func writeOff(orderNumber: String) async throws {
verifiedOrderNumbers.append(orderNumber)
}
func storeOrderDetail(storeId: Int, orderNumber: String) async throws -> StoreOrderDetailResponse {
try TestFixture.payload(StoreOrderDetailResponse.self, named: "store_order_detail_success")
}
func depositOrderList(scenicId: Int, page: Int, pageSize: Int) async throws -> ListPayload<DepositOrderListItem> {
ListPayload(total: 0, list: [])
}
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 {
try TestFixture.payload(StoreOrderShootingDetailResponse.self, named: "store_order_shooting_detail_success")
}
func orderRefund(orderNumber: String, refundType: OrderRefundMode, refundAmount: String, refundReason: String) async throws {}
func multiTravelShootHistory(orderNumber: String) async throws -> MultiTravelShootHistoryResponse {
try TestFixture.payload(MultiTravelShootHistoryResponse.self, named: "multi_travel_shoot_history_success")
}
func multiTravelVerifiedScenicSpotList(orderNumber: String) async throws -> [MultiTravelVerifiedScenicSpotItem] { [] }
func multiTravelUploadMaterial(_ request: MultiTravelUploadMaterialRequest) async throws {}
}