diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index d168fb9..f20a244 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -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; diff --git a/suixinkan/App/App.md b/suixinkan/App/App.md index e43201c..0ba5e32 100644 --- a/suixinkan/App/App.md +++ b/suixinkan/App/App.md @@ -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 自己的导航路径。 diff --git a/suixinkan/App/AppUITestRouteDriver.swift b/suixinkan/App/AppUITestRouteDriver.swift index eeae42c..257e9fd 100644 --- a/suixinkan/App/AppUITestRouteDriver.swift +++ b/suixinkan/App/AppUITestRouteDriver.swift @@ -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)) diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index 360cf1c..5852067 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -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)) } /// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。 diff --git a/suixinkan/App/State/AccountContext.swift b/suixinkan/App/State/AccountContext.swift index c781c19..089935b 100644 --- a/suixinkan/App/State/AccountContext.swift +++ b/suixinkan/App/State/AccountContext.swift @@ -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 diff --git a/suixinkan/App/State/AuthSessionCoordinator.swift b/suixinkan/App/State/AuthSessionCoordinator.swift index 332f438..43b405b 100644 --- a/suixinkan/App/State/AuthSessionCoordinator.swift +++ b/suixinkan/App/State/AuthSessionCoordinator.swift @@ -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 diff --git a/suixinkan/App/State/SessionBootstrapper.swift b/suixinkan/App/State/SessionBootstrapper.swift index 678100f..cd65ca9 100644 --- a/suixinkan/App/State/SessionBootstrapper.swift +++ b/suixinkan/App/State/SessionBootstrapper.swift @@ -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, diff --git a/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json b/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..24cada9 100644 --- a/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/suixinkan/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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" diff --git a/suixinkan/Assets.xcassets/AppIcon.appiconset/icon_1024.png b/suixinkan/Assets.xcassets/AppIcon.appiconset/icon_1024.png new file mode 100644 index 0000000..508217d Binary files /dev/null and b/suixinkan/Assets.xcassets/AppIcon.appiconset/icon_1024.png differ diff --git a/suixinkan/Core/Design/AppDesign.swift b/suixinkan/Core/Design/AppDesign.swift index 8b3c2e0..95fa917 100644 --- a/suixinkan/Core/Design/AppDesign.swift +++ b/suixinkan/Core/Design/AppDesign.swift @@ -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 { diff --git a/suixinkan/Core/Push/PushNotificationManager.swift b/suixinkan/Core/Push/PushNotificationManager.swift index 946012a..16cb377 100644 --- a/suixinkan/Core/Push/PushNotificationManager.swift +++ b/suixinkan/Core/Push/PushNotificationManager.swift @@ -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: diff --git a/suixinkan/Core/Storage/AccountSnapshotStore.swift b/suixinkan/Core/Storage/AccountSnapshotStore.swift index cfad2f4..7a1f54e 100644 --- a/suixinkan/Core/Storage/AccountSnapshotStore.swift +++ b/suixinkan/Core/Storage/AccountSnapshotStore.swift @@ -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, diff --git a/suixinkan/Features/Home/Home.md b/suixinkan/Features/Home/Home.md index bd8daf0..cd87584 100644 --- a/suixinkan/Features/Home/Home.md +++ b/suixinkan/Features/Home/Home.md @@ -18,8 +18,10 @@ Home 模块负责登录后的首页工作台,包括当前景区展示、工作 ## 常用应用 -`HomeCommonMenuStore` 使用 UserDefaults 按角色保存常用应用 URI(key:`home.common.menu.uris.role.{roleCode}`): +`HomeCommonMenuStore` 使用 UserDefaults 按账号和角色保存常用应用 URI: +- 当前版本使用账号 + 角色作用域保存常用应用 URI,避免不同业务账号的同一角色互相覆盖;旧版角色 key 会在首次读取时迁移。 - 首次无配置时,按 Android 规则从当前角色顶层权限 URI 生成默认值:权限多于 4 个取前 4 个,否则全部使用。 +- 首页常用应用按 Android `Constants.menuList` 白名单过滤;iOS 已接入但 Android 首页未登记的 URI 不进入首页常用应用。 - 读取时按当前角色权限过滤不可用 URI。 - 添加和移除时按同义 URI 去重。 - 不同角色的常用应用互不影响。 diff --git a/suixinkan/Features/Home/Models/HomeMenuItem.swift b/suixinkan/Features/Home/Models/HomeMenuItem.swift index cbfb7f7..429f7c0 100644 --- a/suixinkan/Features/Home/Models/HomeMenuItem.swift +++ b/suixinkan/Features/Home/Models/HomeMenuItem.swift @@ -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) diff --git a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift index 05bf611..c2fcb03 100644 --- a/suixinkan/Features/Home/Routing/HomeMenuRouter.swift +++ b/suixinkan/Features/Home/Routing/HomeMenuRouter.swift @@ -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 diff --git a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift index bcc1b8b..96b7356 100644 --- a/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift +++ b/suixinkan/Features/Home/Services/HomeCommonMenuStore.swift @@ -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 = [ + "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() diff --git a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift index 9c74450..3e2f95c 100644 --- a/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift +++ b/suixinkan/Features/Home/Views/HomeMoreFunctionsView.swift @@ -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) ) diff --git a/suixinkan/Features/Home/Views/HomeView.swift b/suixinkan/Features/Home/Views/HomeView.swift index a602a90..8bda946 100644 --- a/suixinkan/Features/Home/Views/HomeView.swift +++ b/suixinkan/Features/Home/Views/HomeView.swift @@ -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) ) diff --git a/suixinkan/Features/Main/Main.md b/suixinkan/Features/Main/Main.md index f1073e1..48ead64 100644 --- a/suixinkan/Features/Main/Main.md +++ b/suixinkan/Features/Main/Main.md @@ -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`。 ## 导航流程 diff --git a/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift b/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift index f687eef..920f949 100644 --- a/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift +++ b/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift @@ -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 diff --git a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift index 0e6741a..f9d5666 100644 --- a/suixinkan/Features/Main/Views/PlaceholderRootViews.swift +++ b/suixinkan/Features/Main/Views/PlaceholderRootViews.swift @@ -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() + } } } diff --git a/suixinkan/Features/Orders/API/OrdersAPI.swift b/suixinkan/Features/Orders/API/OrdersAPI.swift index 4100c59..3d2e4b9 100644 --- a/suixinkan/Features/Orders/API/OrdersAPI.swift +++ b/suixinkan/Features/Orders/API/OrdersAPI.swift @@ -25,11 +25,51 @@ protocol OrderServing { ) async throws -> ListPayload /// 获取核销订单列表。 - func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload + func writeOffList( + scenicId: Int, + storeId: Int?, + page: Int, + pageSize: Int, + orderStatus: Int?, + orderVerifyStatus: Int?, + startTime: String?, + endTime: String?, + userPhone: String? + ) async throws -> ListPayload /// 核销指定订单号。 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 + + /// 申请店长店铺订单退款。 + 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 { + 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 { 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 { + 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 + ) + ) + ) + } } diff --git a/suixinkan/Features/Orders/Models/OrderModels.swift b/suixinkan/Features/Orders/Models/OrderModels.swift index fc80e8c..ab7230b 100644 --- a/suixinkan/Features/Orders/Models/OrderModels.swift +++ b/suixinkan/Features/Orders/Models/OrderModels.swift @@ -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 } /// 订单详情接口响应实体,承载门店订单详情页的完整展示数据。 diff --git a/suixinkan/Features/Orders/Orders.md b/suixinkan/Features/Orders/Orders.md index fc7ee2c..b369c6f 100644 --- a/suixinkan/Features/Orders/Orders.md +++ b/suixinkan/Features/Orders/Orders.md @@ -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 对齐)。 diff --git a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift index fb37b4c..bc2c3f9 100644 --- a/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift +++ b/suixinkan/Features/Orders/ViewModels/OrdersViewModel.swift @@ -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 } diff --git a/suixinkan/Features/Orders/ViewModels/StoreAdminOrderListViewModel.swift b/suixinkan/Features/Orders/ViewModels/StoreAdminOrderListViewModel.swift new file mode 100644 index 0000000..4e0066a --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/StoreAdminOrderListViewModel.swift @@ -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 + } +} diff --git a/suixinkan/Features/Orders/ViewModels/WriteOffListViewModel.swift b/suixinkan/Features/Orders/ViewModels/WriteOffListViewModel.swift new file mode 100644 index 0000000..c0ae2f8 --- /dev/null +++ b/suixinkan/Features/Orders/ViewModels/WriteOffListViewModel.swift @@ -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 + } + } +} diff --git a/suixinkan/Features/Orders/Views/DateRangePickerSheet.swift b/suixinkan/Features/Orders/Views/DateRangePickerSheet.swift new file mode 100644 index 0000000..15fdff1 --- /dev/null +++ b/suixinkan/Features/Orders/Views/DateRangePickerSheet.swift @@ -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) + } +} diff --git a/suixinkan/Features/Orders/Views/OrderDialogViews.swift b/suixinkan/Features/Orders/Views/OrderDialogViews.swift new file mode 100644 index 0000000..9ae765f --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderDialogViews.swift @@ -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, range: ClosedRange) -> 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) + } + } + } + } +} diff --git a/suixinkan/Features/Orders/Views/OrderListCardView.swift b/suixinkan/Features/Orders/Views/OrderListCardView.swift new file mode 100644 index 0000000..0cb929b --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderListCardView.swift @@ -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)") + } +} diff --git a/suixinkan/Features/Orders/Views/OrderListComponents.swift b/suixinkan/Features/Orders/Views/OrderListComponents.swift new file mode 100644 index 0000000..187d1ec --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderListComponents.swift @@ -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 + } +} diff --git a/suixinkan/Features/Orders/Views/OrderListHeaderView.swift b/suixinkan/Features/Orders/Views/OrderListHeaderView.swift new file mode 100644 index 0000000..eb284dd --- /dev/null +++ b/suixinkan/Features/Orders/Views/OrderListHeaderView.swift @@ -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)) + } +} diff --git a/suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift b/suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift new file mode 100644 index 0000000..5be2e7d --- /dev/null +++ b/suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift @@ -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)) + } +} diff --git a/suixinkan/Features/Orders/Views/StoreOrderListView.swift b/suixinkan/Features/Orders/Views/StoreOrderListView.swift new file mode 100644 index 0000000..898b4a2 --- /dev/null +++ b/suixinkan/Features/Orders/Views/StoreOrderListView.swift @@ -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 { + Binding( + get: { viewModel.refundingOrder }, + set: { viewModel.refundingOrder = $0 } + ) + } + + private var sendGiftBinding: Binding { + Binding( + get: { viewModel.sendGiftContext }, + set: { viewModel.sendGiftContext = $0 } + ) + } + + private var bindAcquirerBinding: Binding { + Binding( + get: { bindAcquirerSaleUserId.map(BindAcquirerSheetItem.init(saleUserId:)) }, + set: { bindAcquirerSaleUserId = $0?.saleUserId } + ) + } + + private var referralBindBinding: Binding { + 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) + } +} diff --git a/suixinkan/Features/Orders/Views/WriteOffListView.swift b/suixinkan/Features/Orders/Views/WriteOffListView.swift new file mode 100644 index 0000000..33fdd55 --- /dev/null +++ b/suixinkan/Features/Orders/Views/WriteOffListView.swift @@ -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) + } +} diff --git a/suixinkan/Features/Profile/Profile.md b/suixinkan/Features/Profile/Profile.md index f26b287..76d1ffd 100644 --- a/suixinkan/Features/Profile/Profile.md +++ b/suixinkan/Features/Profile/Profile.md @@ -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`)预填充昵称、手机号和头像;接口返回后再替换为最新数据。 - 昵称为空时展示“未设置昵称”。 - 真实姓名、手机号和账号状态为空时展示 `--`。 - 实名认证状态: diff --git a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift index 138490c..2b29da0 100644 --- a/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift +++ b/suixinkan/Features/Profile/ViewModels/ProfileViewModel.swift @@ -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 } diff --git a/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift b/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift index d5fc202..1cb2b55 100644 --- a/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift +++ b/suixinkan/Features/Profile/Views/DebugHomeMenuPreviewView.swift @@ -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 diff --git a/suixinkan/Features/Profile/Views/ProfileView.swift b/suixinkan/Features/Profile/Views/ProfileView.swift index 61c5753..b06b7cf 100644 --- a/suixinkan/Features/Profile/Views/ProfileView.swift +++ b/suixinkan/Features/Profile/Views/ProfileView.swift @@ -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 { diff --git a/suixinkanTests/AccountContextTests.swift b/suixinkanTests/AccountContextTests.swift index cb13fec..752f489 100644 --- a/suixinkanTests/AccountContextTests.swift +++ b/suixinkanTests/AccountContextTests.swift @@ -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() diff --git a/suixinkanTests/Fixtures/store_order_list_success.json b/suixinkanTests/Fixtures/store_order_list_success.json new file mode 100644 index 0000000..9a49b9b --- /dev/null +++ b/suixinkanTests/Fixtures/store_order_list_success.json @@ -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": "" + } + ] + } +} diff --git a/suixinkanTests/HomeCommonMenuStoreTests.swift b/suixinkanTests/HomeCommonMenuStoreTests.swift index a7b670b..db8ea67 100644 --- a/suixinkanTests/HomeCommonMenuStoreTests.swift +++ b/suixinkanTests/HomeCommonMenuStoreTests.swift @@ -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) diff --git a/suixinkanTests/HomeMenuRouterTests.swift b/suixinkanTests/HomeMenuRouterTests.swift index 587e8e2..ff7a565 100644 --- a/suixinkanTests/HomeMenuRouterTests.swift +++ b/suixinkanTests/HomeMenuRouterTests.swift @@ -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)) } diff --git a/suixinkanTests/MainTabBadgeViewModelTests.swift b/suixinkanTests/MainTabBadgeViewModelTests.swift index 9219487..0565812 100644 --- a/suixinkanTests/MainTabBadgeViewModelTests.swift +++ b/suixinkanTests/MainTabBadgeViewModelTests.swift @@ -88,7 +88,17 @@ private final class MockMainTabOrderService: OrderServing { } /// 记录核销列表请求并返回预设响应。 - func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + func writeOffList( + scenicId: Int, + storeId: Int?, + page: Int, + pageSize: Int, + orderStatus: Int?, + orderVerifyStatus: Int?, + startTime: String?, + endTime: String?, + userPhone: String? + ) async throws -> ListPayload { 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 { + ListPayload(total: 0, list: []) + } + func storeOrderRefund( + storeId: Int, + orderNumber: String, + refundType: OrderRefundMode, + refundAmount: String, + refundReason: String + ) async throws {} + /// 角标测试不会触发核销操作。 func writeOff(orderNumber: String) async throws {} diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index 3921740..4a48c8a 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -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") } diff --git a/suixinkanTests/OrderLongTailTests.swift b/suixinkanTests/OrderLongTailTests.swift index 91fa48e..5e6ae98 100644 --- a/suixinkanTests/OrderLongTailTests.swift +++ b/suixinkanTests/OrderLongTailTests.swift @@ -447,10 +447,43 @@ private final class LongTailMockOrderService: OrderServing { } /// 核销列表接口在长尾测试中不使用。 - func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload { + func writeOffList( + scenicId: Int, + storeId: Int?, + page: Int, + pageSize: Int, + orderStatus: Int?, + orderVerifyStatus: Int?, + startTime: String?, + endTime: String?, + userPhone: String? + ) async throws -> ListPayload { 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 { + ListPayload(total: 0, list: []) + } + func storeOrderRefund( + storeId: Int, + orderNumber: String, + refundType: OrderRefundMode, + refundAmount: String, + refundReason: String + ) async throws {} + /// 普通核销接口在长尾测试中不使用。 func writeOff(orderNumber: String) async throws {} diff --git a/suixinkanTests/OrdersViewModelTests.swift b/suixinkanTests/OrdersViewModelTests.swift index 94f1ec1..377167b 100644 --- a/suixinkanTests/OrdersViewModelTests.swift +++ b/suixinkanTests/OrdersViewModelTests.swift @@ -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.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.self, named: "order_v2_success"), + try TestFixture.payload(ListPayload.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.self, named: "order_v2_success"), + try TestFixture.payload(ListPayload.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.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.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 { + func writeOffList( + scenicId: Int, + storeId: Int?, + page: Int, + pageSize: Int, + orderStatus: Int?, + orderVerifyStatus: Int?, + startTime: String?, + endTime: String?, + userPhone: String? + ) async throws -> ListPayload { 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 { + 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) diff --git a/suixinkanTests/ProfileSecondaryPagesTests.swift b/suixinkanTests/ProfileSecondaryPagesTests.swift index 82b5f8f..5de60af 100644 --- a/suixinkanTests/ProfileSecondaryPagesTests.swift +++ b/suixinkanTests/ProfileSecondaryPagesTests.swift @@ -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: [ diff --git a/suixinkanTests/PushNotificationTests.swift b/suixinkanTests/PushNotificationTests.swift index f699b2a..81dce22 100644 --- a/suixinkanTests/PushNotificationTests.swift +++ b/suixinkanTests/PushNotificationTests.swift @@ -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 { diff --git a/suixinkanTests/StoreAdminOrderListViewModelTests.swift b/suixinkanTests/StoreAdminOrderListViewModelTests.swift new file mode 100644 index 0000000..73ea587 --- /dev/null +++ b/suixinkanTests/StoreAdminOrderListViewModelTests.swift @@ -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.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.self, named: "store_order_list_success"), + try TestFixture.payload(ListPayload.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.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] = [] + 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 { + 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 { + 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 { + 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 { + 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 {} +} diff --git a/suixinkanTests/WriteOffListViewModelTests.swift b/suixinkanTests/WriteOffListViewModelTests.swift new file mode 100644 index 0000000..763636e --- /dev/null +++ b/suixinkanTests/WriteOffListViewModelTests.swift @@ -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.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.self, named: "writeoff_list_success"), + try TestFixture.payload(ListPayload.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.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.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] = [] + 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 { + 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 { + 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 { + 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 { + 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 {} +}