diff --git a/suixinkan/App/Navigation/NavigationRouter.swift b/suixinkan/App/Navigation/NavigationRouter.swift index fbdc62d..030f604 100644 --- a/suixinkan/App/Navigation/NavigationRouter.swift +++ b/suixinkan/App/Navigation/NavigationRouter.swift @@ -72,6 +72,7 @@ final class RouterPath { final class AppRouter { var selectedTab: AppTab = .home var selectedOrdersEntry: OrdersEntry = .storeOrders + private(set) var pendingOrderScanCode: String? private var routers: [AppTab: RouterPath] = [:] /// 获取指定 Tab 对应的路由路径容器,不存在时自动创建。 @@ -106,10 +107,24 @@ final class AppRouter { selectedTab = .orders } + /// 切换到核销订单入口,并暂存全局扫码结果等待订单页消费。 + func routeToOrderVerification(scannedCode: String) { + pendingOrderScanCode = scannedCode + selectOrders(entry: .verificationOrders) + } + + /// 读取并清空待处理的全局扫码结果,确保同一次扫码只被处理一次。 + func consumePendingOrderScanCode() -> String? { + let code = pendingOrderScanCode + pendingOrderScanCode = nil + return code + } + /// 重置主 Tab 选择和所有 Tab 的导航历史。 func reset() { selectedTab = .home selectedOrdersEntry = .storeOrders + pendingOrderScanCode = nil routers.values.forEach { $0.reset() } } } diff --git a/suixinkan/Assets.xcassets/TabDataSelected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabDataSelected.imageset/Contents.json new file mode 100644 index 0000000..37520de --- /dev/null +++ b/suixinkan/Assets.xcassets/TabDataSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_data_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png b/suixinkan/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png new file mode 100644 index 0000000..25a61ce Binary files /dev/null and b/suixinkan/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png differ diff --git a/suixinkan/Assets.xcassets/TabDataUnselected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabDataUnselected.imageset/Contents.json new file mode 100644 index 0000000..faa503c --- /dev/null +++ b/suixinkan/Assets.xcassets/TabDataUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_data_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png b/suixinkan/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png new file mode 100644 index 0000000..2a92be2 Binary files /dev/null and b/suixinkan/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png differ diff --git a/suixinkan/Assets.xcassets/TabHomeSelected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabHomeSelected.imageset/Contents.json new file mode 100644 index 0000000..8cca175 --- /dev/null +++ b/suixinkan/Assets.xcassets/TabHomeSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_home_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png b/suixinkan/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png new file mode 100644 index 0000000..8aeef0c Binary files /dev/null and b/suixinkan/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png differ diff --git a/suixinkan/Assets.xcassets/TabHomeUnselected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabHomeUnselected.imageset/Contents.json new file mode 100644 index 0000000..7c6d760 --- /dev/null +++ b/suixinkan/Assets.xcassets/TabHomeUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_home_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png b/suixinkan/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png new file mode 100644 index 0000000..48cedd3 Binary files /dev/null and b/suixinkan/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png differ diff --git a/suixinkan/Assets.xcassets/TabOrderSelected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabOrderSelected.imageset/Contents.json new file mode 100644 index 0000000..1b32685 --- /dev/null +++ b/suixinkan/Assets.xcassets/TabOrderSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_order_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png b/suixinkan/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png new file mode 100644 index 0000000..8d10fbc Binary files /dev/null and b/suixinkan/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png differ diff --git a/suixinkan/Assets.xcassets/TabOrderUnselected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabOrderUnselected.imageset/Contents.json new file mode 100644 index 0000000..0241d89 --- /dev/null +++ b/suixinkan/Assets.xcassets/TabOrderUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_order_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png b/suixinkan/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png new file mode 100644 index 0000000..366d345 Binary files /dev/null and b/suixinkan/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png differ diff --git a/suixinkan/Assets.xcassets/TabProfileSelected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabProfileSelected.imageset/Contents.json new file mode 100644 index 0000000..3c5e11e --- /dev/null +++ b/suixinkan/Assets.xcassets/TabProfileSelected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_profile_selected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png b/suixinkan/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png new file mode 100644 index 0000000..bf60175 Binary files /dev/null and b/suixinkan/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png differ diff --git a/suixinkan/Assets.xcassets/TabProfileUnselected.imageset/Contents.json b/suixinkan/Assets.xcassets/TabProfileUnselected.imageset/Contents.json new file mode 100644 index 0000000..565e49d --- /dev/null +++ b/suixinkan/Assets.xcassets/TabProfileUnselected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "tab_profile_unselected.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/suixinkan/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png b/suixinkan/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png new file mode 100644 index 0000000..9c24f06 Binary files /dev/null and b/suixinkan/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png differ diff --git a/suixinkan/Features/Main/Main.md b/suixinkan/Features/Main/Main.md index e94a3e7..caccf7b 100644 --- a/suixinkan/Features/Main/Main.md +++ b/suixinkan/Features/Main/Main.md @@ -5,8 +5,10 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页面的占位入口。 主界面由 `MainTabsView` 承载: -- 使用 `TabView` 展示首页、订单、数据、我的四个 Tab。 -- 每个 Tab 内部都包裹独立的 `NavigationStack`。 +- 根据 `MainTabBarConfiguration.activeStyle` 选择自定义 TabBar 或系统 `TabView`。 +- 默认使用自定义 TabBar;如需切回系统实现,将配置改为 `.system`。 +- 自定义和系统两套外壳都展示首页、订单、数据、我的四个 Tab。 +- 每个 Tab 内部都包裹独立的 `NavigationStack`,并通过 `TabNavigationStackHost` 复用同一套导航构建逻辑。 - 每个 Tab 的导航路径由 `AppRouter` 单独保存。 ## Tab 结构 @@ -22,12 +24,27 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页 ## 导航流程 1. `MainTabsView` 从 Environment 读取 `AppRouter`。 -2. `TabView` 使用 `appRouter.selectedTab` 作为选中状态。 -3. 每个 Tab 创建自己的 `NavigationStack(path:)`。 -4. 路径绑定来自 `appRouter.binding(for:)`。 -5. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 -6. `navigationDestination` 根据 `AppRoute` 展示详情页。 -7. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 +2. `MainTabsView` 根据 `MainTabBarConfiguration.activeStyle` 选择 `CustomMainTabsView` 或 `SystemMainTabsView`。 +3. 两套外壳都使用 `appRouter.selectedTab` 作为选中状态。 +4. 每个 Tab 通过 `TabNavigationStackHost` 创建自己的 `NavigationStack(path:)`。 +5. 路径绑定来自 `appRouter.binding(for:)`。 +6. Tab 内页面需要进入尚未迁移的子页面时,通过当前 Tab 注入的 `RouterPath` push 一个 `AppRoute.placeholder`。 +7. `navigationDestination` 根据 `AppRoute` 展示详情页。 +8. 子页面展示时读取 `AppRoute.hidesTabBarWhenPushed`,默认隐藏底部 TabBar。 + +## 自定义 TabBar + +自定义 TabBar 由 `CustomMainTabsView` 和 `CustomMainTabBar` 组成: +- `CustomMainTabsView` 负责保留已访问 Tab 页面、刷新订单角标、展示扫码页。 +- `CustomTabNavigationStackHost` 会把 `CustomMainTabBar` 拼在每个 Tab 的根页面内容下方。 +- `CustomMainTabBar` 只负责展示 UI,不直接读取账号、订单 API 或业务上下文。 +- `MainTabBadgeViewModel` 通过 `OrdersAPI.writeOffList` 获取待核销数量,失败时静默清空角标。 +- 中间扫码按钮使用订单模块已有的 `OrderScannerPage`。 +- 扫码成功后调用 `AppRouter.routeToOrderVerification(scannedCode:)`,订单页再通过 `consumePendingOrderScanCode()` 一次性消费结果。 +- 自定义 TabBar 只属于 Tab 根页面内容;当前 Tab 的导航栈 push 到二级页面后,目标页面不包含 TabBar,也不会保留底部占位高度。 +- 承载根页面和 `CustomMainTabBar` 的容器需要忽略键盘底部安全区,避免订单搜索框等输入控件唤起键盘时把自定义 TabBar 顶起。 + +系统 `TabView` 外壳由 `SystemMainTabsView` 保留。系统模式不显示中间扫码按钮,但订单页内部的扫码核销入口仍然可用。 ## 后续迁移规则 @@ -35,6 +52,8 @@ Main 模块负责登录后的主界面 Tab 容器,以及当前尚未迁移页 - 优先替换对应 Tab 的 RootView。 - 保持每个 Tab 自己的 `NavigationStack`。 - 跨 Tab 跳转通过 `AppRouter.select` 切换 Tab。 +- 需要从首页或全局入口进入订单核销时,优先使用 `AppRouter.selectOrders(entry:)` 或 `AppRouter.routeToOrderVerification(scannedCode:)`。 - Tab 内跳转通过当前 Tab 的 `RouterPath.navigate` 或扩展后的 `AppRoute` 处理。 - 普通业务子页面默认隐藏 TabBar;只有确有产品需求时,才在 `AppRoute` 策略中单独放开。 +- 不要把业务状态塞进自定义 TabBar 组件;TabBar 只接收绑定、文案和动作回调。 - 真实业务页面接入后,应同步补充该模块文档和单元测试。 diff --git a/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift b/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift new file mode 100644 index 0000000..cfea9be --- /dev/null +++ b/suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift @@ -0,0 +1,31 @@ +// +// MainTabBadgeViewModel.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import Foundation +import Observation + +@MainActor +@Observable +/// 主 Tab 角标 ViewModel,负责获取订单 Tab 待核销数量。 +final class MainTabBadgeViewModel { + private(set) var pendingWriteOffCount: Int? + + /// 刷新待核销订单数量,缺少景区或接口失败时清空角标。 + func refreshPendingWriteOffCount(api: OrderServing, scenicId: Int?, storeId: Int?) async { + guard let scenicId, scenicId > 0 else { + pendingWriteOffCount = nil + return + } + + do { + let result = try await api.writeOffList(scenicId: scenicId, storeId: storeId, page: 1, pageSize: 1) + pendingWriteOffCount = min(result.total, 99) + } catch { + pendingWriteOffCount = nil + } + } +} diff --git a/suixinkan/Features/Main/Views/CustomMainTabBar.swift b/suixinkan/Features/Main/Views/CustomMainTabBar.swift new file mode 100644 index 0000000..0234a85 --- /dev/null +++ b/suixinkan/Features/Main/Views/CustomMainTabBar.swift @@ -0,0 +1,155 @@ +// +// CustomMainTabBar.swift +// suixinkan +// +// Created by Codex on 2026/6/23. +// + +import SwiftUI + +/// 自定义主 TabBar,展示四个一级入口和中间扫码核销按钮。 +struct CustomMainTabBar: View { + @Binding var selectedTab: AppTab + + let orderBadgeText: String? + let onScanTap: () -> Void + + private let items = CustomMainTabItem.defaultItems + + var body: some View { + HStack(alignment: .center, spacing: 0) { + CustomMainTabBarItem( + item: items[0], + selectedTab: $selectedTab, + badgeText: nil + ) + CustomMainTabBarItem( + item: items[1], + selectedTab: $selectedTab, + badgeText: orderBadgeText + ) + CustomMainTabBarCenterAction(onScanTap: onScanTap) + .frame(width: 74) + CustomMainTabBarItem( + item: items[2], + selectedTab: $selectedTab, + badgeText: nil + ) + CustomMainTabBarItem( + item: items[3], + selectedTab: $selectedTab, + badgeText: nil + ) + } + .frame(maxWidth: .infinity) + .frame(height: 72) + .padding(.horizontal, 22) + .background(.white) + .overlay(alignment: .top) { + Rectangle() + .fill(Color.black.opacity(0.04)) + .frame(height: 0.5) + } + } +} + +/// 自定义 TabBar 单个入口的静态配置。 +private struct CustomMainTabItem { + let tab: AppTab + let title: String + let selectedImage: String + let unselectedImage: String + + static let defaultItems: [CustomMainTabItem] = [ + .init(tab: .home, title: "首页", selectedImage: "TabHomeSelected", unselectedImage: "TabHomeUnselected"), + .init(tab: .orders, title: "订单", selectedImage: "TabOrderSelected", unselectedImage: "TabOrderUnselected"), + .init(tab: .statistics, title: "数据", selectedImage: "TabDataSelected", unselectedImage: "TabDataUnselected"), + .init(tab: .profile, title: "我的", selectedImage: "TabProfileSelected", unselectedImage: "TabProfileUnselected") + ] +} + +/// 自定义 TabBar 的普通入口按钮。 +private struct CustomMainTabBarItem: View { + let item: CustomMainTabItem + @Binding var selectedTab: AppTab + let badgeText: String? + + private var isSelected: Bool { + selectedTab == item.tab + } + + var body: some View { + Button { + selectedTab = item.tab + } label: { + VStack(spacing: 3) { + ZStack(alignment: .topTrailing) { + Image(isSelected ? item.selectedImage : item.unselectedImage) + .renderingMode(.original) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + + if let badgeText { + CustomMainTabBadge(text: badgeText) + } + } + Text(item.title) + .font(.system(size: 12, weight: isSelected ? .medium : .regular)) + .foregroundStyle(isSelected ? AppDesign.primary : Color(hex: 0x7D8DA3)) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + .frame(height: 58) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(item.title) + } +} + +/// 自定义 TabBar 中间的扫码核销按钮。 +private struct CustomMainTabBarCenterAction: View { + let onScanTap: () -> Void + + var body: some View { + Button(action: onScanTap) { + ZStack { + Circle() + .fill(AppDesign.primary) + .frame(width: 58, height: 58) + Image(systemName: "qrcode.viewfinder") + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: 70, height: 64) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("扫码核销") + } +} + +/// 自定义 TabBar 的订单数量角标。 +private struct CustomMainTabBadge: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.white) + .frame(minWidth: 15, minHeight: 15) + .padding(.horizontal, 2) + .background(Color(hex: 0xEF4444), in: Capsule()) + .offset(x: 9, y: -7) + } +} + +#Preview { + CustomMainTabBar( + selectedTab: .constant(.orders), + orderBadgeText: "8", + onScanTap: {} + ) +} diff --git a/suixinkan/Features/Main/Views/MainTabsView.swift b/suixinkan/Features/Main/Views/MainTabsView.swift index 428534f..37599fd 100644 --- a/suixinkan/Features/Main/Views/MainTabsView.swift +++ b/suixinkan/Features/Main/Views/MainTabsView.swift @@ -7,8 +7,31 @@ import SwiftUI -/// 主 Tab 容器视图,为每个 Tab 创建独立的 NavigationStack。 +/// 主 TabBar 样式,决定登录后首页使用系统 TabView 还是自定义底部栏。 +enum MainTabBarStyle { + case system + case custom +} + +/// 主 TabBar 配置,后续需要切回系统 TabView 时只修改 activeStyle。 +enum MainTabBarConfiguration { + static let activeStyle: MainTabBarStyle = .custom +} + +/// 主 Tab 容器视图,根据配置选择系统 TabView 或自定义 TabBar 外壳。 struct MainTabsView: View { + var body: some View { + switch MainTabBarConfiguration.activeStyle { + case .system: + SystemMainTabsView() + case .custom: + CustomMainTabsView() + } + } +} + +/// 系统 TabView 外壳,保留原生 TabBar 行为以便后续切换回系统实现。 +private struct SystemMainTabsView: View { @Environment(AppRouter.self) private var appRouter var body: some View { @@ -16,15 +39,7 @@ struct MainTabsView: View { TabView(selection: $appRouter.selectedTab) { ForEach(AppTab.allCases) { tab in - NavigationStack(path: appRouter.binding(for: tab)) { - tab.rootView - .navigationTitle(tab.title) - .navigationDestination(for: AppRoute.self) { route in - route.destinationView - .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) - } - } - .environment(appRouter.router(for: tab)) + TabNavigationStackHost(tab: tab) .tabItem { tab.label } .tag(tab) } @@ -32,7 +47,158 @@ struct MainTabsView: View { } } +/// 自定义 TabBar 外壳,复用每个 Tab 独立 NavigationStack 并保留已访问页面状态。 +private struct CustomMainTabsView: View { + @Environment(AccountContext.self) private var accountContext + @Environment(AppRouter.self) private var appRouter + @Environment(OrdersAPI.self) private var ordersAPI + @Environment(ToastCenter.self) private var toastCenter + + @State private var badgeViewModel = MainTabBadgeViewModel() + @State private var showScanner = false + @State private var loadedTabs: Set = [.home] + + private var orderBadgeText: String? { + guard let count = badgeViewModel.pendingWriteOffCount, count > 0 else { + return nil + } + return "\(count)" + } + + var body: some View { + @Bindable var appRouter = appRouter + + ZStack { + ForEach(AppTab.allCases) { tab in + PreservedTabPage( + tab: tab, + selectedTab: appRouter.selectedTab, + isLoaded: loadedTabs.contains(tab) + ) { + CustomTabNavigationStackHost( + tab: tab, + selectedTab: $appRouter.selectedTab, + orderBadgeText: orderBadgeText, + onScanTap: { showScanner = true } + ) + } + } + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .fullScreenCover(isPresented: $showScanner) { + OrderScannerPage( + onClose: { showScanner = false }, + onSuccess: { rawCode in + showScanner = false + appRouter.routeToOrderVerification(scannedCode: rawCode) + }, + onFailure: { error in + showScanner = false + toastCenter.show(error.localizedDescription) + } + ) + } + .task { + loadedTabs.insert(appRouter.selectedTab) + await refreshOrderBadge() + } + .onChange(of: appRouter.selectedTab) { _, selectedTab in + loadedTabs.insert(selectedTab) + guard selectedTab == .orders else { return } + Task { await refreshOrderBadge() } + } + .onChange(of: accountContext.currentScenic?.id) { _, _ in + Task { await refreshOrderBadge() } + } + .onChange(of: accountContext.currentStore?.id) { _, _ in + Task { await refreshOrderBadge() } + } + } + + /// 刷新订单 Tab 待核销角标数量。 + private func refreshOrderBadge() async { + await badgeViewModel.refreshPendingWriteOffCount( + api: ordersAPI, + scenicId: accountContext.currentScenic?.id, + storeId: accountContext.currentStore?.id + ) + } +} + +/// 自定义 TabBar 模式下的单个 Tab 导航栈,TabBar 只属于根页面内容。 +private struct CustomTabNavigationStackHost: View { + @Environment(AppRouter.self) private var appRouter + + let tab: AppTab + @Binding var selectedTab: AppTab + let orderBadgeText: String? + let onScanTap: () -> Void + + var body: some View { + NavigationStack(path: appRouter.binding(for: tab)) { + VStack(spacing: 0) { + tab.rootView + .frame(maxWidth: .infinity, maxHeight: .infinity) + + CustomMainTabBar( + selectedTab: $selectedTab, + orderBadgeText: orderBadgeText, + onScanTap: onScanTap + ) + } + .ignoresSafeArea(.keyboard, edges: .bottom) + .navigationTitle(tab.title) + .navigationDestination(for: AppRoute.self) { route in + route.destinationView + .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) + } + } + .environment(appRouter.router(for: tab)) + } +} + +/// 单个 Tab 对应的导航栈,供系统和自定义 TabBar 外壳共同复用。 +private struct TabNavigationStackHost: View { + @Environment(AppRouter.self) private var appRouter + + let tab: AppTab + + var body: some View { + NavigationStack(path: appRouter.binding(for: tab)) { + tab.rootView + .navigationTitle(tab.title) + .navigationDestination(for: AppRoute.self) { route in + route.destinationView + .toolbar(route.hidesTabBarWhenPushed ? .hidden : .visible, for: .tabBar) + } + } + .environment(appRouter.router(for: tab)) + } +} + +/// 自定义 TabBar 中保持已加载 Tab 页面存活的容器。 +private struct PreservedTabPage: View { + let tab: AppTab + let selectedTab: AppTab + let isLoaded: Bool + @ViewBuilder let content: () -> Content + + var body: some View { + if isLoaded { + content() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(selectedTab == tab ? 1 : 0) + .zIndex(selectedTab == tab ? 1 : 0) + .allowsHitTesting(selectedTab == tab) + .accessibilityHidden(selectedTab != tab) + } + } +} + #Preview { MainTabsView() .environment(AppRouter()) + .environment(AccountContext()) + .environment(OrdersAPI(client: APIClient())) + .environment(ToastCenter()) } diff --git a/suixinkan/Features/Orders/Views/OrdersView.swift b/suixinkan/Features/Orders/Views/OrdersView.swift index 1ca1b78..d95ff3d 100644 --- a/suixinkan/Features/Orders/Views/OrdersView.swift +++ b/suixinkan/Features/Orders/Views/OrdersView.swift @@ -93,12 +93,16 @@ struct OrdersView: View { .task { viewModel.selectedEntry = appRouter.selectedOrdersEntry await reload() + await consumePendingScanCodeIfNeeded() } .onChange(of: appRouter.selectedOrdersEntry) { _, entry in guard viewModel.selectedEntry != entry else { return } viewModel.selectedEntry = entry Task { await reload() } } + .onChange(of: appRouter.pendingOrderScanCode) { _, _ in + Task { await consumePendingScanCodeIfNeeded() } + } .onChange(of: accountContext.currentScenic?.id) { _, _ in Task { await reload() } } @@ -575,6 +579,15 @@ struct OrdersView: View { } } + /// 消费主 TabBar 全局扫码结果,并复用订单页内部扫码核销处理。 + private func consumePendingScanCodeIfNeeded() async { + guard let rawCode = appRouter.consumePendingOrderScanCode() else { return } + viewModel.selectedEntry = .verificationOrders + appRouter.selectOrders(entry: .verificationOrders) + await reload(showLoading: false) + handleScanResult(rawCode) + } + /// 处理扫码返回的原始内容,并进入二次确认核销流程。 private func handleScanResult(_ rawCode: String) { guard let result = viewModel.matchedWriteOffOrder(for: rawCode) else { diff --git a/suixinkanTests/MainTabBadgeViewModelTests.swift b/suixinkanTests/MainTabBadgeViewModelTests.swift new file mode 100644 index 0000000..7e1be08 --- /dev/null +++ b/suixinkanTests/MainTabBadgeViewModelTests.swift @@ -0,0 +1,106 @@ +// +// MainTabBadgeViewModelTests.swift +// suixinkanTests +// +// Created by Codex on 2026/6/23. +// + +import XCTest +@testable import suixinkan + +@MainActor +/// 主 Tab 角标测试,覆盖待核销数量加载、封顶和失败清理。 +final class MainTabBadgeViewModelTests: XCTestCase { + /// 测试无景区时清空角标且不请求接口。 + func testMissingScenicClearsBadgeWithoutRequestingAPI() async { + let api = MockMainTabOrderService() + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: nil, storeId: 66) + + XCTAssertNil(viewModel.pendingWriteOffCount) + XCTAssertTrue(api.writeOffListCalls.isEmpty) + } + + /// 测试接口成功时展示待核销总数。 + func testSuccessfulRefreshStoresPendingWriteOffCount() async { + let api = MockMainTabOrderService() + api.writeOffResponse = ListPayload(total: 8, list: []) + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: 66) + + XCTAssertEqual(viewModel.pendingWriteOffCount, 8) + XCTAssertEqual(api.writeOffListCalls, [.init(scenicId: 88, storeId: 66, page: 1, pageSize: 1)]) + } + + /// 测试待核销数量超过 99 时角标封顶。 + func testSuccessfulRefreshCapsPendingWriteOffCountAtNinetyNine() async { + let api = MockMainTabOrderService() + api.writeOffResponse = ListPayload(total: 120, list: []) + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: nil) + + XCTAssertEqual(viewModel.pendingWriteOffCount, 99) + } + + /// 测试接口失败时清空角标。 + func testRefreshFailureClearsBadge() async { + let api = MockMainTabOrderService() + api.writeOffError = APIError.httpStatus(500, "server error") + let viewModel = MainTabBadgeViewModel() + + await viewModel.refreshPendingWriteOffCount(api: api, scenicId: 88, storeId: 66) + + XCTAssertNil(viewModel.pendingWriteOffCount) + XCTAssertEqual(api.writeOffListCalls.count, 1) + } +} + +@MainActor +/// 主 Tab 角标测试用订单服务,只实现角标依赖的核销列表记录。 +private final class MockMainTabOrderService: OrderServing { + struct WriteOffListCall: Equatable { + let scenicId: Int + let storeId: Int? + let page: Int + let pageSize: Int + } + + var writeOffResponse = ListPayload(total: 0, list: []) + var writeOffError: Error? + private(set) var writeOffListCalls: [WriteOffListCall] = [] + + /// 返回空订单管理列表,角标测试不会使用该方法。 + 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) async throws -> ListPayload { + writeOffListCalls.append(.init(scenicId: scenicId, storeId: storeId, page: page, pageSize: pageSize)) + if let writeOffError { + throw writeOffError + } + return writeOffResponse + } + + /// 角标测试不会触发核销操作。 + 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") + } +} diff --git a/suixinkanTests/NavigationRouterTests.swift b/suixinkanTests/NavigationRouterTests.swift index 2685043..25280ac 100644 --- a/suixinkanTests/NavigationRouterTests.swift +++ b/suixinkanTests/NavigationRouterTests.swift @@ -30,6 +30,38 @@ 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.pendingOrderScanCode, "order_number=VERIFY001") + } + + /// 测试全局扫码结果只能被消费一次。 + func testConsumePendingOrderScanCodeClearsValueAfterFirstRead() { + let router = AppRouter() + router.routeToOrderVerification(scannedCode: "VERIFY001") + + XCTAssertEqual(router.consumePendingOrderScanCode(), "VERIFY001") + XCTAssertNil(router.consumePendingOrderScanCode()) + } + + /// 测试重置主路由时会清理未消费的扫码结果。 + func testResetClearsPendingOrderScanCode() { + let router = AppRouter() + router.routeToOrderVerification(scannedCode: "VERIFY001") + + router.reset() + + XCTAssertEqual(router.selectedTab, .home) + XCTAssertEqual(router.selectedOrdersEntry, .storeOrders) + XCTAssertNil(router.pendingOrderScanCode) + } + /// 测试订单模块真实路由可被压入导航栈。 func testOrderRoutesCanBePushedIntoRouterPath() throws { let router = RouterPath() diff --git a/功能同步Checklist.md b/功能同步Checklist.md index 1b10280..7cfab20 100644 --- a/功能同步Checklist.md +++ b/功能同步Checklist.md @@ -1,6 +1,6 @@ # 功能同步 Checklist -更新时间:2026-06-22 +更新时间:2026-06-23 ## 状态说明 @@ -42,17 +42,18 @@ | 完成 | 常用应用 | 默认常用应用、添加/移除、UserDefaults 持久化、按权限过滤。 | 后续可加入拖拽排序。 | | 完成 | 更多功能 | 全部功能页、常用应用增删、首页入口跳转。 | 未迁移功能仍进入占位页。 | | 完成 | 首页路由壳 | 已迁移入口跳真实页面;订单/数据入口切换 Tab;未知入口安全占位。 | 按模块逐步替换占位页。 | -| 部分完成 | 景区选择入口 | 首页轻量景区选择,可调用 `AccountContext.selectScenic(id:)`。 | 如旧工程有完整筛选/搜索/权限细节,后续补齐。 | +| 完成 | 景区选择入口 | 旧工程同款景区选择页、搜索、定位距离、景区卡片、封面、地址、营业状态、切换景区并持久化。 | 后续如有景区选择视觉细节变化再微调。 | +| 完成 | 权限申请入口 | `permission_apply`、`permission_apply_status`、`scenicapplication` 已接真实页面。 | 景区结算仍按独立模块后续迁移。 | ## 订单 Tab | 状态 | 模块 | 同步内容 | 剩余事项 | | --- | --- | --- | --- | -| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续补完整详情流程。 | -| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 订单详情仍为占位。 | -| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 核销详情仍为占位。 | -| 部分完成 | 扫码核销 | 入口存在,进入安全占位。 | 迁移相机权限、扫码识别、核销闭环。 | -| 部分完成 | 订单详情 | 列表点击可进入占位详情。 | 迁移完整详情、退款、历史拍摄、任务上传、视频预告等长尾流程。 | +| 完成 | 订单 Tab 根页面 | 订单管理/核销订单分段入口。 | 后续继续补长尾订单流程。 | +| 完成 | 订单管理列表 | 景区上下文校验、状态筛选、手机号搜索、日期筛选、分页、刷新。 | 无。 | +| 完成 | 核销订单列表 | 核销列表、分页、刷新、手动输入订单号核销。 | 无。 | +| 完成 | 扫码核销 | AVFoundation 扫码、订单号解析、命中高亮、二次确认后核销。 | 后续可继续补扫码 UI 细节。 | +| 完成 | 订单详情 | 订单管理详情、核销订单详情、详情接口和列表摘要兜底。 | 退款、历史拍摄、任务上传、视频预告等长尾流程仍未迁移。 | | 未开始 | 退款流程 | 暂未迁移。 | 需要同步旧工程退款 UI、接口和状态流。 | | 未开始 | 历史拍摄 | 暂未迁移。 | 需要同步旧工程相关页面和接口。 | @@ -81,11 +82,11 @@ | 状态 | 模块 | 当前情况 | 后续事项 | | --- | --- | --- | --- | -| 部分完成 | 钱包 | 首页入口已识别,进入占位页。 | 迁移钱包首页、提现、流水、余额等流程。 | +| 完成 | 钱包 | 钱包首页、收益明细、提现记录、提现资格校验、提现申请、银行卡设置、积分兑换。 | `withdrawal_audit` 提现审核仍按独立管理模块后续迁移。 | | 部分完成 | 提现审核 | 首页入口已识别,进入占位页。 | 迁移审核列表、详情、操作接口。 | | 部分完成 | 消息中心 | 首页入口已识别,进入占位页。 | 迁移消息列表、已读状态和详情。 | | 部分完成 | 排队管理 | `/scenic-queue` / `queue_management` 已识别,进入占位页。 | 迁移队列列表、叫号、设置等流程。 | -| 部分完成 | 立即收款/收款码 | 入口已识别,进入占位页。 | 迁移收款码、收款记录、支付结果。 | +| 完成 | 立即收款/收款码 | 收款码、设置金额、动态二维码、保存二维码、收款记录、到账轮询、语音提醒。 | 后续可按业务继续优化收款反馈细节。 | | 部分完成 | 押金订单 | 入口已识别,进入占位页。 | 迁移押金订单详情和拍摄信息。 | | 部分完成 | 任务管理 | 入口已识别,进入占位页。 | 迁移任务列表、筛选、详情、状态操作。 | | 部分完成 | 发布任务 | 入口已识别,进入占位页。 | 迁移任务创建、选择订单、附件上传。 | @@ -97,8 +98,8 @@ | 部分完成 | 素材管理 | 入口已识别,OSS 上传服务已具备。 | 迁移素材列表、上传素材。 | | 部分完成 | 样片管理 | 入口已识别,OSS 上传服务已具备。 | 迁移样片列表、上传样片。 | | 部分完成 | 直播管理 | 入口已识别,进入占位页。 | 迁移直播管理和直播相册。 | -| 部分完成 | 景区申请 | 入口已识别,OSS 上传服务已具备。 | 迁移申请表单、图片上传、提交状态。 | -| 部分完成 | 权限申请 | 入口已识别,进入占位页。 | 迁移权限申请、申请状态。 | +| 完成 | 景区申请 | 景区申请表单、地区选择、合作类型、图片选择、OSS 上传、待审核/驳回状态回填。 | 景区结算不属于本模块,后续单独迁移。 | +| 完成 | 权限申请 | 角色权限申请、景区多选、已有权限禁用、申请状态页、驳回编辑入口。 | 后续如有附件上传入口再补。 | | 部分完成 | 景区结算 | 入口已识别,进入占位页。 | 迁移结算列表、审核、详情。 | | 部分完成 | 运营区域 | 入口已识别,进入占位页。 | 迁移区域列表、地图/范围配置。 | | 部分完成 | 位置上报 | 首页已有位置上报卡;具体页面入口进入占位。 | 迁移上报提交、历史记录、定位权限。 | @@ -128,8 +129,8 @@ xcodebuild build -project suixinkan.xcodeproj -scheme suixinkan -destination 'ge ## 下一批建议迁移顺序 -1. 订单详情与扫码核销:订单 Tab 已具备入口和列表,继续补闭环收益最大。 -2. 任务管理/发布任务:已具备 OSS 上传服务,可复用当前上传层。 -3. 相册云盘/素材管理:上传能力已准备好,适合接着迁移文件类模块。 -4. 钱包/提现/结算:涉及资金流,建议单独一轮迁移并补足单元测试。 -5. 打卡点管理/位置上报:依赖当前景区和景点上下文,适合在账号上下文稳定后推进。 +1. 任务管理/发布任务:已具备 OSS 上传服务,可复用当前上传层。 +2. 相册云盘/素材管理:上传能力已准备好,适合接着迁移文件类模块。 +3. 打卡点管理/位置上报:依赖当前景区和景点上下文,当前上下文已经稳定。 +4. 押金订单/退款/历史拍摄:订单主链路已完成,适合继续补订单长尾。 +5. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。