From 08492df6ce3e571945dcc56ce5b97ea69b16b56f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Mon, 29 Jun 2026 14:46:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E9=97=A8=E5=BA=97=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E4=B8=8E=E6=A0=B8=E9=94=80=E7=AE=A1=E7=90=86=E5=88=97?= =?UTF-8?q?=E8=A1=A8=EF=BC=8C=E5=AF=B9=E9=BD=90=20Android=20=E8=AE=A2?= =?UTF-8?q?=E5=8D=95=E8=83=BD=E5=8A=9B=E5=B9=B6=E5=AE=8C=E5=96=84=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E7=BA=A7=E7=BC=93=E5=AD=98=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- suixinkan.xcodeproj/project.pbxproj | 3 +- suixinkan/App/App.md | 2 +- suixinkan/App/AppUITestRouteDriver.swift | 3 + .../App/Navigation/NavigationRouter.swift | 13 +- suixinkan/App/State/AccountContext.swift | 14 +- .../App/State/AuthSessionCoordinator.swift | 8 +- suixinkan/App/State/SessionBootstrapper.swift | 4 +- .../AppIcon.appiconset/Contents.json | 3 + .../AppIcon.appiconset/icon_1024.png | Bin 0 -> 108416 bytes suixinkan/Core/Design/AppDesign.swift | 17 + .../Core/Push/PushNotificationManager.swift | 2 +- .../Core/Storage/AccountSnapshotStore.swift | 2 +- suixinkan/Features/Home/Home.md | 4 +- .../Features/Home/Models/HomeMenuItem.swift | 1 + .../Home/Routing/HomeMenuRouter.swift | 4 +- .../Home/Services/HomeCommonMenuStore.swift | 101 +++-- .../Home/Views/HomeMoreFunctionsView.swift | 15 +- suixinkan/Features/Home/Views/HomeView.swift | 4 + suixinkan/Features/Main/Main.md | 2 +- .../ViewModels/MainTabBadgeViewModel.swift | 12 +- .../Main/Views/PlaceholderRootViews.swift | 10 +- suixinkan/Features/Orders/API/OrdersAPI.swift | 171 +++++++- .../Features/Orders/Models/OrderModels.swift | 168 +++++++- suixinkan/Features/Orders/Orders.md | 92 +++-- .../Orders/ViewModels/OrdersViewModel.swift | 108 ++++- .../StoreAdminOrderListViewModel.swift | 112 +++++ .../ViewModels/WriteOffListViewModel.swift | 141 +++++++ .../Orders/Views/DateRangePickerSheet.swift | 101 +++++ .../Orders/Views/OrderDialogViews.swift | 207 ++++++++++ .../Orders/Views/OrderListCardView.swift | 237 +++++++++++ .../Orders/Views/OrderListComponents.swift | 276 +++++++++++++ .../Orders/Views/OrderListHeaderView.swift | 71 ++++ .../Views/StoreAdminOrderListView.swift | 262 ++++++++++++ .../Orders/Views/StoreOrderListView.swift | 382 ++++++++++++++++++ .../Orders/Views/WriteOffListView.swift | 309 ++++++++++++++ suixinkan/Features/Profile/Profile.md | 5 +- .../Profile/ViewModels/ProfileViewModel.swift | 16 +- .../Views/DebugHomeMenuPreviewView.swift | 9 +- .../Features/Profile/Views/ProfileView.swift | 12 +- suixinkanTests/AccountContextTests.swift | 12 + .../Fixtures/store_order_list_success.json | 45 +++ suixinkanTests/HomeCommonMenuStoreTests.swift | 126 +++++- suixinkanTests/HomeMenuRouterTests.swift | 2 +- .../MainTabBadgeViewModelTests.swift | 35 +- suixinkanTests/NavigationRouterTests.swift | 4 +- suixinkanTests/OrderLongTailTests.swift | 35 +- suixinkanTests/OrdersViewModelTests.swift | 131 +++++- .../ProfileSecondaryPagesTests.swift | 29 ++ suixinkanTests/PushNotificationTests.swift | 2 +- .../StoreAdminOrderListViewModelTests.swift | 224 ++++++++++ .../WriteOffListViewModelTests.swift | 222 ++++++++++ 51 files changed, 3668 insertions(+), 102 deletions(-) create mode 100644 suixinkan/Assets.xcassets/AppIcon.appiconset/icon_1024.png create mode 100644 suixinkan/Features/Orders/ViewModels/StoreAdminOrderListViewModel.swift create mode 100644 suixinkan/Features/Orders/ViewModels/WriteOffListViewModel.swift create mode 100644 suixinkan/Features/Orders/Views/DateRangePickerSheet.swift create mode 100644 suixinkan/Features/Orders/Views/OrderDialogViews.swift create mode 100644 suixinkan/Features/Orders/Views/OrderListCardView.swift create mode 100644 suixinkan/Features/Orders/Views/OrderListComponents.swift create mode 100644 suixinkan/Features/Orders/Views/OrderListHeaderView.swift create mode 100644 suixinkan/Features/Orders/Views/StoreAdminOrderListView.swift create mode 100644 suixinkan/Features/Orders/Views/StoreOrderListView.swift create mode 100644 suixinkan/Features/Orders/Views/WriteOffListView.swift create mode 100644 suixinkanTests/Fixtures/store_order_list_success.json create mode 100644 suixinkanTests/StoreAdminOrderListViewModelTests.swift create mode 100644 suixinkanTests/WriteOffListViewModelTests.swift 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 0000000000000000000000000000000000000000..508217dfff459dab56c4be632c155bfea58865a9 GIT binary patch literal 108416 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

>9_+FTqmo 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 = [ + "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 {} +}