Add custom main tab bar with global scan-to-verify flow.

Introduce configurable custom TabBar with order badge, central scanner routing to verification orders, and update the migration checklist for recently completed modules.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-23 10:52:45 +08:00
parent 3a11583729
commit 1d1eb46ed8
25 changed files with 740 additions and 34 deletions

View File

@ -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() }
}
}

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View 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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -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 只接收绑定、文案和动作回调。
- 真实业务页面接入后,应同步补充该模块文档和单元测试。

View File

@ -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
}
}
}

View 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: {}
)
}

View File

@ -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())
}

View File

@ -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 {

View File

@ -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<WriteOffOrderItem>(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<WriteOffOrderItem>(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<WriteOffOrderItem>(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<OrderEntity> {
ListPayload(total: 0, list: [])
}
///
func writeOffList(scenicId: Int, storeId: Int?, page: Int, pageSize: Int) async throws -> ListPayload<WriteOffOrderItem> {
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")
}
}

View File

@ -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()

View File

@ -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. 提现审核/景区结算:涉及审核和资金流,建议单独一轮迁移并补足单元测试