新增自定义主 TabBar 与全局扫码核销流程
引入可配置的自定义 TabBar(含订单角标)、中央扫码入口跳转核销订单页,并更新迁移清单中最近完成的模块。 Co-authored-by: Cursor <cursoragent@cursor.com>
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
21
suixinkan/Assets.xcassets/TabDataSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabDataSelected.imageset/tab_data_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
21
suixinkan/Assets.xcassets/TabDataUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabDataUnselected.imageset/tab_data_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
21
suixinkan/Assets.xcassets/TabHomeSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabHomeSelected.imageset/tab_home_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
21
suixinkan/Assets.xcassets/TabHomeUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabHomeUnselected.imageset/tab_home_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
21
suixinkan/Assets.xcassets/TabOrderSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabOrderSelected.imageset/tab_order_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
21
suixinkan/Assets.xcassets/TabOrderUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabOrderUnselected.imageset/tab_order_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
21
suixinkan/Assets.xcassets/TabProfileSelected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabProfileSelected.imageset/tab_profile_selected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
21
suixinkan/Assets.xcassets/TabProfileUnselected.imageset/Contents.json
vendored
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
BIN
suixinkan/Assets.xcassets/TabProfileUnselected.imageset/tab_profile_unselected.png
vendored
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
@ -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 只接收绑定、文案和动作回调。
|
||||
- 真实业务页面接入后,应同步补充该模块文档和单元测试。
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
155
suixinkan/Features/Main/Views/CustomMainTabBar.swift
Normal file
@ -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: {}
|
||||
)
|
||||
}
|
||||
@ -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<AppTab> = [.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<Content: View>: 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())
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||