From 5541a0b1068f4a39154cee1bb83aa1f02966b823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Tue, 23 Jun 2026 10:52:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E4=B8=BB=20TabBar=20=E4=B8=8E=E5=85=A8=E5=B1=80=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E6=A0=B8=E9=94=80=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引入可配置的自定义 TabBar(含订单角标)、中央扫码入口跳转核销订单页,并更新迁移清单中最近完成的模块。 Co-authored-by: Cursor --- .../App/Navigation/NavigationRouter.swift | 15 ++ .../TabDataSelected.imageset/Contents.json | 21 ++ .../tab_data_selected.png | Bin 0 -> 2008 bytes .../TabDataUnselected.imageset/Contents.json | 21 ++ .../tab_data_unselected.png | Bin 0 -> 2358 bytes .../TabHomeSelected.imageset/Contents.json | 21 ++ .../tab_home_selected.png | Bin 0 -> 2075 bytes .../TabHomeUnselected.imageset/Contents.json | 21 ++ .../tab_home_unselected.png | Bin 0 -> 2404 bytes .../TabOrderSelected.imageset/Contents.json | 21 ++ .../tab_order_selected.png | Bin 0 -> 1246 bytes .../TabOrderUnselected.imageset/Contents.json | 21 ++ .../tab_order_unselected.png | Bin 0 -> 1403 bytes .../TabProfileSelected.imageset/Contents.json | 21 ++ .../tab_profile_selected.png | Bin 0 -> 1649 bytes .../Contents.json | 21 ++ .../tab_profile_unselected.png | Bin 0 -> 1922 bytes suixinkan/Features/Main/Main.md | 35 +++- .../ViewModels/MainTabBadgeViewModel.swift | 31 +++ .../Main/Views/CustomMainTabBar.swift | 155 +++++++++++++++ .../Features/Main/Views/MainTabsView.swift | 186 +++++++++++++++++- .../Features/Orders/Views/OrdersView.swift | 13 ++ .../MainTabBadgeViewModelTests.swift | 106 ++++++++++ suixinkanTests/NavigationRouterTests.swift | 32 +++ 功能同步Checklist.md | 33 ++-- 25 files changed, 740 insertions(+), 34 deletions(-) create mode 100644 suixinkan/Assets.xcassets/TabDataSelected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png create mode 100644 suixinkan/Assets.xcassets/TabDataUnselected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png create mode 100644 suixinkan/Assets.xcassets/TabHomeSelected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png create mode 100644 suixinkan/Assets.xcassets/TabHomeUnselected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png create mode 100644 suixinkan/Assets.xcassets/TabOrderSelected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png create mode 100644 suixinkan/Assets.xcassets/TabOrderUnselected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png create mode 100644 suixinkan/Assets.xcassets/TabProfileSelected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png create mode 100644 suixinkan/Assets.xcassets/TabProfileUnselected.imageset/Contents.json create mode 100644 suixinkan/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png create mode 100644 suixinkan/Features/Main/ViewModels/MainTabBadgeViewModel.swift create mode 100644 suixinkan/Features/Main/Views/CustomMainTabBar.swift create mode 100644 suixinkanTests/MainTabBadgeViewModelTests.swift 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 0000000000000000000000000000000000000000..25a61ceeb9c1a53cd60c153e46582d5b5a81f78e GIT binary patch literal 2008 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9c24f0628e0fe9deb2c55757bc70aa40faaa1762 GIT binary patch literal 1922 zcmV-|2YvX7P)wmZo#cxywa$i6HOHV3+W$hR;U8fH<8mTpG@<%U8=d0pti?Dh;FAluIY zKclMFBjQX1m`dA8WEp@k0TAXQU{SXZ`KAC5RMa#+-q7&9@F6#6nxlZGoX0Zcum=E3 z$)*8-i_EH#50Wpg$ z(rLo7$>+ma@e1`eUJD@8h)f3#IlTqc@g3(dhk6ppYJlxs4XV~v~ef{tLy z0oBlgNsH%v9mz)5-fIjEv6TY~1Dx$Oc?Z4> zci~CZVUz>fRFwZM0B=*BsI?w%%Q8BDwf3{;p&Zb)@F?ySUfXo&FI|%zE-6{wt~#6+ zK*(U1>O?K{I$Vai&B7<9ZY_XJ2p>g+Z(_#1i>DWQnWZKsvj<7!q@gqdK`=sIjpaqb~Ge8UG z)rjk5T0D=@=LnhV77^N=6vN|jUr{aIqo)m!2oNZF?r#=0U!?5=WGW#Etph?^#2{^L zO)DUgbyZ2;DGZyh(7DkO@mYn@%sDPJg|-U4Xd2o90j&*HYnJ|2?a7#DUk+{p%xSr>V4yWL>lioupqzzN77p z!bkYFre5^<3*A48BP4zASaTFmPgQkv)Tu7YKU?L=JLvc16;~Iz=aEgU_P8_2hWI`1 zY5tq4J+gUzkLlFqmo ze8muU(|J(+Z4LN;`otK6v15>8{2{c2W<_mi)w1(&{$rLf86&h=wazJH)T~j-T z-?xSh9k$bqz}HmRR=Uo929*Aoh*?6DP8_$IXdHH3y|TS4{r;6AHYk261JwQGD(yCm znR^+7jg;rpWJAexw+uSjTuieufGo1x8*Eyw@`>2E0g2~wyur|{?DouklqBsMFp-Ej z`XnmF6Kxm*P61%`sfbPGHBArre4o>gt)eS-iwn@JTec)m{?p8*%Y-IMp${Hdps2kx zx=dLD2K%%#t#z5t_g-AHI4(fx;nAfC>>WUu0R8NR0QVrm=B|v?H4w)uCO~55R0La! zC~hTL23db?cYq0%v;?ddImeQuZ9rnbPU{+#OABK~ECKsyOkrAhV?xpw864 zrUIgPAiO`%oLf0$AZPwb=OVUGGHn48hjMfobYp=-sU7kD=u3Aii@Ix(OimgQ=lCRPjwc)F?Jg>M z9_WIcfa>YGZ7XP2X>^$WGV%ScfknSz2*>dYBI8p``8P} z;o)SKK0;=Y6rfHsB6h92WdsL^51p!986}ge6d(rdF%nBA8$^vgB~y$PAOxH)BQ}tH zWNVTF#5s%}NX5(KYI2aez?K38*mqCQ%M=@n52D;ZT?!EAILOu?y0yn4T?VWaAfhcE zf|Ai2Fg{e%ahYW0009I@>j^mHcx^^myD`@aAH0RR78u60oW000I_L_t&o0AhlFuPZV@NB{r;07*qo IM6N<$f_x%^tN;K2 literal 0 HcmV?d00001 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. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试。